From 3534adf3c6daf62880de4b7c8fa8d9f490a33b03 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:47:28 +1000 Subject: [PATCH] fix(profiles): require an active {{target}} in user-defined scanners A BYOS command that never expands {{target}} outside quotes and comments can return valid JSON and be recorded as a completed scan that never received the target. Replace scannerTargetPlaceholdersAreUnquoted with scannerTargetPlaceholderState, which reports both whether every placeholder is unquoted and how many are active, and reject commands with zero active placeholders. --- internal/profiles/resolver.go | 27 ++++++++++++++++----- internal/profiles/resolver_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index b10b01d..23e74e1 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -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) @@ -1037,7 +1043,12 @@ 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) @@ -1045,8 +1056,12 @@ func scannerTargetPlaceholdersAreUnquoted(command string) bool { 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++ @@ -1080,7 +1095,7 @@ func scannerTargetPlaceholdersAreUnquoted(command string) bool { quote = 0 } } - return true + return allUnquoted, activeCount } func shellCommentCanStart(command string, index int) bool { diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index dc7f7d5..e5c0b49 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -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")