diff --git a/README.md b/README.md index aa89f26..fb66737 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The built-in detectors run against each commit, each producing findings at a con - `aider:` prefix (Aider's default commit format). - `Generated with Claude Code` footer. - Known commit trailers in formats unique to specific tools (such as EntireIO, Replit Agent/Assistant) that can contain values indicative of AI use. +- Branch name prefixes such as `codex/` that indicate OpenAI Codex involvement. **Low confidence** -- mentions of AI tool names in text: @@ -28,6 +29,7 @@ The built-in detectors run against each commit, each producing findings at a con ``` disclosure scan [--range=BASE..HEAD] [--format=json|text] [--min-confidence=low|medium|high] [repo-path] disclosure text [--format=json|text] [--input=FILE|-] +disclosure branch [branch-name] [--format=json|text] [--min-confidence=low|medium|high] disclosure version ``` @@ -56,6 +58,13 @@ echo "I used Claude to write this PR" | disclosure text --format=json disclosure text --input=pr-body.txt ``` +### Scan branch names + +```sh +disclosure branch codex/fix-metrics --format=json +disclosure branch "$GITHUB_HEAD_REF" +``` + ### Use as a CI gate The exit code makes it usable in shell pipelines and CI scripts: @@ -80,10 +89,10 @@ Add to your workflow to automatically label PRs with detected AI involvement: scan-pr-body: 'true' # scan PR description for tool mentions (default: true) ``` -The action builds the CLI from source, scans the PR's commits and optionally its body, then applies the configured label if anything is found. It exposes two outputs: +The action builds the CLI from source, scans the PR's commits, head branch name, and optionally its body, then applies the configured label if anything is found. It exposes two outputs: - `ai-detected` -- `true` or `false` -- `report` -- JSON object with the full findings from both the commit scan and text scan +- `report` -- JSON object with the full findings from the commit scan, branch scan, and text scan The labeling logic lives entirely in the action layer. The CLI reports findings; the action decides what to do with them. @@ -172,6 +181,7 @@ detection/committer/ Known AI bot committer emails detection/coauthor/ Co-Authored-By trailer parsing detection/gitnotes/ git-ai authorship logs from refs/notes/ai detection/message/ Commit message pattern matching +detection/branchname/ AI disclosure patterns in branch names detection/toolmention/ AI tool name mentions in text gitops/ go-git wrapper for reading commits scan/ Orchestration: run detectors over commits or text diff --git a/action/action.yml b/action/action.yml index f8fea82..bed45dc 100644 --- a/action/action.yml +++ b/action/action.yml @@ -47,6 +47,7 @@ runs: MIN_CONFIDENCE: ${{ inputs.min-confidence }} SCAN_PR_BODY: ${{ inputs.scan-pr-body }} PR_BODY: ${{ github.event.pull_request.body }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | @@ -74,13 +75,27 @@ runs: fi fi + # Scan PR head branch name + BRANCH_REPORT='{"findings":[]}' + if [ -n "${PR_HEAD_REF}" ]; then + BRANCH_REPORT=$(${{ runner.temp }}/ai-detection branch "${PR_HEAD_REF}" \ + --format=json \ + --min-confidence="${MIN_CONFIDENCE}") || BRANCH_EXIT=$? + if [ "${BRANCH_EXIT:-0}" = "1" ]; then + AI_DETECTED=true + elif [ "${BRANCH_EXIT:-0}" = "2" ]; then + echo "::warning::ai-detection branch scan failed" + fi + fi + echo "ai-detected=${AI_DETECTED}" >> "$GITHUB_OUTPUT" # Combine reports into single output COMBINED=$(jq -n \ --argjson commits "${COMMIT_REPORT}" \ --argjson text "${TEXT_REPORT}" \ - '{commits: $commits, text: $text}') + --argjson branch "${BRANCH_REPORT}" \ + '{commits: $commits, text: $text, branch: $branch}') # Use a delimiter for multiline output EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) diff --git a/cmd/cmd.go b/cmd/cmd.go index c49ec24..c311fc8 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/chaoss/disclosure/detection" + "github.com/chaoss/disclosure/detection/branchname" "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" @@ -37,6 +38,7 @@ func allDetectors() []detection.Detector { &gitnotes.Detector{}, &message.Detector{}, &toolmention.Detector{}, + &branchname.Detector{}, } } @@ -67,6 +69,7 @@ Exit codes: rootCmd.AddCommand(scanCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(textCommand(stdout, stderr, &exitCode)) + rootCmd.AddCommand(branchCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(versionCommand(stdout, &exitCode)) rootCmd.AddCommand(generateDocs(&exitCode)) @@ -176,6 +179,81 @@ Examples: return cmd } +func branchCommand(stdout, stderr io.Writer, exitCode *int) *cobra.Command { + var formatFlag string + var minConfFlag string + + cmd := &cobra.Command{ + Use: "branch [branch-name]", + Short: "Scan a branch name for AI signals", + Long: `Scan a git branch name for known AI disclosure patterns. + +Useful for scanning pull request head refs such as codex/* branches that +indicate OpenAI Codex involvement. + +Examples: + disclosure branch codex/fix-metrics --format=json + disclosure branch "$GITHUB_HEAD_REF" --min-confidence=medium`, + Example: ` disclosure branch codex/update-docs --format=json + disclosure branch "$GITHUB_HEAD_REF"`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + minConf, err := output.ConfidenceFromString(minConfFlag) + if err != nil { + fmt.Fprintln(stderr, err) + *exitCode = ExitError + return err + } + + findings := filterFindings(scan.ScanBranch(args[0], allDetectors()), minConf) + + switch formatFlag { + case "json": + if err := output.FormatJSONFindings(stdout, findings); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + *exitCode = ExitError + return err + } + case "text": + if err := output.FormatTextFindings(stdout, findings); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + *exitCode = ExitError + return err + } + default: + err := fmt.Errorf("unknown format: %s", formatFlag) + fmt.Fprintln(stderr, err) + *exitCode = ExitError + return err + } + + if len(findings) > 0 { + *exitCode = ExitAI + } + return nil + }, + } + + cmd.Flags().StringVar(&formatFlag, "format", "text", "output format: json or text") + cmd.Flags().StringVar(&minConfFlag, "min-confidence", "low", "minimum confidence level: low, medium, high (or 1, 2, 3)") + + return cmd +} + +func filterFindings(findings []detection.Finding, minConf detection.Confidence) []detection.Finding { + if minConf <= detection.ConfidenceLow { + return findings + } + + filtered := make([]detection.Finding, 0, len(findings)) + for _, f := range findings { + if f.Confidence >= minConf { + filtered = append(filtered, f) + } + } + return filtered +} + func textCommand(stdout, stderr io.Writer, exitCode *int) *cobra.Command { var formatFlag string var inputFlag string diff --git a/detection/branchname/branchname.go b/detection/branchname/branchname.go new file mode 100644 index 0000000..05700c8 --- /dev/null +++ b/detection/branchname/branchname.go @@ -0,0 +1,45 @@ +package branchname + +import ( + "fmt" + "strings" + + "github.com/chaoss/disclosure/detection" +) + +// branchPrefixPatterns maps branch name prefixes to AI tools. Matching is +// case-insensitive on the branch name. +var branchPrefixPatterns = []struct { + prefix string + tool string + confidence detection.Confidence +}{ + {prefix: "codex/", tool: "OpenAI Codex", confidence: detection.ConfidenceMedium}, +} + +type Detector struct{} + +func (d *Detector) Name() string { return "branchname" } + +func (d *Detector) Detect(input detection.Input) []detection.Finding { + branch := strings.TrimSpace(input.BranchName) + if branch == "" { + return nil + } + + lowerBranch := strings.ToLower(branch) + var findings []detection.Finding + + for _, p := range branchPrefixPatterns { + if strings.HasPrefix(lowerBranch, strings.ToLower(p.prefix)) { + findings = append(findings, detection.Finding{ + Detector: d.Name(), + Tool: p.tool, + Confidence: p.confidence, + Detail: fmt.Sprintf("branch name %q matches %s prefix", branch, p.prefix), + }) + } + } + + return findings +} diff --git a/detection/branchname/branchname_test.go b/detection/branchname/branchname_test.go new file mode 100644 index 0000000..c2cd4bc --- /dev/null +++ b/detection/branchname/branchname_test.go @@ -0,0 +1,60 @@ +package branchname + +import ( + "testing" + + "github.com/chaoss/disclosure/detection" +) + +func TestDetectCodexBranch(t *testing.T) { + d := &Detector{} + findings := d.Detect(detection.Input{BranchName: "codex/fix-collectoss-metrics"}) + + if len(findings) != 1 { + t.Fatalf("findings = %d, want 1", len(findings)) + } + + f := findings[0] + if f.Tool != "OpenAI Codex" { + t.Errorf("tool = %q, want OpenAI Codex", f.Tool) + } + if f.Confidence != detection.ConfidenceMedium { + t.Errorf("confidence = %v, want medium", f.Confidence) + } + if f.Detector != "branchname" { + t.Errorf("detector = %q, want branchname", f.Detector) + } +} + +func TestDetectCodexBranchCaseInsensitive(t *testing.T) { + d := &Detector{} + findings := d.Detect(detection.Input{BranchName: "Codex/add-feature"}) + + if len(findings) != 1 { + t.Fatalf("findings = %d, want 1", len(findings)) + } +} + +func TestDetectNoMatch(t *testing.T) { + d := &Detector{} + + for _, branch := range []string{"", "main", "feature/fix-bug", "codec/typo"} { + findings := d.Detect(detection.Input{BranchName: branch}) + if len(findings) != 0 { + t.Errorf("branch %q: expected no findings, got %v", branch, findings) + } + } +} + +func TestDetectIgnoresOtherFields(t *testing.T) { + d := &Detector{} + findings := d.Detect(detection.Input{ + BranchName: "main", + CommitMessage: "Generated with Claude Code", + Text: "I used Claude", + }) + + if len(findings) != 0 { + t.Errorf("expected branch detector to ignore non-branch fields, got %v", findings) + } +} diff --git a/detection/detection.go b/detection/detection.go index b38185b..51c4ccc 100644 --- a/detection/detection.go +++ b/detection/detection.go @@ -40,6 +40,7 @@ type Input struct { CommitHash string CommitEmail string CommitMessage string + BranchName string // Git branch name (e.g. PR head ref) Notes string // Content from refs/notes/ai, if any Text string // For text-only scans (PR body, comments) RepoPath string diff --git a/scan/scan.go b/scan/scan.go index e961bab..6a0995d 100644 --- a/scan/scan.go +++ b/scan/scan.go @@ -61,6 +61,16 @@ func ScanText(text string, detectors []detection.Detector) []detection.Finding { return findings } +// ScanBranch runs detectors against a git branch name (e.g. PR head ref). +func ScanBranch(branchName string, detectors []detection.Detector) []detection.Finding { + input := detection.Input{BranchName: branchName} + var findings []detection.Finding + for _, d := range detectors { + findings = append(findings, d.Detect(input)...) + } + return findings +} + func scanOneCommit(c gitops.Commit, detectors []detection.Detector) CommitResult { input := detection.Input{ CommitHash: c.Hash, diff --git a/scan/scan_test.go b/scan/scan_test.go index 1f10a83..0c0613f 100644 --- a/scan/scan_test.go +++ b/scan/scan_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/chaoss/disclosure/detection" + "github.com/chaoss/disclosure/detection/branchname" "github.com/chaoss/disclosure/detection/coauthor" "github.com/chaoss/disclosure/detection/committer" "github.com/chaoss/disclosure/detection/gitnotes" @@ -24,6 +25,7 @@ func allDetectors() []detection.Detector { &gitnotes.Detector{}, &message.Detector{}, &toolmention.Detector{}, + &branchname.Detector{}, } } @@ -179,6 +181,27 @@ func TestScanTextNoFindings(t *testing.T) { } } +func TestScanBranch(t *testing.T) { + detectors := allDetectors() + + findings := ScanBranch("codex/fix-collectoss", detectors) + if len(findings) != 1 { + t.Fatalf("findings = %d, want 1", len(findings)) + } + if findings[0].Tool != "OpenAI Codex" { + t.Errorf("tool = %q, want OpenAI Codex", findings[0].Tool) + } +} + +func TestScanBranchNoFindings(t *testing.T) { + detectors := allDetectors() + + findings := ScanBranch("feature/update-docs", detectors) + if len(findings) != 0 { + t.Errorf("expected no findings, got %v", findings) + } +} + func TestScanCommitWithGitNotes(t *testing.T) { dir := t.TempDir()