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
19 changes: 17 additions & 2 deletions cmd/clawscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func printProfileCatalog(w io.Writer, catalog profiles.ProfileCatalog, cwd strin
if info.Profile.Judge != nil {
judge = "configured"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, displayProfileSource(info.Source, cwd), strings.Join(info.Profile.Scanners, ", "), judge)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, displayProfileSource(info.Source, cwd), strings.Join(info.Profile.ScannerIDs(), ", "), judge)
}
_ = tw.Flush()
}
Expand Down Expand Up @@ -337,6 +337,15 @@ func printRunSummary(w io.Writer, result runner.RunTargetsResult, outputPath str
fmt.Fprintf(w, "scanner_other: %d\n", summary.ScannerOther)
}
fmt.Fprintf(w, "issues_found: %d\n", summary.IssuesFound)
fmt.Fprintf(w, "gate: %s", summary.Gate)
if len(summary.GateRules) > 0 {
details := make([]string, 0, len(summary.GateRules))
for _, rule := range summary.GateRules {
details = append(details, fmt.Sprintf("%s exit %d -> %s", rule.Scanner, rule.ExitCode, rule.Action))
}
fmt.Fprintf(w, " (%s)", strings.Join(details, ", "))
}
fmt.Fprintln(w)
if summary.HasJudge {
fmt.Fprintf(w, "judge_completed: %d\n", summary.JudgeCompleted)
fmt.Fprintf(w, "judge_failed: %d\n", summary.JudgeFailed)
Expand Down Expand Up @@ -404,6 +413,8 @@ type runSummary struct {
ScannerSkipped int
ScannerOther int
IssuesFound int
Gate string
GateRules []runner.FiredGateRule
HasJudge bool
JudgeCompleted int
JudgeFailed int
Expand All @@ -415,7 +426,7 @@ type runSummary struct {
}

func summarizeRunTargets(result runner.RunTargetsResult) runSummary {
var summary runSummary
summary := runSummary{Gate: "pass"}
if result.Batch != nil {
summary.Profile = result.Batch.Profile
summary.Profiles = result.Batch.Summary.ProfileCount
Expand All @@ -436,6 +447,10 @@ func (summary *runSummary) addArtifact(artifact runner.Artifact) {
summary.Profile = artifact.Profile
}
summary.Targets++
summary.GateRules = append(summary.GateRules, artifact.GateRules...)
if artifact.Gate == "block" || (artifact.Gate == "warn" && summary.Gate == "pass") {
summary.Gate = artifact.Gate
}
for _, result := range artifact.Scanners {
switch result.Status {
case "completed":
Expand Down
17 changes: 17 additions & 0 deletions cmd/clawscan/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/openclaw/clawscan/internal/runner"
)

func TestRunCommandPrintsHelp(t *testing.T) {
Expand Down Expand Up @@ -511,6 +513,21 @@ func TestRunCommandWritesDefaultOutputAndPrintsKeyValueSummary(t *testing.T) {
}
}

func TestPrintRunSummaryIncludesGateVerdictAndFiredRule(t *testing.T) {
artifact := runner.Artifact{
Gate: "block",
GateRules: []runner.FiredGateRule{
{Scanner: "my-scanner", Rule: "blockOnExitCode", ExitCode: 3, Action: "block"},
},
Scanners: map[string]runner.ScannerResult{},
}
var output strings.Builder
printRunSummary(&output, runner.RunTargetsResult{Single: &artifact}, "")
if !strings.Contains(output.String(), "gate: block (my-scanner exit 3 -> block)") {
t.Fatalf("summary missing gate rule:\n%s", output.String())
}
}

func TestRunCommandJSONDoesNotWriteDefaultOutput(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "skill")
Expand Down
68 changes: 68 additions & 0 deletions docs/scanners.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,74 @@ clawscan scanners
clawscan scanners skillspector
```

## User-defined scanners

A trusted config can mix built-in scanner IDs with user-defined command
scanners. The config schema uses the existing `profiles.<name>.scanners` list:

```yaml
version: 1

profiles:
review:
scanners:
- clawscan-static
- id: my-scanner
command: my-scanner --json {{target}}
env:
- MY_SCANNER_TOKEN
targets:
- skill
- plugin
gate:
blockOnExitCode: nonzero
```

String entries select built-in scanners. Object entries define a scanner for
that config-backed run and accept these fields:

| Field | Required | Meaning |
| --- | --- | --- |
| `id` | yes | Scanner ID using letters, digits, `_`, and `-`, starting with a letter or digit. It must not match a built-in scanner ID. |
| `command` | yes | Shell command to execute. Unquoted `{{target}}` is replaced with the safely passed resolved target; do not wrap the placeholder in shell quotes. |
| `env` | no | Required environment variable names. Values stay in the process environment and are never stored in the config or artifact. |
| `targets` | no | Supported target kinds: `skill`, `plugin`, and/or `url`. Defaults to `skill` and `url`. |
| `gate` | no | Exit-code policy with optional `blockOnExitCode` and `warnOnExitCode` rules. |

Each exit-code rule accepts one non-negative integer, a list such as `[1, 2,
3]`, or the string `nonzero`. The block and warning rules may not claim the
same exit code. For example:

```yaml
gate:
blockOnExitCode: [2, 3]
warnOnExitCode: 1
```

After every selected scanner finishes, ClawScan records the strongest fired
action as the top-level artifact `gate`: `block` wins over `warn`, and an
artifact with no fired rules records `"gate": "pass"`. Each fired rule is also
listed in `gateRules` with its scanner ID, rule name, exit code, and action.
Skipped scanners do not fire gate rules. A scanner result with status `failed`
also does not fire an exit-code rule; a nonzero command that still returned
valid JSON has status `completed` and can fire one.

The command must write JSON to stdout. ClawScan preserves valid stdout as the
scanner's raw evidence; empty or non-JSON stdout produces a failed scanner
result. Required environment variables are checked before any scanner starts.
Artifacts record each requirement as only `present` or `missing`.

User-defined scanners use the same execution path as built-in command-backed
scanners. They run in the Docker sandbox by default, and declared `env` names
are added to its environment allowlist. Use `--sandbox off` only when you
intentionally want the command to run on the host. User-defined scanners are
local to the resolved config and do not appear in the built-in `clawscan
scanners` catalog.

> **Trust boundary:** only load user-defined scanners from config files you
> control. A scanner entry is executable code. The default sandbox limits its
> host access, but does not make an untrusted command safe to run.

## Target kinds

Clawscan classifies each explicit target before dispatching scanners and records
Expand Down
4 changes: 2 additions & 2 deletions internal/profiles/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func NewProfileRegistry(profiles map[string]resolvedProfile) (ProfileRegistry, e
return ProfileRegistry{}, err
}
for _, scanner := range profile.profile.Scanners {
if !runner.DefaultScannerRegistry().Contains(scanner) {
return ProfileRegistry{}, unknownScannerInProfileError(id, scanner)
if !scanner.custom && !runner.DefaultScannerRegistry().Contains(scanner.ID) {
return ProfileRegistry{}, unknownScannerInProfileError(id, scanner.ID)
}
}
registry.profiles[id] = profile
Expand Down
12 changes: 6 additions & 6 deletions internal/profiles/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (

func TestProfileRegistryReturnsSortedIDs(t *testing.T) {
registry, err := NewProfileRegistry(map[string]resolvedProfile{
"review": {profile: Profile{Scanners: []string{"snyk"}}},
"clawhub": {profile: Profile{Scanners: []string{"skillspector"}}},
"review": {profile: Profile{Scanners: []ProfileScanner{{ID: "snyk"}}}},
"clawhub": {profile: Profile{Scanners: []ProfileScanner{{ID: "skillspector"}}}},
})
if err != nil {
t.Fatal(err)
Expand All @@ -26,7 +26,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(clawhub.profile.Scanners, ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.profile.Scanners), ","); got != "skillspector,clawscan-static" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.configDir != "clawhub" {
Expand All @@ -46,7 +46,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub-aig profile")
}
if got := strings.Join(candidate.profile.Scanners, ","); got != "skillspector,aig" {
if got := strings.Join(profileScannerIDs(candidate.profile.Scanners), ","); got != "skillspector,aig" {
t.Fatalf("clawhub-aig scanners = %q", got)
}
if candidate.configDir != "clawhub" {
Expand All @@ -71,7 +71,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {

func TestProfileRegistryRejectsUnknownScannerReferences(t *testing.T) {
_, err := NewProfileRegistry(map[string]resolvedProfile{
"bad": {profile: Profile{Scanners: []string{"missing-scanner"}}},
"bad": {profile: Profile{Scanners: []ProfileScanner{{ID: "missing-scanner"}}}},
})
if err == nil || err.Error() != "Profile bad references unknown scanner: missing-scanner" {
t.Fatalf("err = %v", err)
Expand All @@ -91,7 +91,7 @@ func TestInspectProfilesReturnsBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(clawhub.Profile.Scanners, ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.Profile.Scanners), ","); got != "skillspector,clawscan-static" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.Source != "built-in" {
Expand Down
Loading
Loading