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
1 change: 1 addition & 0 deletions cmd/clawscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ Core flags:
--sandbox <docker|off> Command sandbox mode. Defaults to docker.
--sandbox-image <image> Docker runtime image. Defaults to %s or CLAWSCAN_SANDBOX_IMAGE.
--sandbox-env <name> Allow an env var through the Docker sandbox. Repeat for multiple vars.
--sandbox-mount <path[:rw]> Bind-mount a host dir into the Docker sandbox (read-only; append :rw for writable). Repeat for multiple.

Benchmark command flags:
--split <name> Benchmark split. Defaults to benchmark for SkillTrustBench and eval_holdout for clawhub-security-signals.
Expand Down
2 changes: 1 addition & 1 deletion internal/profiles/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,5 @@ func (registry ProfileRegistry) IDs() []string {
}

func sandboxIsZero(sandbox Sandbox) bool {
return sandbox.Mode == "" && sandbox.Image == "" && len(sandbox.Env) == 0
return sandbox.Mode == "" && sandbox.Image == "" && len(sandbox.Env) == 0 && len(sandbox.Mounts) == 0
}
103 changes: 100 additions & 3 deletions internal/profiles/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,51 @@ func profileGateRules(scanners []ProfileScanner) map[string]runner.ScannerGatePo
}

type Sandbox struct {
Mode string `yaml:"mode,omitempty"`
Image string `yaml:"image,omitempty"`
Env []string `yaml:"env,omitempty"`
Mode string `yaml:"mode,omitempty"`
Image string `yaml:"image,omitempty"`
Env []string `yaml:"env,omitempty"`
Mounts []SandboxMount `yaml:"mounts,omitempty"`
}

type SandboxMount struct {
Path string
Write bool
}

func (m *SandboxMount) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
return node.Decode(&m.Path)
case yaml.MappingNode:
for i := 0; i < len(node.Content); i += 2 {
switch node.Content[i].Value {
case "path", "write":
default:
return fmt.Errorf("field %s not found in type profiles.SandboxMount", node.Content[i].Value)
}
}
var v struct {
Path string `yaml:"path"`
Write bool `yaml:"write"`
}
if err := node.Decode(&v); err != nil {
return err
}
m.Path, m.Write = v.Path, v.Write
return nil
default:
return fmt.Errorf("sandbox mount must be a string or object")
}
}

func (m SandboxMount) MarshalYAML() (interface{}, error) {
if !m.Write {
return m.Path, nil
}
return struct {
Path string `yaml:"path"`
Write bool `yaml:"write"`
}{Path: m.Path, Write: m.Write}, nil
}

