diff --git a/README.md b/README.md index 9b5cbff..4d84106 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ClawScan turns that approach into a repeatable CLI. It includes a built-in `claw | --- | --- | | `clawscan --scanner ` | Run one or more scanners against an explicit skill or OpenClaw plugin target. Omit `` to scan child skill directories under `./skills`; plugins are never auto-discovered. | | `clawscan scanners [list\|]` | Discover supported scanner IDs, required env vars, upstream links, descriptions, and install guidance. | -| `clawscan profiles [-v]` | Inspect built-in plus nearest project-local profiles; `-v` prints the resolved profile catalog as YAML. | +| `clawscan profiles [-v]` | Inspect built-in profiles; `-v` prints the catalog as YAML. | | `clawscan benchmark [list\|]` | Discover or run supported benchmarks through a selected scanner/profile/judge setup. | | `clawscan install [...]` | Install or verify local scanner dependencies where ClawScan has registry-backed install plans. | @@ -173,8 +173,7 @@ The same profile accepts an explicit OpenClaw plugin directory (or its `openclaw.plugin.json` manifest), runs both scanners, and renders the bundled judge prompt with `packageRelease` target context. -Inspect the resolved profile catalog, including the nearest project -`.clawscan.yml` / `.clawscan.yaml` when present: +Inspect the built-in profile catalog: ```bash clawscan profiles diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 5cbebe2..dfcf661 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -235,7 +235,7 @@ func runProfiles(args []string) error { if err != nil { return err } - catalog, err := profiles.InspectProfiles(cwd) + catalog, err := profiles.InspectProfiles() if err != nil { return err } @@ -566,6 +566,7 @@ Usage: Core flags: --profile Profile to run. Use --profile clawhub for ClawHub parity. --config Load profiles from a specific .clawscan.yml file; omit --profile to run them all. + --discover-config Find and load the nearest .clawscan.yml/.clawscan.yaml in the current directory or a parent. Requires --profile. Off by default: config files can define scanners that run arbitrary commands, so ClawScan never loads config it was not explicitly told to trust. --scanner Scanner to run. Repeat for multiple scanners. --scanner-result Use a JSON fixture instead of running that scanner. --context Load profile runtime context from a JSON file. @@ -596,7 +597,7 @@ Catalog commands: clawscan scanners List supported scanners with required env vars. clawscan scanners list Alias for clawscan scanners. clawscan scanners Show scanner repository, description, env vars, and install guidance. - clawscan profiles List built-in plus nearest project .clawscan.yml/.clawscan.yaml profiles. + clawscan profiles List built-in profiles. clawscan profiles -v Print the resolved profile catalog as pasteable YAML. clawscan benchmark list List supported benchmarks with source datasets and splits. diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 220ca23..2390c39 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -32,6 +32,7 @@ func TestRunCommandPrintsHelp(t *testing.T) { "Install scanner dependencies without running scans.", "--profile ", "--config ", + "--discover-config", "Benchmark command flags:", "--split ", "--ids ", @@ -150,7 +151,7 @@ func TestRunCommandScannerDetailPrintsHumanReadableInfo(t *testing.T) { } } -func TestRunCommandProfilesPrintsMergedDiscoveredProfiles(t *testing.T) { +func TestRunCommandProfilesPrintsBuiltInProfilesOnly(t *testing.T) { dir := t.TempDir() removedProfile := "skills" + "-sh" writeFile(t, filepath.Join(dir, ".clawscan.yml"), `version: 1 @@ -177,12 +178,10 @@ profiles: "Source", "Scanners", "clawhub", - ".clawscan.yml", - "clawscan-static", + "built-in", + "skillspector, clawscan-static", "clawhub-aig", "skillspector, aig", - "local-review", - "snyk", } { if !strings.Contains(stdout, want) { t.Fatalf("profiles output missing %q:\n%s", want, stdout) @@ -191,6 +190,9 @@ profiles: if strings.Contains(stdout, removedProfile) { t.Fatalf("profiles output should not include removed profile %q:\n%s", removedProfile, stdout) } + if strings.Contains(stdout, "local-review") { + t.Fatalf("profiles output should not include project profile:\n%s", stdout) + } } func TestRunCommandProfilesVerbosePrintsResolvedYAML(t *testing.T) { @@ -216,10 +218,8 @@ profiles: "profiles:", "clawhub:", "clawhub-aig:", - "local-review:", - "- clawscan-static", + "- skillspector", "- aig", - "json: true", } { if !strings.Contains(stdout, want) { t.Fatalf("verbose profiles output missing %q:\n%s", want, stdout) @@ -228,6 +228,9 @@ profiles: if strings.Contains(stdout, removedProfile+":") { t.Fatalf("verbose profiles output should not include removed profile %q:\n%s", removedProfile, stdout) } + if strings.Contains(stdout, "local-review:") { + t.Fatalf("verbose profiles output should not include project profile:\n%s", stdout) + } } func TestRunCommandBenchmarkListPrintsCatalogTable(t *testing.T) { @@ -675,7 +678,7 @@ profiles: t.Chdir(dir) stdout := captureStdout(t, func() { - if err := run([]string{target, "--profile", "local"}, []string{}); err != nil { + if err := run([]string{target, "--profile", "local", "--discover-config"}, []string{}); err != nil { t.Fatal(err) } }) @@ -695,7 +698,111 @@ profiles: } } -func TestRunCommandConfigWithoutProfileRunsEveryConfigProfile(t *testing.T) { +func TestRunDefaultModeIgnoresConfigWithoutNotice(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + writeSkill(t, target, "# Ignored config\n") + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, "version: [\n") + t.Chdir(dir) + + stdout, stderr := captureOutput(t, func() { + if err := run([]string{target, "--scanner", "clawscan-static", "--json"}, []string{}); err != nil { + t.Fatal(err) + } + }) + + var artifact struct { + ConfigSource *string `json:"configSource"` + } + if err := json.Unmarshal([]byte(stdout), &artifact); err != nil { + t.Fatalf("stdout is not artifact JSON: %v\n%s", err, stdout) + } + if artifact.ConfigSource != nil { + t.Fatalf("config source = %q, want nil", *artifact.ConfigSource) + } + if !strings.Contains(stdout, `"configSource": null`) { + t.Fatalf("stdout lacks explicit null config source: %s", stdout) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } +} + +func TestRunWithDiscoverConfigLoadsConfig_NoNotice(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + writeSkill(t, target, "# Discovered config\n") + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + custom: + scanners: + - clawscan-static +`) + t.Chdir(dir) + + stdout, stderr := captureOutput(t, func() { + if err := run([]string{target, "--profile", "custom", "--discover-config", "--json"}, []string{}); err != nil { + t.Fatal(err) + } + }) + + var artifact struct { + ConfigSource *string `json:"configSource"` + } + if err := json.Unmarshal([]byte(stdout), &artifact); err != nil { + t.Fatal(err) + } + if artifact.ConfigSource == nil { + t.Fatalf("config source = nil, want %q", config) + } + if *artifact.ConfigSource != config { + t.Fatalf("config source = %q, want %q", *artifact.ConfigSource, config) + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } +} + +func TestRunWithExplicitConfigLoadsConfig_NoNotice(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + writeSkill(t, target, "# Explicit config\n") + writeFile(t, filepath.Join(dir, ".clawscan.yml"), "version: [\n") + explicit := filepath.Join(dir, "explicit.yml") + writeFile(t, explicit, `version: 1 +profiles: + p: + scanners: + - clawscan-static +`) + t.Chdir(dir) + + stdout, stderr := captureOutput(t, func() { + if err := run([]string{target, "--config", explicit, "--profile", "p", "--json"}, []string{}); err != nil { + t.Fatal(err) + } + }) + + var artifact struct { + ConfigSource *string `json:"configSource"` + } + if err := json.Unmarshal([]byte(stdout), &artifact); err != nil { + t.Fatal(err) + } + if artifact.ConfigSource == nil { + t.Fatalf("config source = nil, want %q", explicit) + } + if *artifact.ConfigSource != explicit { + t.Fatalf("config source = %q, want %q", *artifact.ConfigSource, explicit) + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } +} + +func TestBatchArtifactRuns_EachHasConfigSource(t *testing.T) { dir := t.TempDir() writeSkill(t, filepath.Join(dir, "skills", "foo"), "# Foo\n") writeSkill(t, filepath.Join(dir, "skills", "bar"), "# Bar\n") @@ -720,8 +827,9 @@ profiles: var artifact struct { SchemaVersion string `json:"schemaVersion"` Runs []struct { - Profile string `json:"profile"` - Scanners map[string]interface{} `json:"scanners"` + Profile string `json:"profile"` + ConfigSource *string `json:"configSource"` + Scanners map[string]interface{} `json:"scanners"` } `json:"runs"` } if err := json.Unmarshal([]byte(stdout), &artifact); err != nil { @@ -737,6 +845,12 @@ profiles: t.Fatalf("profiles = %q", got) } for _, run := range artifact.Runs { + if run.ConfigSource == nil { + t.Fatalf("config source = nil, want %q", config) + } + if *run.ConfigSource != config { + t.Fatalf("config source = %q, want %q", *run.ConfigSource, config) + } if _, ok := run.Scanners["clawscan-static"]; !ok { t.Fatalf("missing clawscan-static scanner for %s: %#v", run.Profile, run.Scanners) } @@ -864,3 +978,50 @@ func captureStdout(t *testing.T, fn func()) string { os.Stdout = original return string(out) } + +func captureOutput(t *testing.T, fn func()) (string, string) { + t.Helper() + + originalStdout := os.Stdout + originalStderr := os.Stderr + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = stdoutWrite + os.Stderr = stderrWrite + t.Cleanup(func() { + os.Stdout = originalStdout + os.Stderr = originalStderr + }) + + fn() + + if err := stdoutWrite.Close(); err != nil { + t.Fatal(err) + } + if err := stderrWrite.Close(); err != nil { + t.Fatal(err) + } + stdout, err := io.ReadAll(stdoutRead) + if err != nil { + t.Fatal(err) + } + stderr, err := io.ReadAll(stderrRead) + if err != nil { + t.Fatal(err) + } + if err := stdoutRead.Close(); err != nil { + t.Fatal(err) + } + if err := stderrRead.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = originalStdout + os.Stderr = originalStderr + return string(stdout), string(stderr) +} diff --git a/docs/index.md b/docs/index.md index 36f5afd..5eacfcf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -107,6 +107,6 @@ ClawScan turns that approach into a repeatable CLI. It includes a built-in `claw | --- | --- | | `clawscan --scanner ` | Run one or more scanners against an explicit target. Omit `` to scan child skill directories under `./skills`. | | `clawscan scanners [list\|]` | Discover supported scanner IDs, required env vars, upstream links, descriptions, and install guidance. | -| `clawscan profiles [-v]` | Inspect built-in plus nearest project-local profiles; `-v` prints the resolved profile catalog as YAML. | +| `clawscan profiles [-v]` | Inspect built-in profiles; `-v` prints the catalog as YAML. | | `clawscan benchmark [list\|]` | Discover or run supported benchmarks through a selected scanner/profile/judge setup. | | `clawscan install [...]` | Install or verify local scanner dependencies where ClawScan has registry-backed install plans. | diff --git a/docs/profiles.md b/docs/profiles.md index d332559..21e605c 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -12,8 +12,39 @@ The same profile accepts an explicit OpenClaw plugin directory (or its `openclaw.plugin.json` manifest), runs both scanners, and renders the bundled judge prompt with `packageRelease` target context. -Inspect the resolved profile catalog, including the nearest project -`.clawscan.yml` / `.clawscan.yaml` when present: +## Config discovery + +By default, ClawScan does not auto-discover `.clawscan.yml` or `.clawscan.yaml` +files from the current directory or parent directories. + +ClawScan never loads a config file it was not explicitly pointed at. A +`.clawscan.yml` can define user-defined scanners whose commands execute with +your credentials in the environment, so silently loading one from the current +directory or a parent (for example, from inside a repository you are scanning) +would let an untrusted target execute commands. Pass `--config ` or opt in +with `--discover-config`. + +To load a discovered config file, use one of these flags: + +- `--config ` - Explicitly specify a config file path +- `--discover-config` - Search upward from the current directory and load the nearest `.clawscan.yml` or `.clawscan.yaml` + +Mixing `--config` and `--discover-config` is an error. `--discover-config` +also requires `--profile`: without a profile the run would record the +discovered file as its config source while applying none of its settings. +Use `--config ` without `--profile` to run every profile in a config. + +```bash +clawscan ./my-skill --config ./security/clawscan.yml --profile review +clawscan ./my-skill --profile review --discover-config +``` + +Without either flag, ClawScan uses built-in profiles and CLI flags only. The +`clawscan profiles` catalog command lists built-in profiles only. + +## Inspect available profiles + +Inspect the built-in profile catalog: ```bash clawscan profiles diff --git a/internal/profiles/registry.go b/internal/profiles/registry.go index 1d022fe..1ed0721 100644 --- a/internal/profiles/registry.go +++ b/internal/profiles/registry.go @@ -53,11 +53,8 @@ func ProfileIDs() []string { return DefaultProfileRegistry().IDs() } -func InspectProfiles(cwd string) (ProfileCatalog, error) { - registry, err := loadConfigs(cwd, "") - if err != nil { - return ProfileCatalog{}, err - } +func InspectProfiles() (ProfileCatalog, error) { + registry := DefaultProfileRegistry() catalog := ProfileCatalog{profiles: map[string]ProfileInfo{}} for _, id := range registry.IDs() { resolved, _ := registry.Profile(id) diff --git a/internal/profiles/registry_test.go b/internal/profiles/registry_test.go index 82c306a..24e5714 100644 --- a/internal/profiles/registry_test.go +++ b/internal/profiles/registry_test.go @@ -1,8 +1,6 @@ package profiles import ( - "os" - "path/filepath" "strings" "testing" ) @@ -80,27 +78,12 @@ func TestProfileRegistryRejectsUnknownScannerReferences(t *testing.T) { } } -func TestInspectProfilesDiscoversNearestConfigAndShadowsBuiltIns(t *testing.T) { - dir := t.TempDir() - writeFile(t, filepath.Join(dir, ".clawscan.yml"), `version: 1 -profiles: - clawhub: - scanners: - - clawscan-static - local: - scanners: - - socket -`) - nested := filepath.Join(dir, "nested") - if err := os.Mkdir(nested, 0o755); err != nil { - t.Fatal(err) - } - - catalog, err := InspectProfiles(nested) +func TestInspectProfilesReturnsBuiltIns(t *testing.T) { + catalog, err := InspectProfiles() if err != nil { t.Fatal(err) } - if got := strings.Join(catalog.IDs(), ","); got != "clawhub,clawhub-aig,local" { + if got := strings.Join(catalog.IDs(), ","); got != "clawhub,clawhub-aig" { t.Fatalf("profile ids = %q", got) } @@ -108,10 +91,10 @@ profiles: if !ok { t.Fatal("missing clawhub profile") } - if got := strings.Join(clawhub.Profile.Scanners, ","); got != "clawscan-static" { + if got := strings.Join(clawhub.Profile.Scanners, ","); got != "skillspector,clawscan-static" { t.Fatalf("clawhub scanners = %q", got) } - if !strings.HasSuffix(clawhub.Source, ".clawscan.yml") { + if clawhub.Source != "built-in" { t.Fatalf("clawhub source = %q", clawhub.Source) } diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index cf4353b..9afadc5 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -61,6 +61,7 @@ type cliIntent struct { profile string profileSet bool configPath string + discoverConfig bool contextPath string scanners []string scannerResultPaths map[string]string @@ -143,19 +144,37 @@ func resolveRunSetIntent(intent cliIntent, cwd string) (ResolvedRunSet, error) { if intent.configPath != "" && !intent.profileSet { return resolveAllConfigProfiles(intent, cwd) } + // Discovery without a profile would record the discovered file as the + // run's ConfigSource while applying none of its settings — a provenance + // claim the run does not honor. Reject instead of misleading. + if intent.discoverConfig && !intent.profileSet { + return ResolvedRunSet{}, errors.New("--discover-config requires --profile; use --config to run every profile in a config") + } profileName := "" + configSource := "" selected := resolvedProfile{profile: Profile{}} - if intent.profileSet || intent.configPath != "" { - registry, err := loadConfigs(cwd, intent.configPath) + if intent.profileSet || intent.configPath != "" || intent.discoverConfig { + registry, loadedConfig, err := loadConfigs(cwd, intent.configPath, intent.discoverConfig) if err != nil { return ResolvedRunSet{}, err } - profileName = intent.profile - var ok bool - selected, ok = registry.Profile(profileName) - if !ok { - return ResolvedRunSet{}, unknownProfileError(profileName, registry.IDs()) + if loadedConfig != "" { + configSource = loadedConfig + } + if intent.profileSet { + profileName = intent.profile + var ok bool + selected, ok = registry.Profile(profileName) + if !ok { + return ResolvedRunSet{}, unknownProfileError(profileName, registry.IDs()) + } + // selected.source is authoritative: a profile served from + // embedded YAML is "built-in" even when an unrelated project + // config was loaded alongside it. + if selected.source == "built-in" { + configSource = "built-in" + } } } @@ -168,6 +187,8 @@ func resolveRunSetIntent(intent cliIntent, cwd string) (ResolvedRunSet, error) { return ResolvedRunSet{}, err } opts.Profile = profileName + opts.ConfigSource = configSource + opts.DiscoverConfig = intent.discoverConfig if opts.Judge != nil { opts.Judge.Files = files } @@ -192,7 +213,7 @@ func explicitRunSelectionError() error { return errors.New("No scanner, profile, or config selected. Pass --scanner, --profile, or --config. Use `clawscan benchmark ` for benchmark runs.") } -func loadConfigs(cwd string, explicitConfig string) (ProfileRegistry, error) { +func loadConfigs(cwd string, explicitConfig string, discover bool) (ProfileRegistry, string, error) { registry := DefaultProfileRegistry() var projectPath string @@ -202,20 +223,25 @@ func loadConfigs(cwd string, explicitConfig string) (ProfileRegistry, error) { if !filepath.IsAbs(projectPath) { projectPath = filepath.Join(cwd, projectPath) } - } else { + projectPath = filepath.Clean(projectPath) + } else if discover { projectPath, err = discoverConfig(cwd) if err != nil { - return ProfileRegistry{}, err + return ProfileRegistry{}, "", err } } if projectPath == "" { - return registry, nil + return registry, "", nil } projectProfiles, err := loadProjectProfiles(projectPath) if err != nil { - return ProfileRegistry{}, err + return ProfileRegistry{}, "", err + } + registry, err = registry.Merge(projectProfiles) + if err != nil { + return ProfileRegistry{}, "", err } - return registry.Merge(projectProfiles) + return registry, projectPath, nil } func resolveAllConfigProfiles(intent cliIntent, cwd string) (ResolvedRunSet, error) { @@ -258,6 +284,7 @@ func resolveAllConfigProfiles(intent cliIntent, cwd string) (ResolvedRunSet, err return ResolvedRunSet{}, err } opts.Profile = profileName + opts.ConfigSource = filepath.Clean(projectPath) opts.OutputPath = "" opts.JSON = false if opts.Judge != nil { @@ -434,6 +461,8 @@ func parseCLIIntent(args []string) (cliIntent, error) { } intent.configPath = value i = next + case "--discover-config": + intent.discoverConfig = true case "--context": value, next, err := readValue(args, i, arg) if err != nil { @@ -552,6 +581,9 @@ func parseCLIIntent(args []string) (cliIntent, error) { return cliIntent{}, fmt.Errorf("Unknown argument: %s", arg) } } + if intent.configPath != "" && intent.discoverConfig { + return cliIntent{}, errors.New("--config and --discover-config are mutually exclusive") + } return intent, nil } diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index 1ae9f7d..55e3b48 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -20,6 +20,9 @@ func TestResolveArgsUsesEmbeddedClawHubProfile(t *testing.T) { if opts.Target != "./skill" { t.Fatalf("target = %q", opts.Target) } + if opts.ConfigSource != "built-in" { + t.Fatalf("config source = %q, want built-in", opts.ConfigSource) + } if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static" { t.Fatalf("scanners = %q", got) } @@ -140,6 +143,153 @@ func TestResolveArgsTreatsScannerOnlyCommandAsAdHocWithoutDefaultJudge(t *testin } } +func TestResolveArgsFlagsOnlyRunIgnoresAncestorConfig(t *testing.T) { + parent := t.TempDir() + writeFile(t, filepath.Join(parent, ".clawscan.yml"), `version: 1 +profiles: + custom: + scanners: + - virustotal +`) + dir := filepath.Join(parent, "child") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + + opts, err := ResolveArgs([]string{"./skill", "--scanner", "clawscan-static"}, dir) + if err != nil { + t.Fatal(err) + } + + if got := strings.Join(opts.Scanners, ","); got != "clawscan-static" { + t.Fatalf("scanners = %q", got) + } + if opts.ConfigSource != "" { + t.Fatalf("config source = %q, want empty for a flags-only run", opts.ConfigSource) + } +} + +func TestResolveArgsBuiltInProfileProvenanceSurvivesDiscoveredConfig(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".clawscan.yml"), `version: 1 +profiles: + unrelated: + scanners: + - clawscan-static +`) + + opts, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--discover-config"}, dir) + if err != nil { + t.Fatal(err) + } + if opts.ConfigSource != "built-in" { + t.Fatalf("config source = %q, want built-in (unrelated discovered config must not claim provenance)", opts.ConfigSource) + } + + opts, err = ResolveArgs([]string{"./skill", "--profile", "clawhub", "--discover-config"}, t.TempDir()) + if err != nil { + t.Fatal(err) + } + if opts.ConfigSource != "built-in" { + t.Fatalf("config source = %q, want built-in when discovery finds nothing", opts.ConfigSource) + } +} + +func TestResolveArgsUnknownProfileDoesNotDiscoverConfig(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".clawscan.yml"), `version: 1 +profiles: + custom: + scanners: + - virustotal +`) + + _, err := ResolveArgs([]string{"./skill", "--profile", "custom"}, dir) + if err == nil { + t.Fatal("expected unknown profile error") + } + want := "Unknown profile: custom (available: clawhub, clawhub-aig)" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + +func TestResolveArgsDiscoverConfigFlag_RestoresOldBehavior(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + custom: + scanners: + - clawscan-static +`) + + opts, err := ResolveArgs([]string{"./skill", "--profile", "custom", "--discover-config"}, dir) + if err != nil { + t.Fatal(err) + } + + if opts.Profile != "custom" { + t.Fatalf("profile = %q", opts.Profile) + } + if opts.ConfigSource != config { + t.Fatalf("config source = %q, want %q", opts.ConfigSource, config) + } + if !opts.DiscoverConfig { + t.Fatal("DiscoverConfig = false, want true") + } +} + +func TestResolveArgsDiscoverConfigRequiresProfile(t *testing.T) { + // Without a profile the run would record the discovered file as its + // ConfigSource while applying none of its settings (sandbox mode/image/ + // env); rejecting is honest, silently ignoring the config is not. + _, err := ResolveArgs([]string{"./skill", "--scanner", "clawscan-static", "--discover-config"}, t.TempDir()) + if err == nil { + t.Fatal("expected error for --discover-config without --profile") + } + if !strings.Contains(err.Error(), "--discover-config requires --profile") { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsConfigAndDiscoverConfigFlagsAreMutuallyExclusive(t *testing.T) { + dir := t.TempDir() + _, err := ResolveArgs([]string{"./skill", "--config", "config.yml", "--discover-config", "--scanner", "clawscan-static"}, dir) + if err == nil { + t.Fatal("expected error for --config and --discover-config together") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsExplicitConfigUsesExplicitSource(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".clawscan.yml"), `version: 1 +profiles: + builtin: + scanners: + - clawscan-static +`) + explicit := filepath.Join(dir, "custom.yml") + writeFile(t, explicit, `version: 1 +profiles: + custom: + scanners: + - virustotal +`) + + opts, err := ResolveArgs([]string{"./skill", "--config", explicit, "--profile", "custom"}, dir) + if err != nil { + t.Fatal(err) + } + + if opts.ConfigSource != explicit { + t.Fatalf("config source = %q", opts.ConfigSource) + } +} + func TestResolveArgsAllowsExplicitProfileWithoutTarget(t *testing.T) { dir := t.TempDir() @@ -319,7 +469,7 @@ profiles: json: true `) - opts, err := ResolveArgs([]string{"./skill", "--profile", "clawhub"}, child) + opts, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--discover-config"}, child) if err != nil { t.Fatal(err) } @@ -388,7 +538,7 @@ func TestResolveArgsRejectsAmbiguousDiscoveredConfig(t *testing.T) { writeFile(t, filepath.Join(dir, ".clawscan.yml"), "version: 1\nprofiles: {}\n") writeFile(t, filepath.Join(dir, ".clawscan.yaml"), "version: 1\nprofiles: {}\n") - _, err := ResolveArgs([]string{"./skill", "--profile", "clawhub"}, dir) + _, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--discover-config"}, dir) if err == nil || !strings.Contains(err.Error(), "Ambiguous ClawScan config files") { t.Fatalf("err = %v", err) } @@ -401,7 +551,7 @@ func TestResolveArgsRejectsMalformedYAML(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, ".clawscan.yml"), "version: [\n") - _, err := ResolveArgs([]string{"./skill", "--profile", "clawhub"}, dir) + _, err := ResolveArgs([]string{"./skill", "--profile", "clawhub", "--discover-config"}, dir) if err == nil || !strings.Contains(err.Error(), "parse ClawScan config") { t.Fatalf("err = %v", err) } @@ -502,7 +652,7 @@ profiles: - ANTHROPIC_API_KEY `) - opts, err := ResolveArgs([]string{"./skill", "--profile", "review"}, dir) + opts, err := ResolveArgs([]string{"./skill", "--profile", "review", "--discover-config"}, dir) if err != nil { t.Fatal(err) } @@ -565,7 +715,7 @@ profiles: OPENAI_API_KEY: secret `) - _, err := ResolveArgs([]string{"./skill", "--profile", "bad"}, dir) + _, err := ResolveArgs([]string{"./skill", "--profile", "bad", "--discover-config"}, dir) if err == nil || !strings.Contains(err.Error(), "field env not found") { t.Fatalf("err = %v", err) } @@ -582,7 +732,7 @@ profiles: - clawscan-static `) - _, err := ResolveArgs([]string{"./skill", "--profile", "dup"}, dir) + _, err := ResolveArgs([]string{"./skill", "--profile", "dup", "--discover-config"}, dir) if err == nil || err.Error() != "Duplicate scanner in profile dup: clawscan-static" { t.Fatalf("err = %v", err) } @@ -597,12 +747,12 @@ profiles: json: true `) - _, err := ResolveArgs([]string{"./skill", "--profile", "empty"}, dir) + _, err := ResolveArgs([]string{"./skill", "--profile", "empty", "--discover-config"}, dir) if err == nil || err.Error() != "Profile empty must include at least one scanner or use --scanner" { t.Fatalf("err = %v", err) } - opts, err := ResolveArgs([]string{"./skill", "--profile", "empty", "--scanner", "clawscan-static"}, dir) + opts, err := ResolveArgs([]string{"./skill", "--profile", "empty", "--scanner", "clawscan-static", "--discover-config"}, dir) if err != nil { t.Fatal(err) } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 11e36d1..c8b735a 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -28,6 +28,8 @@ import ( type Options struct { Target string Profile string + ConfigSource string + DiscoverConfig bool ContextPath string Benchmark *BenchmarkOptions Scanners []string @@ -85,16 +87,19 @@ type CommandOutput struct { } type Artifact struct { - SchemaVersion string `json:"schemaVersion"` - Profile string `json:"profile,omitempty"` - Context json.RawMessage `json:"context,omitempty"` - Target Target `json:"target"` - StartedAt string `json:"startedAt"` - CompletedAt string `json:"completedAt"` - Env map[string]string `json:"env"` - Sandbox SandboxMetadata `json:"sandbox"` - Scanners map[string]ScannerResult `json:"scanners"` - Judge *JudgeResult `json:"judge"` + SchemaVersion string `json:"schemaVersion"` + Profile string `json:"profile,omitempty"` + // ConfigSource deliberately lacks omitempty: null positively claims no config + // influenced the run, unlike older artifacts that omitted the field. + ConfigSource *string `json:"configSource"` + Context json.RawMessage `json:"context,omitempty"` + Target Target `json:"target"` + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt"` + Env map[string]string `json:"env"` + Sandbox SandboxMetadata `json:"sandbox"` + Scanners map[string]ScannerResult `json:"scanners"` + Judge *JudgeResult `json:"judge"` } type RunTargetsResult struct { @@ -2114,9 +2119,15 @@ func NewArtifact(opts Options, resolvedPath string, startedAt string, completedA Raw: nil, } } + var configSource *string + if opts.ConfigSource != "" { + source := opts.ConfigSource + configSource = &source + } return Artifact{ SchemaVersion: "clawscan-run-v1", Profile: opts.Profile, + ConfigSource: configSource, Target: Target{ Kind: "skill", Input: opts.Target, diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index d466641..66b0726 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1100,6 +1100,27 @@ func TestArtifactRedactsEnvValues(t *testing.T) { } } +func TestArtifactConfigSourceField_FlagsOnly(t *testing.T) { + opts, err := ParseArgs([]string{"./my-skill", "--scanner", "clawscan-static"}) + if err != nil { + t.Fatal(err) + } + opts.ConfigSource = "" + + artifact := NewArtifact(opts, "/tmp/my-skill", "2026-06-03T00:00:00Z", "2026-06-03T00:00:01Z", map[string]string{}) + + if artifact.ConfigSource != nil { + t.Fatalf("config source = %q, want nil", *artifact.ConfigSource) + } + raw, err := json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"configSource":null`) { + t.Fatalf("artifact lacks explicit null config source: %s", raw) + } +} + func TestRunWritesScannerOnlyArtifact(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "skill") diff --git a/skills/clawscan-cli/SKILL.md b/skills/clawscan-cli/SKILL.md index f03c83d..c8a742e 100644 --- a/skills/clawscan-cli/SKILL.md +++ b/skills/clawscan-cli/SKILL.md @@ -83,15 +83,17 @@ Built-in profiles: | --- | --- | --- | | `clawhub` | `skillspector`, `clawscan-static` | bundled Codex judge with ClawHub prompt/schema | -Profiles are loaded from embedded built-ins plus the nearest `.clawscan.yml` or -`.clawscan.yaml` discovered upward from the current directory. A project profile -with the same name shadows the built-in whole profile. `--config ` loads a -specific config; without `--profile`, it runs every profile in that config and -emits a `clawscan-batch-v1` artifact. - -Use `clawscan profiles` to inspect the resolved built-in plus nearest local -profile catalog. Use `clawscan profiles -v` to print the merged catalog as -pasteable YAML. +Profiles are loaded from embedded built-ins. Project `.clawscan.yml` / +`.clawscan.yaml` files are NOT loaded automatically: pass `--config ` to +load a specific config, or `--discover-config` to load the nearest one found +upward from the current directory. A project profile with the same name shadows +the built-in whole profile. `--config ` without `--profile` runs every +profile in that config and emits a `clawscan-batch-v1` artifact. Discovery is +off by default because project configs can define scanner commands that execute +with the caller's environment and credentials. + +Use `clawscan profiles` to inspect the built-in profile catalog. Use +`clawscan profiles -v` to print it as pasteable YAML. CLI flags override the selected profile for one run. Passing `--scanner` without `--profile` creates an ad hoc scanner-only run, so profile judges are