diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index 0ae5561..ca69a59 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -310,9 +310,17 @@ type cliIntent struct { } var judgePathPlaceholderPattern = regexp.MustCompile(`\{\{\s*(prompt|output_schema):([^}]+)\}\}`) -var scannerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) + +// scannerIDPattern restricts user-defined scanner IDs to lowercase. IDs are +// lowercased when used as evidence file names, so allowing uppercase would let +// two case-distinct IDs (Foo and foo) collide on the same output file. +var scannerIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) var scannerTargetPlaceholderPattern = regexp.MustCompile(`\{\{\s*target\s*\}\}`) +// maxScannerIDLength bounds user-defined scanner IDs so .json evidence file +// names stay within filesystem limits. +const maxScannerIDLength = 64 + type ResolvedRunSet struct { Options []runner.Options OutputPath string @@ -952,7 +960,10 @@ func validateProfile(name string, profile Profile) error { return fmt.Errorf("User-defined scanner in profile %s must include a non-empty id", name) } if scanner.custom && !scannerIDPattern.MatchString(scanner.ID) { - return fmt.Errorf("User-defined scanner %s in profile %s has invalid id; use letters, digits, underscores, and hyphens, starting with a letter or digit", scanner.ID, name) + return fmt.Errorf("User-defined scanner %s in profile %s has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit", scanner.ID, name) + } + if scanner.custom && len(scanner.ID) > maxScannerIDLength { + return fmt.Errorf("User-defined scanner id in profile %s is %d characters; scanner IDs are used as file names and must be at most %d characters", name, len(scanner.ID), maxScannerIDLength) } if scanner.custom && strings.TrimSpace(scanner.Command) == "" { return fmt.Errorf("User-defined scanner %s in profile %s must include a non-empty command", scanner.ID, name) diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index accb291..4c8ca2d 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -1078,7 +1078,42 @@ profiles: `) _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) - if err == nil || err.Error() != "User-defined scanner foo=bar in profile review has invalid id; use letters, digits, underscores, and hyphens, starting with a letter or digit" { + if err == nil || err.Error() != "User-defined scanner foo=bar in profile review has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit" { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsRejectsUppercaseUserDefinedScannerID(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: Foo + command: scanner {{target}} +`) + + _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err == nil || err.Error() != "User-defined scanner Foo in profile review has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit" { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsRejectsOversizedUserDefinedScannerID(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + longID := strings.Repeat("a", 65) + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: `+longID+` + command: scanner {{target}} +`) + + _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err == nil || err.Error() != "User-defined scanner id in profile review is 65 characters; scanner IDs are used as file names and must be at most 64 characters" { t.Fatalf("err = %v", err) } }