Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ clawscan ./my-skill --profile clawhub
```

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.
`openclaw.plugin.json` manifest), runs the plugin-capable scanners in the
profile, and renders the bundled judge prompt with `packageRelease` target
context.

Inspect the built-in profile catalog:

Expand All @@ -198,7 +199,7 @@ clawscan profiles -v

| Profile | Scanners | Judge |
| --- | --- | --- |
| `clawhub` | `skillspector`, `clawscan-static` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema |
| `clawhub` | `skillspector`, `clawscan-static`, `aig` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema |
| `openclaw-install-policy` | `skillspector`, `clawscan-static` | none |

### Build a custom profile with `.clawscan.yml`
Expand Down
11 changes: 10 additions & 1 deletion cmd/clawscan/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ profiles:
"Scanners",
"clawhub",
"built-in",
"skillspector, clawscan-static",
"skillspector, clawscan-static, aig",
"clawhub-aig",
"skillspector, aig",
} {
Expand Down Expand Up @@ -1041,12 +1041,15 @@ func TestRunCommandUsesBuiltInProfile(t *testing.T) {
writeSkill(t, target, "# Profile\n")
skillSpectorFixture := filepath.Join(dir, "skillspector.json")
writeFile(t, skillSpectorFixture, `{"status":"clean","findings":[]}`)
aigFixture := filepath.Join(dir, "aig.sarif.json")
writeFile(t, aigFixture, `{"version":"2.1.0","runs":[{"results":[]}]}`)

stdout := captureStdout(t, func() {
if err := run([]string{
target,
"--profile", "clawhub",
"--scanner-result", "skillspector=" + skillSpectorFixture,
"--scanner-result", "aig=" + aigFixture,
"--judge", clawHubReceiptJudgeCommand(),
"--sandbox", "off",
"--json",
Expand All @@ -1071,6 +1074,9 @@ func TestRunCommandUsesBuiltInProfile(t *testing.T) {
if _, ok := artifact.Scanners["clawscan-static"]; !ok {
t.Fatalf("missing clawscan-static scanner: %#v", artifact.Scanners)
}
if _, ok := artifact.Scanners["aig"]; !ok {
t.Fatalf("missing aig scanner: %#v", artifact.Scanners)
}
if artifact.Judge == nil || artifact.Judge.Status != "completed" {
t.Fatalf("judge = %#v", artifact.Judge)
}
Expand All @@ -1082,12 +1088,15 @@ func TestRunCommandDiscoversSkillsWithExplicitProfile(t *testing.T) {
writeSkill(t, filepath.Join(dir, "skills", "bar"), "# Bar\n")
skillSpectorFixture := filepath.Join(dir, "skillspector.json")
writeFile(t, skillSpectorFixture, `{"status":"clean","findings":[]}`)
aigFixture := filepath.Join(dir, "aig.sarif.json")
writeFile(t, aigFixture, `{"version":"2.1.0","runs":[{"results":[]}]}`)
t.Chdir(dir)

stdout := captureStdout(t, func() {
if err := run([]string{
"--profile", "clawhub",
"--scanner-result", "skillspector=" + skillSpectorFixture,
"--scanner-result", "aig=" + aigFixture,
"--judge", clawHubReceiptJudgeCommand(),
"--sandbox", "off",
"--json",
Expand Down
7 changes: 4 additions & 3 deletions docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ clawscan ./my-skill --profile clawhub
```

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.
`openclaw.plugin.json` manifest), runs the plugin-capable scanners in the
profile, and renders the bundled judge prompt with `packageRelease` target
context.

## Config discovery

Expand Down Expand Up @@ -55,7 +56,7 @@ clawscan profiles -v

| Profile | Scanners | Judge |
| --- | --- | --- |
| `clawhub` | `skillspector`, `clawscan-static` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema |
| `clawhub` | `skillspector`, `clawscan-static`, `aig` | Codex `gpt-5.5`, high reasoning, bundled ClawHub prompt/schema |
| `openclaw-install-policy` | `skillspector`, `clawscan-static` | none |

