Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Label worker-owned Docker containers with optional run and command IDs so supervisors can clean up after cancellation. Thanks @jesse-merhi (#55).
- Add `--platform` to build smaller npm tarballs for one supported operating system and architecture while retaining the default universal package.
- Preserve every scanner report when sanitized target, profile, or custom scanner names collide with each other or generated numeric suffixes.
- Fix unbounded memory use when loading benchmark `--ids` from files or HTTP, including whitespace-padded IDs; document selection limits and preserve full-set JSONL support. Thanks @SebTardif (#47).
1 change: 1 addition & 0 deletions cmd/clawscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,7 @@ Required environment variables:
cisco: no ClawScan-required env vars; optional upstream env vars enable LLM, VirusTotal, and Cisco AI Defense analyzers.
judge: provider credentials belong to the command passed to --judge.
sandbox: CLAWSCAN_SANDBOX=off disables Docker by default; CLAWSCAN_SANDBOX_IMAGE overrides the runtime image.
Process supervisors can set CLAWSCAN_SANDBOX_RUN_ID to identify only that process's Docker containers for cleanup.

Target notes:
No target with --scanner, --profile, or --config scans child skill directories under ./skills.
Expand Down
1 change: 1 addition & 0 deletions cmd/clawscan/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func TestRunCommandPrintsHelp(t *testing.T) {
"cisco: no ClawScan-required env vars",
"CLAWSCAN_SANDBOX=off",
"CLAWSCAN_SANDBOX_IMAGE",
"CLAWSCAN_SANDBOX_RUN_ID",
"No target with --scanner, --profile, or --config scans child skill directories under ./skills",
"--judge <cmd>",
"{{ workspace }}",
Expand Down
10 changes: 10 additions & 0 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@ Use `--sandbox off` only in an already-isolated environment, or when you have
installed scanner dependencies on the host with `clawscan install`. Use
`--sandbox-env <NAME>` or a profile `sandbox.env` list to pass judge-specific
environment variables into the container.

## Worker cleanup

A supervising worker can set `CLAWSCAN_SANDBOX_RUN_ID` to a fresh random ID
(1-64 letters, digits, dots, underscores or hyphens; start with a letter or digit).
ClawScan labels each container with `org.openclaw.clawscan.run-id` and a random
`org.openclaw.clawscan.command-id`. After terminating ClawScan, the worker owns
cleanup: select containers by the exact run label, verify their ownership labels,
and remove them by container ID, including containers created after cancellation.
Docker's `--rm` handles normal exits; ClawScan itself does not sweep containers.
32 changes: 19 additions & 13 deletions internal/runner/sandbox.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package runner

import (
"crypto/rand"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"time"
Expand All @@ -14,9 +17,15 @@ const (
SandboxModeDocker = "docker"
SandboxModeOff = "off"

SandboxRunIDEnv = "CLAWSCAN_SANDBOX_RUN_ID"
SandboxRunIDLabel = "org.openclaw.clawscan.run-id"
SandboxCommandIDLabel = "org.openclaw.clawscan.command-id"

DefaultSandboxImage = "ghcr.io/openclaw/clawscan-runtime:latest"
)

var sandboxRunIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)

type SandboxOptions struct {
Mode string
Image string
Expand Down Expand Up @@ -116,25 +125,13 @@ func sandboxMetadataForOptionList(optsList []Options, env map[string]string) San
next.Image != first.Image ||
next.Network != first.Network ||
strings.Join(next.Env, "\x00") != strings.Join(first.Env, "\x00") ||
!sandboxMountsEqual(next.Mounts, first.Mounts) {
!slices.Equal(next.Mounts, first.Mounts) {
return SandboxMetadata{Mode: "mixed"}
}
}
return first
}

func sandboxMountsEqual(left []SandboxMount, right []SandboxMount) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}

func effectiveSandboxMounts(mounts []SandboxMount) []SandboxMount {
permissions := make(map[string]bool, len(mounts))
for _, mount := range mounts {
Expand Down Expand Up @@ -198,6 +195,15 @@ func dockerAvailable() error {

func (runner dockerCommandRunner) Run(command string, args []string, cwd string, timeout time.Duration) (CommandOutput, error) {
dockerArgs := []string{"run", "--rm", "--network", "bridge"}
if runID := strings.TrimSpace(runner.Env[SandboxRunIDEnv]); runID != "" {
if !sandboxRunIDPattern.MatchString(runID) {
return CommandOutput{}, fmt.Errorf("%s must be 1-64 letters, digits, dots, underscores or hyphens, starting with a letter or digit", SandboxRunIDEnv)
}
dockerArgs = append(dockerArgs,
"--label", SandboxRunIDLabel+"="+runID,
"--label", SandboxCommandIDLabel+"="+rand.Text(),
)
}
for _, name := range runner.EnvNames {
if strings.TrimSpace(runner.Env[name]) != "" {
dockerArgs = append(dockerArgs, "-e", name)
Expand Down
51 changes: 51 additions & 0 deletions internal/runner/sandbox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package runner

import (
"strings"
"testing"
)

func TestDockerSandboxLabelsForWorkerCleanup(t *testing.T) {
for _, runID := range []string{"", "worker-123", "invalid id", strings.Repeat("a", 65)} {
t.Run(runID, func(t *testing.T) {
host := &recordingCommandRunner{stdout: `{}`}
commandRunner, _, err := commandRunnerForOptions(Options{}, RunContext{HostCommandRunner: host}, map[string]string{SandboxRunIDEnv: runID})
if err != nil {
t.Fatal(err)
}
var previousCommandID string
for range 2 {
_, err := commandRunner.Run("scanner", nil, "", 0)
if strings.Contains(runID, " ") || len(runID) > 64 {
if err == nil || len(host.calls) != 0 {
t.Fatalf("invalid ID executed Docker: calls=%v err=%v", host.calls, err)
}
return
}
if err != nil {
t.Fatal(err)
}
args := host.calls[len(host.calls)-1].args
if runID == "" {
if containsArg(args, "--label") {
t.Fatalf("unsupervised run has worker labels: %v", args)
}
continue
}
if !containsArgPair(args, "--label", SandboxRunIDLabel+"="+runID) || containsArgPair(args, "-e", SandboxRunIDEnv) {
t.Fatalf("worker ID must be a label, not container environment: %v", args)
}
var commandID string
for i, arg := range args[:len(args)-1] {
if arg == "--label" && strings.HasPrefix(args[i+1], SandboxCommandIDLabel+"=") {
commandID = strings.TrimPrefix(args[i+1], SandboxCommandIDLabel+"=")
}
}
if commandID == previousCommandID || !sandboxRunIDPattern.MatchString(commandID) {
t.Fatalf("command ID %q must be valid and unique", commandID)
}
previousCommandID = commandID
}
})
}
}
Loading