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
27 changes: 21 additions & 6 deletions internal/profiles/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,8 +976,14 @@ func validateProfile(name string, profile Profile) error {
return fmt.Errorf("User-defined scanner %s in profile %s has an invalid env entry %q; declare bare variable names and set values in the environment, not inline", scanner.ID, name, bad)
}
}
if scanner.custom && !scannerTargetPlaceholdersAreUnquoted(scanner.Command) {
return fmt.Errorf("User-defined scanner %s in profile %s must use {{target}} outside shell quotes", scanner.ID, name)
if scanner.custom {
allUnquoted, activeCount := scannerTargetPlaceholderState(scanner.Command)
if !allUnquoted {
return fmt.Errorf("User-defined scanner %s in profile %s must use {{target}} outside shell quotes", scanner.ID, name)
}
if activeCount == 0 {
return fmt.Errorf("User-defined scanner %s in profile %s must include an active {{target}} placeholder outside shell quotes and comments so the scanner receives the target", scanner.ID, name)
}
}
if scanner.custom && runner.DefaultScannerRegistry().Contains(scanner.ID) {
return fmt.Errorf("User-defined scanner %s collides with a built-in scanner ID", scanner.ID)
Expand Down Expand Up @@ -1037,16 +1043,25 @@ func overlappingExitCodeRules(block *profileExitCodeRule, warn *profileExitCodeR
return 0, false
}

func scannerTargetPlaceholdersAreUnquoted(command string) bool {
// scannerTargetPlaceholderState reports whether every {{target}} placeholder in
// command sits outside shell quotes (allUnquoted), and how many placeholders are
// active — outside both quotes and comments (activeCount). A command with zero
// active placeholders can complete without ever receiving the target.
func scannerTargetPlaceholderState(command string) (allUnquoted bool, activeCount int) {
allUnquoted = true
matches := scannerTargetPlaceholderPattern.FindAllStringIndex(command, -1)
matchIndex := 0
quote := byte(0)
escaped := false
comment := false
for index := 0; index < len(command); index++ {
if matchIndex < len(matches) && index == matches[matchIndex][0] {
if !comment && quote != 0 {
return false
if !comment {
if quote != 0 {
allUnquoted = false
} else {
activeCount++
}
}
index = matches[matchIndex][1] - 1
matchIndex++
Expand Down Expand Up @@ -1080,7 +1095,7 @@ func scannerTargetPlaceholdersAreUnquoted(command string) bool {
quote = 0
}
}
return true
return allUnquoted, activeCount
}

func shellCommentCanStart(command string, index int) bool {
Expand Down
38 changes: 38 additions & 0 deletions internal/profiles/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,44 @@ profiles:
}
}

func TestResolveArgsRejectsUserDefinedScannerWithoutTargetPlaceholder(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, `version: 1
profiles:
review:
scanners:
- id: my-scanner
command: my-scanner --scan
`)

_, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir)
want := "User-defined scanner my-scanner in profile review must include an active {{target}} placeholder outside shell quotes and comments so the scanner receives the target"
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
}

func TestResolveArgsRejectsUserDefinedScannerWithOnlyCommentedTargetPlaceholder(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, `version: 1
profiles:
review:
scanners:
- id: my-scanner
command: |-
# scans {{target}}
my-scanner --scan
`)

_, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir)
want := "User-defined scanner my-scanner in profile review must include an active {{target}} placeholder outside shell quotes and comments so the scanner receives the target"
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
}

func TestResolveArgsSupportsAliasedScannerEntries(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
Expand Down
Loading