## Build a custom profile with `.clawscan.yml`
Expand Down
6 changes: 6 additions & 0 deletions internal/profiles/clawhub/clawscan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ profiles:
scanners:
- skillspector
- clawscan-static
- aig
sandbox:
env:
- OPENAI_API_KEY
- CODEX_API_KEY
- SKILLSPECTOR_PROVIDER
- LLM_API_KEY
- DEFAULT_MODEL
- DEFAULT_BASE_URL
judge:
command: >-
[ -n "$CODEX_API_KEY" ] || export CODEX_API_KEY="$OPENAI_API_KEY";
Expand Down Expand Up @@ -38,6 +42,8 @@ profiles:
- CODEX_API_KEY
- SKILLSPECTOR_PROVIDER
- LLM_API_KEY
- DEFAULT_MODEL
- DEFAULT_BASE_URL
judge:
command: >-
[ -n "$CODEX_API_KEY" ] || export CODEX_API_KEY="$OPENAI_API_KEY";
Expand Down
4 changes: 2 additions & 2 deletions internal/profiles/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(profileScannerIDs(clawhub.profile.Scanners), ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.profile.Scanners), ","); got != "skillspector,clawscan-static,aig" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.configDir != "clawhub" {
Expand Down Expand Up @@ -91,7 +91,7 @@ func TestInspectProfilesReturnsBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(profileScannerIDs(clawhub.Profile.Scanners), ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.Profile.Scanners), ","); got != "skillspector,clawscan-static,aig" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.Source != "built-in" {
Expand Down
14 changes: 8 additions & 6 deletions internal/profiles/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestResolveArgsUsesEmbeddedClawHubProfile(t *testing.T) {
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" {
if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static,aig" {
t.Fatalf("scanners = %q", got)
}
if len(opts.GateRules) != 0 {
Expand Down Expand Up @@ -66,7 +66,7 @@ func TestResolveArgsUsesEmbeddedClawHubProfile(t *testing.T) {
if string(opts.Judge.Files["clawhub/output.schema.json"]) == "" {
t.Fatal("expected embedded clawhub output schema file")
}
if got := strings.Join(opts.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER" {
if got := strings.Join(opts.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER,LLM_API_KEY,DEFAULT_MODEL,DEFAULT_BASE_URL" {
t.Fatalf("sandbox env = %q", got)
}
}
Expand Down Expand Up @@ -100,7 +100,7 @@ func TestResolveArgsUsesEmbeddedClawHubAIGCandidateProfile(t *testing.T) {
t.Fatalf("candidate judge file %s differs from clawhub", path)
}
}
if got := strings.Join(candidate.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER,LLM_API_KEY" {
if got := strings.Join(candidate.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER,LLM_API_KEY,DEFAULT_MODEL,DEFAULT_BASE_URL" {
t.Fatalf("sandbox env = %q", got)
}
}
Expand Down Expand Up @@ -358,7 +358,7 @@ func TestResolveArgsAllowsExplicitProfileWithoutTarget(t *testing.T) {
if opts.Target != "" {
t.Fatalf("target = %q", opts.Target)
}
if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static" {
if got := strings.Join(opts.Scanners, ","); got != "skillspector,clawscan-static,aig" {
t.Fatalf("scanners = %q", got)
}
}
Expand All @@ -369,7 +369,9 @@ func TestResolveArgsDoesNotRequireVirusTotalForClawHubProfile(t *testing.T) {
t.Fatal(err)
}

if err := runner.ValidateRequirements(opts, map[string]string{}); err != nil {
// OPENAI_API_KEY is already required by the ClawHub Codex judge for this
// profile, and aig's requirement is satisfied by that same key.
if err := runner.ValidateRequirements(opts, map[string]string{"OPENAI_API_KEY": "present"}); err != nil {
t.Fatalf("unexpected requirement error: %v", err)
}
if strings.Contains(strings.Join(opts.Sandbox.Env, ","), "VIRUSTOTAL_API_KEY") {
Expand Down Expand Up @@ -691,7 +693,7 @@ func TestResolveArgsAppliesCLIOverrides(t *testing.T) {
if opts.Sandbox.Image != "ghcr.io/acme/runtime:v1" {
t.Fatalf("sandbox image = %q", opts.Sandbox.Image)
}
if got := strings.Join(opts.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER,ANTHROPIC_API_KEY" {
if got := strings.Join(opts.Sandbox.Env, ","); got != "OPENAI_API_KEY,CODEX_API_KEY,SKILLSPECTOR_PROVIDER,LLM_API_KEY,DEFAULT_MODEL,DEFAULT_BASE_URL,ANTHROPIC_API_KEY" {
t.Fatalf("sandbox env = %q", got)
}
}
Expand Down
38 changes: 36 additions & 2 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -1570,10 +1570,10 @@ func RenderClawHubPrompt(systemPromptSource string, artifact Artifact) (string,
return "", err
}
var supplemental []clawhubprompt.ScannerEvidence
if artifact.Profile == clawHubAIGProfileID {
if aigAnalysis := clawHubAIGAnalysis(artifact); aigAnalysis != nil {
supplemental = append(supplemental, clawhubprompt.ScannerEvidence{
Label: "A.I.G SARIF evidence supplied to Codex",
Value: clawHubAIGAnalysis(artifact),
Value: aigAnalysis,
})
}
return clawhubprompt.Build(
Expand Down Expand Up @@ -2199,10 +2199,44 @@ func defaultSkillSpectorOpenAIProvider(env map[string]string) {
env["SKILLSPECTOR_PROVIDER"] = "openai"
}

const (
defaultAIGOpenAIModel = "gpt-5.5"
defaultAIGOpenAIBaseURL = "https://api.openai.com/v1"
)

// defaultAIGRuntimeEnv keeps credentials on their intended provider. AIG's
// upstream default is OpenRouter, so an OpenAI or Codex credential must also
// select OpenAI's endpoint and a compatible model. A dedicated LLM_API_KEY
// retains AIG's documented provider defaults.
func defaultAIGRuntimeEnv(env map[string]string) {
if env == nil {
return
}
if strings.TrimSpace(env["LLM_API_KEY"]) != "" {
return
}
if strings.TrimSpace(env["OPENAI_API_KEY"]) == "" {
codexKey := strings.TrimSpace(env["CODEX_API_KEY"])
if codexKey == "" {
return
}
env["LLM_API_KEY"] = codexKey
}
if strings.TrimSpace(env["DEFAULT_MODEL"]) == "" {
env["DEFAULT_MODEL"] = defaultAIGOpenAIModel
}
if strings.TrimSpace(env["DEFAULT_BASE_URL"]) == "" {
env["DEFAULT_BASE_URL"] = defaultAIGOpenAIBaseURL
}
}

func applyRuntimeEnvDefaults(opts Options, env map[string]string) {
if scannerRequested(opts, "skillspector") {
defaultSkillSpectorOpenAIProvider(env)
}
if scannerRequested(opts, "aig") {
defaultAIGRuntimeEnv(env)
}
}

func discoverSkillSpectorCommand() []string {
Expand Down
114 changes: 114 additions & 0 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2849,6 +2849,91 @@ func TestSkillSpectorDefaultsProviderToOpenAIWhenOpenAIKeyIsPresent(t *testing.T
}
}

func TestAIGRuntimeDefaultsKeepCredentialsOnTheirProvider(t *testing.T) {
// The clawhub judge command already treats CODEX_API_KEY as an accepted
// OpenAI credential. AIG defaults to OpenRouter, so reuse must also bind the
// request to OpenAI without enabling SkillSpector's OpenAI mode.
env := map[string]string{"CODEX_API_KEY": "fake"}
defaultAIGRuntimeEnv(env)
if env["LLM_API_KEY"] != "fake" {
t.Fatalf("LLM_API_KEY not backfilled from CODEX_API_KEY: %#v", env)
}
if env["DEFAULT_MODEL"] != defaultAIGOpenAIModel || env["DEFAULT_BASE_URL"] != defaultAIGOpenAIBaseURL {
t.Fatalf("AIG OpenAI defaults not applied: %#v", env)
}
if skillSpectorLLMEnabled(env) {
t.Fatalf("AIG fallback unexpectedly enabled SkillSpector LLM mode: %#v", env)
}

openAI := map[string]string{"OPENAI_API_KEY": "fake"}
defaultAIGRuntimeEnv(openAI)
if openAI["DEFAULT_MODEL"] != defaultAIGOpenAIModel || openAI["DEFAULT_BASE_URL"] != defaultAIGOpenAIBaseURL {
t.Fatalf("OPENAI_API_KEY did not select OpenAI defaults: %#v", openAI)
}
if _, ok := openAI["LLM_API_KEY"]; ok {
t.Fatalf("LLM_API_KEY backfilled despite existing OPENAI_API_KEY: %#v", openAI)
}

explicitOpenAI := map[string]string{
"OPENAI_API_KEY": "fake",
"DEFAULT_MODEL": "custom-model",
"DEFAULT_BASE_URL": "https://example.invalid/v1",
}
defaultAIGRuntimeEnv(explicitOpenAI)
if explicitOpenAI["DEFAULT_MODEL"] != "custom-model" || explicitOpenAI["DEFAULT_BASE_URL"] != "https://example.invalid/v1" {
t.Fatalf("explicit AIG provider settings were overwritten: %#v", explicitOpenAI)
}

dedicated := map[string]string{"CODEX_API_KEY": "fake", "OPENAI_API_KEY": "fake", "LLM_API_KEY": "fake"}
defaultAIGRuntimeEnv(dedicated)
if _, ok := dedicated["DEFAULT_MODEL"]; ok {
t.Fatalf("dedicated LLM_API_KEY received an OpenAI model default: %#v", dedicated)
}
if _, ok := dedicated["DEFAULT_BASE_URL"]; ok {
t.Fatalf("dedicated LLM_API_KEY received an OpenAI base URL default: %#v", dedicated)
}

withoutKey := map[string]string{}
defaultAIGRuntimeEnv(withoutKey)
if len(withoutKey) != 0 {
t.Fatalf("AIG defaults applied without a credential source: %#v", withoutKey)
}
}

func TestApplyRuntimeEnvDefaultsBackfillsAIGKeyOnlyWhenAIGRequested(t *testing.T) {
requested, err := ParseArgs([]string{"./skill", "--scanner", "aig"})
if err != nil {
t.Fatal(err)
}
env := map[string]string{"CODEX_API_KEY": "fake"}
applyRuntimeEnvDefaults(requested, env)
if err := ValidateRequirements(requested, env); err != nil {
t.Fatalf("unexpected requirement error after backfill: %v", err)
}
if env["LLM_API_KEY"] != "fake" {
t.Fatalf("AIG key not backfilled into LLM_API_KEY: %#v", env)
}
if env["DEFAULT_MODEL"] != defaultAIGOpenAIModel || env["DEFAULT_BASE_URL"] != defaultAIGOpenAIBaseURL {
t.Fatalf("AIG OpenAI defaults not applied: %#v", env)
}
if skillSpectorLLMEnabled(env) {
t.Fatalf("AIG fallback unexpectedly enabled SkillSpector LLM mode: %#v", env)
}

notRequested, err := ParseArgs([]string{"./skill", "--scanner", "clawscan-static"})
if err != nil {
t.Fatal(err)
}
unaffected := map[string]string{"CODEX_API_KEY": "fake"}
applyRuntimeEnvDefaults(notRequested, unaffected)
if _, ok := unaffected["LLM_API_KEY"]; ok {
t.Fatalf("LLM_API_KEY backfilled without aig in the scanner list: %#v", unaffected)
}
if _, ok := unaffected["DEFAULT_BASE_URL"]; ok {
t.Fatalf("AIG provider defaults applied without aig in the scanner list: %#v", unaffected)
}
}

func TestRunExecutesAgentVerusScanner(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "skill")
Expand Down Expand Up @@ -4564,6 +4649,35 @@ func TestRenderClawHubAIGPromptIncludesAIGEvidence(t *testing.T) {
}
}

func TestRenderClawHubPromptIncludesAIGEvidenceForProductionProfile(t *testing.T) {
// The production "clawhub" profile (not just the retired "clawhub-aig"
// candidate) now runs aig alongside skillspector and clawscan-static, so
// its SARIF evidence must reach the Codex judge whenever it produced a
// result, regardless of which profile label requested the run. The
// pre-scan malicious-signal heuristic is unchanged by this and still
// keys off clawscan-static for the "clawhub" profile label.
prompt, err := RenderClawHubPrompt("SYSTEM", Artifact{
Profile: "clawhub",
Scanners: map[string]ScannerResult{
"skillspector": {Raw: json.RawMessage(`{"status":"clean"}`)},
"clawscan-static": {Raw: json.RawMessage(`{"schemaVersion":"clawscan-static-v1","findings":[]}`)},
"aig": {Raw: json.RawMessage(`{"version":"2.1.0","runs":[{"results":[{"ruleId":"T04","level":"error"}]}]}`)},
},
})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"SkillSpector findings supplied to Codex:",
"A.I.G SARIF evidence supplied to Codex:",
`"ruleId": "T04"`,
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q:\n%s", want, prompt)
}
}
}

func TestRenderClawHubPromptIgnoresLegacyVirusTotalEvidence(t *testing.T) {
prompt, err := RenderClawHubPrompt("SYSTEM", Artifact{
Context: json.RawMessage(`{"skillSpectorCheckedAt":123}`),
Expand Down
Loading