From 556dc83c08dea212439fca2a83c653f884ce487e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 22 Sep 2026 02:25:23 -0700 Subject: [PATCH] feat(sandbox): label worker-owned containers Allow supervising workers to identify and clean up their own scan containers after cancellation. Preserve unsupervised behavior and keep deletion ownership with the supervisor. Replace redundant mount slice comparison with slices.Equal. Co-authored-by: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> --- CHANGELOG.md | 1 + cmd/clawscan/main.go | 1 + cmd/clawscan/main_test.go | 1 + docs/sandbox.md | 10 +++++++ internal/runner/sandbox.go | 32 ++++++++++++--------- internal/runner/sandbox_test.go | 51 +++++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 internal/runner/sandbox_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3095d..5dd6af4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 5aa19be..3b5c1fa 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -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. diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index b491374..93b21d7 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -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 ", "{{ workspace }}", diff --git a/docs/sandbox.md b/docs/sandbox.md index 2e67584..5372ef4 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -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 ` 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. diff --git a/internal/runner/sandbox.go b/internal/runner/sandbox.go index 0bd5ad4..bae8fee 100644 --- a/internal/runner/sandbox.go +++ b/internal/runner/sandbox.go @@ -1,10 +1,13 @@ package runner import ( + "crypto/rand" "fmt" "os" "os/exec" "path/filepath" + "regexp" + "slices" "sort" "strings" "time" @@ -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 @@ -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 { @@ -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) diff --git a/internal/runner/sandbox_test.go b/internal/runner/sandbox_test.go new file mode 100644 index 0000000..e9983b3 --- /dev/null +++ b/internal/runner/sandbox_test.go @@ -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 + } + }) + } +}