type Judge struct {
Expand Down Expand Up @@ -268,6 +310,7 @@ type cliIntent struct {
sandboxImage string
sandboxImageSet bool
sandboxEnv []string
sandboxMounts []SandboxMount
benchmark string
benchmarkSet bool
split string
Expand Down Expand Up @@ -606,6 +649,7 @@ func mergeSandbox(defaults *Sandbox, override *Sandbox) Sandbox {
if defaults != nil {
out = *defaults
out.Env = append([]string(nil), defaults.Env...)
out.Mounts = append([]SandboxMount(nil), defaults.Mounts...)
}
if override != nil {
if override.Mode != "" {
Expand All @@ -617,6 +661,9 @@ func mergeSandbox(defaults *Sandbox, override *Sandbox) Sandbox {
if len(override.Env) > 0 {
out.Env = append(out.Env, override.Env...)
}
if len(override.Mounts) > 0 {
out.Mounts = append(out.Mounts, override.Mounts...)
}
}
out.Env = dedupeStrings(out.Env)
return out
Expand Down Expand Up @@ -747,6 +794,17 @@ func parseCLIIntent(args []string) (cliIntent, error) {
}
intent.sandboxEnv = append(intent.sandboxEnv, value)
i = next
case "--sandbox-mount":
value, next, err := readValue(args, i, arg)
if err != nil {
return cliIntent{}, err
}
mount, err := parseSandboxMountFlag(value)
if err != nil {
return cliIntent{}, err
}
intent.sandboxMounts = append(intent.sandboxMounts, mount)
i = next
case "--split":
value, next, err := readValue(args, i, arg)
if err != nil {
Expand Down Expand Up @@ -891,6 +949,10 @@ func buildRunnerArgs(intent cliIntent, selected resolvedProfile, profileName str
for _, envVar := range selected.sandbox.Env {
args = append(args, "--sandbox-env", envVar)
}
args, err := appendSandboxMountArgs(args, selected.sandbox.Mounts)
if err != nil {
return nil, nil, err
}
if intent.sandboxSet {
args = append(args, "--sandbox", intent.sandbox)
}
Expand All @@ -900,9 +962,44 @@ func buildRunnerArgs(intent cliIntent, selected resolvedProfile, profileName str
for _, envVar := range intent.sandboxEnv {
args = append(args, "--sandbox-env", envVar)
}
args, err = appendSandboxMountArgs(args, intent.sandboxMounts)
if err != nil {
return nil, nil, err
}
return args, selected.files, nil
}

func parseSandboxMountFlag(value string) (SandboxMount, error) {
if i := strings.LastIndexByte(value, ':'); i >= 0 {
suffix := value[i+1:]
if suffix == "rw" || suffix == "write" {
path := value[:i]
if strings.TrimSpace(path) == "" {
return SandboxMount{}, fmt.Errorf("--sandbox-mount requires a path before %q", ":"+suffix)
}
return SandboxMount{Path: path, Write: true}, nil
}
}
return SandboxMount{Path: value, Write: false}, nil
}

func appendSandboxMountArgs(args []string, mounts []SandboxMount) ([]string, error) {
for _, mount := range mounts {
if !filepath.IsAbs(mount.Path) {
return nil, fmt.Errorf("sandbox mount path must be absolute: %q", mount.Path)
}
if _, err := os.Stat(mount.Path); err != nil {
return nil, fmt.Errorf("sandbox mount path does not exist: %q", mount.Path)
}
value := mount.Path
if mount.Write {
value += ":rw"
}
args = append(args, "--sandbox-mount", value)
}
return args, nil
}

func shouldUseProfileJudge(intent cliIntent) bool {
if len(intent.scanners) == 0 {
return true
Expand Down
59 changes: 59 additions & 0 deletions internal/profiles/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,65 @@ profiles:
}
}

func TestResolveArgsSupportsSandboxMountConfig(t *testing.T) {
dir := t.TempDir()
readOnlyDir := t.TempDir()
writableDir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, "version: 1\n"+
"sandbox:\n"+
" mounts:\n"+
" - "+readOnlyDir+"\n"+
" - path: "+writableDir+"\n"+
" write: true\n"+
"profiles:\n"+
" review:\n"+
" scanners:\n"+
" - clawscan-static\n")

opts, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir)
if err != nil {
t.Fatal(err)
}
want := []runner.SandboxMount{
{Path: readOnlyDir},
{Path: writableDir, Write: true},
}
if !reflect.DeepEqual(opts.Sandbox.Mounts, want) {
t.Fatalf("sandbox mounts = %#v, want %#v", opts.Sandbox.Mounts, want)
}
}

func TestResolveArgsValidatesSandboxMountConfig(t *testing.T) {
tests := []struct {
name string
path string
wantErr string
}{
{name: "relative", path: "relative/rules", wantErr: "must be absolute"},
{name: "missing", path: filepath.Join(t.TempDir(), "missing"), wantErr: "does not exist"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, "version: 1\n"+
"sandbox:\n"+
" mounts:\n"+
" - "+test.path+"\n"+
"profiles:\n"+
" review:\n"+
" scanners:\n"+
" - clawscan-static\n")

_, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("err = %v, want substring %q", err, test.wantErr)
}
})
}
}

func TestResolveArgsRejectsUnrequestedScannerResultAfterOverrides(t *testing.T) {
_, err := ResolveArgs([]string{
"./skill",
Expand Down
6 changes: 5 additions & 1 deletion internal/runner/cisco_scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ func (runner ExternalScannerRunner) runCisco(target string, startedAt string) (S
timeout = 20 * time.Minute
}

output, runErr := runner.CommandRunner.Run(command, args, "", timeout)
cwd := ""
if runner.SandboxMode == SandboxModeDocker {
cwd = resultDir
}
output, runErr := runner.CommandRunner.Run(command, args, cwd, timeout)
raw, readErr := os.ReadFile(resultPath)
completedAt := time.Now().UTC().Format(time.RFC3339Nano)
if readErr != nil {
Expand Down
23 changes: 23 additions & 0 deletions internal/runner/cisco_scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,29 @@ func TestCiscoScannerCompletesWithJSONOutputFile(t *testing.T) {
}
}

func TestCiscoScannerUsesResultDirAsDockerSandboxCWD(t *testing.T) {
commandRunner := &ciscoRecordingCommandRunner{output: `{"scanner":"cisco","findings":[]}`}
result, err := (ExternalScannerRunner{
CommandRunner: commandRunner,
Env: map[string]string{},
SandboxMode: SandboxModeDocker,
}).runCisco(t.TempDir(), "2026-07-24T00:00:00Z")
if err != nil {
t.Fatal(err)
}
if result.Status != "completed" {
t.Fatalf("result = %#v", result)
}
if len(commandRunner.calls) != 1 {
t.Fatalf("calls = %#v", commandRunner.calls)
}
call := commandRunner.calls[0]
outputPath := argValue(call.args, "--output")
if call.cwd == "" || call.cwd != filepath.Dir(outputPath) {
t.Fatalf("cwd = %q, output path = %q", call.cwd, outputPath)
}
}

func TestCiscoScannerEnablesUpstreamAnalyzersFromEnv(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "skill")
Expand Down
29 changes: 29 additions & 0 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,17 @@ func ParseArgsWithRegistry(args []string, registry ScannerRegistry) (Options, er
}
opts.Sandbox.Env = append(opts.Sandbox.Env, value)
i = next
case "--sandbox-mount":
value, next, err := readValue(args, i, arg)
if err != nil {
return Options{}, err
}
mount, err := parseSandboxMountFlag(value)
if err != nil {
return Options{}, err
}
opts.Sandbox.Mounts = append(opts.Sandbox.Mounts, mount)
i = next
default:
return Options{}, fmt.Errorf("Unknown argument: %s", arg)
}
Expand All @@ -387,6 +398,22 @@ func ParseArgsWithRegistry(args []string, registry ScannerRegistry) (Options, er
return opts, nil
}

// parseSandboxMountFlag parses a --sandbox-mount value: "<path>" is read-only,
// "<path>:rw" or "<path>:write" is writable.
func parseSandboxMountFlag(value string) (SandboxMount, error) {
if i := strings.LastIndexByte(value, ':'); i >= 0 {
suffix := value[i+1:]
if suffix == "rw" || suffix == "write" {
path := value[:i]
if strings.TrimSpace(path) == "" {
return SandboxMount{}, fmt.Errorf("--sandbox-mount requires a path before %q", ":"+suffix)
}
return SandboxMount{Path: path, Write: true}, nil
}
}
return SandboxMount{Path: value, Write: false}, nil
}

func ValidateRequirements(opts Options, env map[string]string) error {
var missing []EnvRequirement
for _, req := range requirements(opts, env) {
Expand Down Expand Up @@ -2028,6 +2055,8 @@ func (runner ExternalScannerRunner) runSkillSpector(target string, startedAt str
scanTarget = "artifact"
cwd = resultDir
resultName = "skillspector-report-0.json"
} else if runner.SandboxMode == SandboxModeDocker {
cwd = resultDir
}
resultPath := filepath.Join(resultDir, resultName)
args = append(args, "scan", scanTarget, "--format", "json", "--output", resultPath)
Expand Down
Loading
Loading