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 + } + }) + } +}