Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 6 additions & 4 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,14 +645,16 @@ services:
--health-retries 10
```

Services support `image`, `credentials`, `env`, `ports`, `volumes`, `options`, `command`, and `entrypoint`.
Job containers support `image`, `env`, `ports`, `volumes`, and `options`. Services support `image`, `credentials`, `env`, `ports`, `volumes`, `options`, `command`, and `entrypoint`.

- Job container images can use compile-time `github`, `inputs`, `vars`, `strategy`, and `matrix` values. The complete image must resolve to a non-empty string and a valid image reference during compilation. Secrets, `needs`, step outputs, and whole or dynamic contexts are unsupported.
- Job container volumes accept `DESTINATION` for an anonymous volume or `SOURCE:DESTINATION[:ro|rw]` for a named volume or bind mount. `DESTINATION` must be absolute. `SOURCE` must be a Docker volume name or absolute host path. A job can define 128 unique declarations. Expressions are unsupported.
- Job container options pass through to `docker create`, except `--network`, `--net`, and `--entrypoint`, including their `--flag=value` forms. Options split into arguments without a shell. Double quotes group arguments; single quotes are ordinary characters. Expressions, line breaks, NUL bytes, and values over 65,536 bytes are unsupported.
- Service fields can use compile-time `github`, `inputs`, `vars`, `strategy`, and `matrix` values or runtime `needs` outputs. An empty evaluated image skips the service.
- A complete non-credential service map can use `${{ fromJSON(needs.<job>.outputs.<name>) }}`. Declare credentials statically so the compiler can prove their secret authority.
- Credentials accept direct values and `github`, `vars`, `secrets`, or `env` expressions. Passwords pass to `docker login` through standard input. Authentication uses a private per-job Docker configuration and never reads ambient Docker credentials.
- Docker options pass through except `--network` and its `--net` aliases, which GitHub Actions does not support. Options can grant privileges, mount host paths, publish ports, and change resource settings.
- Named, anonymous, and absolute bind volumes are supported.
- Service Docker options pass through except `--network` and its `--net` aliases, which GitHub Actions does not support. Service options can grant privileges, mount host paths, publish ports, and change resource settings.
- Service named, anonymous, and absolute bind volumes are supported.
- A job can define 32 services. Each service can define 256 environment entries and 128 ports or volumes.

Implicit GHCR authentication is unsupported; provide explicit credentials. Mutable tags resolve at job start. Use a digest when image immutability matters. Job container images must provide `sh` and run the mounted self-contained Linux runtime executable.
Expand All @@ -661,7 +663,7 @@ Each job uses a private Docker bridge network. Container jobs reach services by

A service with a Docker health check must become healthy before steps run. A service without one is ready after it starts. Failures include bounded status, health, port, and log diagnostics.

Cleanup removes the job container, emits masked and bounded service logs, then removes services in declaration order, the network, newly created volumes, and private Docker configuration. Remaining owned resources fail the job. Docker resources are not a security or resource-isolation boundary: the hosted queue must isolate the whole job and enforce host CPU, memory, disk, and network limits. See the [security model](security.md#isolate-the-whole-job).
Cleanup removes the job container, emits masked and bounded service logs, then removes services in declaration order, the network, volumes created during the job, and private Docker configuration. Pre-existing named volumes can be attached but are not removed. Remaining owned resources fail the job. Docker resources are not a security or resource-isolation boundary: the hosted queue must isolate the whole job and enforce host CPU, memory, disk, and network limits. See the [security model](security.md#isolate-the-whole-job).

macOS jobs reject containers, services, Docker actions, and Docker capability.

Expand Down
10 changes: 7 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ Run untrusted jobs on a queue with:
- a clean environment for every job
- host-level CPU, memory, disk, and network limits

Service options can grant privileges, mount host paths, and publish ports. The
private Docker network, ownership labels, and cleanup checks reduce accidental
residue. They do not contain hostile code.
Job and service container options can grant privileges, mount host paths,
publish ports, and override Docker settings. Job container options cannot
override the runner-owned network or entrypoint, but other Docker create
options pass through. Job container volumes accept named volumes, anonymous
volumes, and absolute host bind mounts. The private Docker network, ownership
labels, and cleanup checks reduce accidental residue. They do not contain
hostile code.

On a persistent self-hosted agent, workflow code can read exposed host
resources and leave state for later jobs.
Expand Down
8 changes: 6 additions & 2 deletions internal/compiler/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ jobs:
strategy:
matrix:
image: [node:24, node:25]
container: ${{ matrix.image }}
container:
image: ${{ matrix.image }}
volumes: [cache:/cache:ro, /anonymous, /srv/data:/data]
options: --privileged --label "description=two words"
steps: [{run: true}]
`)
event := readFile(t, smokePath("events", "push.json"))
Expand All @@ -84,7 +87,8 @@ jobs:
t.Fatalf("container bundle was not deterministic: %#v", first)
}
for i, image := range []string{"node:24", "node:25"} {
if first.Plans[i].Job.Container == nil || first.Plans[i].Job.Container.Image != image || bytes.Contains(first.Plans[i].Contents, []byte("${{")) {
container := first.Plans[i].Job.Container
if container == nil || container.Image != image || !slices.Equal(container.Volumes, []string{"cache:/cache:ro", "/anonymous", "/srv/data:/data"}) || container.Options != `--privileged --label "description=two words"` || bytes.Contains(first.Plans[i].Contents, []byte("${{")) {
t.Fatalf("plan %d container = %#v", i, first.Plans[i].Job.Container)
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/compiler/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ func resolveCompileContainer(container *workflow.Container, context expression.C
resolved := *container
resolved.Env = cloneMap(container.Env)
resolved.Ports = append([]string(nil), container.Ports...)
resolved.Volumes = append([]string(nil), container.Volumes...)
if strings.Contains(resolved.Image, "${{") {
image, err := expression.EvaluateCompileStringTemplate(resolved.Image, context)
if err != nil {
Expand Down
5 changes: 3 additions & 2 deletions internal/compiler/compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4173,14 +4173,15 @@ func readFile(t *testing.T, path string) []byte {
}

func TestCompilePlansEmitV8ForContainers(t *testing.T) {
workflowSource := []byte("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n container: node:24\n services:\n redis: {image: redis:7}\n steps:\n - run: true\n")
workflowSource := []byte("on: push\njobs:\n test:\n runs-on: ubuntu-latest\n container:\n image: node:24\n volumes: ['cache:/cache:ro', '/anonymous', '/srv/data:/data']\n options: --privileged --label \"description=two words\"\n services:\n redis: {image: redis:7}\n steps:\n - run: true\n")
plans, err := compileUntrustedPlans("containers.yml", workflowSource, readFile(t, smokePath("events", "push.json")), "0.0.0-test", "sha256:"+strings.Repeat("1", 64), "gha-untrusted")
if err != nil {
t.Fatal(err)
}
if len(plans) != 1 || plans[0].Schema != plan.Schema || plans[0].Container == nil || len(plans[0].Services) != 1 || !slices.Equal(plans[0].RequiredCapabilities, []string{"docker", "network"}) {
if len(plans) != 1 || plans[0].Schema != plan.Schema || plans[0].Container == nil || !slices.Equal(plans[0].Container.Volumes, []string{"cache:/cache:ro", "/anonymous", "/srv/data:/data"}) || plans[0].Container.Options != `--privileged --label "description=two words"` || len(plans[0].Services) != 1 || !slices.Equal(plans[0].RequiredCapabilities, []string{"docker", "network"}) {
t.Fatalf("container plan = %#v", plans)
}
validateCompiledPlansAgainstSchema(t, plans)
}

func TestCompilePlansResolveJobContainerImageExpressions(t *testing.T) {
Expand Down
17 changes: 12 additions & 5 deletions internal/compiler/plan_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,20 @@ func (b planBuilder) reducePlanInstanceEventExpressions(instance JobInstance) (J

if instance.Container != nil {
container := *instance.Container
if container.Image, err = reduceTemplate(container.Image); err != nil {
return JobInstance{}, err
for _, field := range []*string{&container.Image, &container.Options} {
if *field, err = reduceTemplate(*field); err != nil {
return JobInstance{}, err
}
}
if container.Env, err = reduceMap(container.Env); err != nil {
return JobInstance{}, err
}
if container.Ports, err = reduceSlice(container.Ports); err != nil {
return JobInstance{}, err
}
if container.Volumes, err = reduceSlice(container.Volumes); err != nil {
return JobInstance{}, err
}
instance.Container = &container
}

Expand Down Expand Up @@ -631,9 +636,11 @@ func (b planBuilder) lowerPlanJob(instance JobInstance, workflowProgram program.
job.RequiresMise = &actions.requiresMise
if programJob.Container != nil {
job.Container = &plan.Container{
Image: programJob.Container.Image.Source,
Env: programBindingMap(programJob.Container.Env),
Ports: programSiteSources(programJob.Container.Ports),
Image: programJob.Container.Image.Source,
Env: programBindingMap(programJob.Container.Env),
Ports: programSiteSources(programJob.Container.Ports),
Volumes: programSiteSources(programJob.Container.Volumes),
Options: programJob.Container.Options.Source,
}
}
if programJob.Services.Dynamic != nil {
Expand Down
8 changes: 5 additions & 3 deletions internal/compiler/workflow_program.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ func lowerWorkflowProgram(instance JobInstance) program.Program {
}
if instance.Container != nil {
result.Job.Container = &program.Container{
Image: workflowSite(instance.Container.Image, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.image"),
Env: workflowBindings(instance.Container.Env, program.SurfaceRuntimeTemplate, jobLocation, "job.container.env", program.PurposeExpression),
Ports: workflowSites(instance.Container.Ports, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.ports"),
Image: workflowSite(instance.Container.Image, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.image"),
Env: workflowBindings(instance.Container.Env, program.SurfaceRuntimeTemplate, jobLocation, "job.container.env", program.PurposeExpression),
Ports: workflowSites(instance.Container.Ports, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.ports"),
Volumes: workflowSites(instance.Container.Volumes, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.volumes"),
Options: workflowSite(instance.Container.Options, program.SurfaceRuntimeTemplate, program.ResultString, jobLocation, "job.container.options"),
}
}
if len(instance.Services) != 0 {
Expand Down
5 changes: 4 additions & 1 deletion internal/compiler/workflow_program_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ func TestWorkflowProgramInventoriesEveryExecutionField(t *testing.T) {
DefaultShell: "job-shell",
DefaultWorkingDirectory: "job-directory",
Container: &workflow.Container{
Image: "container-image", Env: map[string]string{"C": "container-env"}, Ports: []string{"container-port"}, Span: span,
Image: "container-image", Env: map[string]string{"C": "container-env"}, Ports: []string{"container-port"},
Volumes: []string{"container-volume"}, Options: "container-options", Span: span,
},
Services: []workflow.Service{{Name: "db", Container: workflow.ServiceContainer{
Image: "service-image", Env: map[string]string{"S": "service-env"}, Ports: []string{"service-port"}, Volumes: []string{"service-volume"},
Expand Down Expand Up @@ -53,6 +54,8 @@ func TestWorkflowProgramInventoriesEveryExecutionField(t *testing.T) {
"job.container.image|runtime-template|string|expression",
"job.container.env.C|runtime-template|string|expression",
"job.container.ports[0]|runtime-template|string|expression",
"job.container.volumes[0]|runtime-template|string|expression",
"job.container.options|runtime-template|string|expression",
"job.services.db.image|runtime-template|string|expression",
"job.services.db.credentials.username|service-credential|string|expression",
"job.services.db.credentials.password|service-credential|string|expression",
Expand Down
141 changes: 141 additions & 0 deletions internal/containerpolicy/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Package containerpolicy owns the job-container Docker syntax that workflows
// may control.
package containerpolicy

import (
"fmt"
"path"
"regexp"
"strings"
)

const MaxJobVolumes = 128
const MaxJobOptionsLength = 65536

var volumeNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)

// JobOptions splits options using the GitHub runner's argument rules and
// rejects the network and entrypoint overrides that GitHub does not support.
// It returns the exact Docker argv without invoking a shell.
func JobOptions(value string) ([]string, error) {
if len(value) > MaxJobOptionsLength || strings.ContainsAny(value, "\x00\r\n") {
return nil, fmt.Errorf("options exceed %d bytes or contain a control character", MaxJobOptionsLength)
}
args := ArgumentList(value)
for _, arg := range args {
for _, unsupported := range []string{"--network", "--net", "--entrypoint"} {
if arg == unsupported || strings.HasPrefix(arg, unsupported+"=") {
return nil, fmt.Errorf("option %q is unsupported", arg)
}
}
}
return args, nil
}

// ValidateJobVolume accepts GitHub's named, anonymous, and absolute host-bind
// volume syntax with an absolute container destination.
func ValidateJobVolume(value string) error {
if value == "" || len(value) > 4096 || hasASCIIControl(value) {
return fmt.Errorf("volume is empty, too long, or contains a control character")
}
parts := strings.Split(value, ":")
var source, target, mode string
switch len(parts) {
case 1:
target = parts[0]
case 2:
source, target = parts[0], parts[1]
if source == "" {
return fmt.Errorf("volume source is empty")
}
case 3:
source, target, mode = parts[0], parts[1], parts[2]
if source == "" {
return fmt.Errorf("volume source is empty")
}
default:
return fmt.Errorf("volume must be DESTINATION or SOURCE:DESTINATION[:ro|rw]")
}
if source != "" && !path.IsAbs(source) && !volumeNamePattern.MatchString(source) {
return fmt.Errorf("volume source %q must be a name or absolute host path", source)
}
if !path.IsAbs(target) {
return fmt.Errorf("volume target %q must be an absolute path", target)
}
if mode != "" && mode != "ro" && mode != "rw" {
return fmt.Errorf("volume mode %q is unsupported", mode)
}
return nil
}

// ValidateJobVolumes applies the bounded list contract used by plans and the
// runtime boundary.
func ValidateJobVolumes(values []string) error {
if len(values) > MaxJobVolumes {
return fmt.Errorf("more than %d volumes", MaxJobVolumes)
}
seen := map[string]bool{}
for _, value := range values {
if seen[value] {
Comment thread
lox marked this conversation as resolved.
return fmt.Errorf("volume %q is repeated", value)
}
seen[value] = true
if err := ValidateJobVolume(value); err != nil {
return fmt.Errorf("invalid volume %q: %w", value, err)
}
}
return nil
}

func hasASCIIControl(value string) bool {
return strings.ContainsFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f })
}

// ArgumentList matches the argument splitting used by the pinned
// actions/runner ProcessStartInfo.Arguments path. Single quotes are ordinary
// characters; double quotes group arguments; backslashes only escape quotes.
func ArgumentList(value string) []string {
Comment thread
lox marked this conversation as resolved.
var args []string
for i := 0; i < len(value); {
for i < len(value) && (value[i] == ' ' || value[i] == '\t') {
i++
}
if i == len(value) {
break
}
var arg strings.Builder
quoted := false
for i < len(value) {
if !quoted && (value[i] == ' ' || value[i] == '\t') {
break
}
backslashes := 0
for i < len(value) && value[i] == '\\' {
backslashes++
i++
}
copyCharacter := true
if i < len(value) && value[i] == '"' {
if backslashes%2 == 0 {
if quoted && i+1 < len(value) && value[i+1] == '"' {
i++
} else {
copyCharacter = false
quoted = !quoted
}
}
backslashes /= 2
}
arg.WriteString(strings.Repeat("\\", backslashes))
if i == len(value) || !quoted && (value[i] == ' ' || value[i] == '\t') {
break
}
if copyCharacter {
arg.WriteByte(value[i])
}
i++
}
args = append(args, arg.String())
}
return args
}
84 changes: 84 additions & 0 deletions internal/containerpolicy/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package containerpolicy

import (
"slices"
"strings"
"testing"
)

func TestJobOptionsAcceptedSyntax(t *testing.T) {
value := `--privileged --user 1000 --label "description=two words" --mount type=tmpfs,dst=/tmp --name=custom`
want := []string{"--privileged", "--user", "1000", "--label", "description=two words", "--mount", "type=tmpfs,dst=/tmp", "--name=custom"}
got, err := JobOptions(value)
if err != nil || !slices.Equal(got, want) {
t.Fatalf("JobOptions(%q) = %#v, %v; want %#v", value, got, err, want)
}
}

func TestJobOptionsRejectsUnsupportedOverrides(t *testing.T) {
for _, value := range []string{
"--network host", "--network=host", "--net host", "--net=host",
"--entrypoint sh", "--entrypoint=sh",
} {
t.Run(value, func(t *testing.T) {
if _, err := JobOptions(value); err == nil {
t.Fatal("JobOptions() accepted an unsupported override")
}
})
}
}

func TestJobOptionsInputBounds(t *testing.T) {
if _, err := JobOptions(strings.Repeat("x", MaxJobOptionsLength)); err != nil {
t.Fatalf("JobOptions() rejected the maximum input length: %v", err)
}
for _, value := range []string{strings.Repeat("x", MaxJobOptionsLength+1), "--label=a\n--privileged", "--label=a\x00b"} {
if _, err := JobOptions(value); err == nil {
t.Fatal("JobOptions() accepted out-of-bounds input")
}
}
}

func TestArgumentList(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name, value string
want []string
}{
{name: "empty argument", value: `--env ""`, want: []string{"--env", ""}},
{name: "double quotes", value: `--health-cmd "pg_isready -U postgres"`, want: []string{"--health-cmd", "pg_isready -U postgres"}},
{name: "single quotes are literal", value: `--label 'two words'`, want: []string{"--label", "'two", "words'"}},
{name: "escaped quote", value: `one\"two`, want: []string{`one"two`}},
{name: "unmatched quote", value: `"two words`, want: []string{"two words"}},
{name: "newline is literal", value: "one\ntwo", want: []string{"one\ntwo"}},
{name: "consecutive quotes", value: `"one""two"`, want: []string{`one"two`}},
} {
t.Run(test.name, func(t *testing.T) {
if got := ArgumentList(test.value); !slices.Equal(got, test.want) {
t.Fatalf("ArgumentList(%q) = %#v; want %#v", test.value, got, test.want)
}
})
}
}

func TestValidateJobVolume(t *testing.T) {
for _, value := range []string{
"v:/data", "cache:/cache", "cache.v1:/var/cache:ro", "CACHE_1:/data:rw",
"/anonymous", "/srv/cache:/cache", "/srv/cache:/cache:rw",
} {
if err := ValidateJobVolume(value); err != nil {
t.Errorf("ValidateJobVolume(%q) = %v", value, err)
}
}
for _, value := range []string{"-bad:/data", "relative/path:/data", "cache:data", "cache:/data:z", ":/data", "/anonymous:ro"} {
if err := ValidateJobVolume(value); err == nil {
t.Errorf("ValidateJobVolume(%q) accepted invalid volume", value)
}
}
if err := ValidateJobVolumes([]string{"cache:/one", "cache:/one"}); err == nil {
t.Error("ValidateJobVolumes() accepted a repeated volume")
}
if err := ValidateJobVolumes([]string{"one:/cache", "two:/cache"}); err != nil {
t.Errorf("ValidateJobVolumes() rejected Docker-owned duplicate target validation: %v", err)
}
}
Loading