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
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Comment on lines 29 to 33
```

Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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=$?
Comment on lines +79 to +83
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)
Expand Down
78 changes: 78 additions & 0 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -37,6 +38,7 @@ func allDetectors() []detection.Detector {
&gitnotes.Detector{},
&message.Detector{},
&toolmention.Detector{},
&branchname.Detector{},
}
}

Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions detection/branchname/branchname.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions detection/branchname/branchname_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions detection/detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions scan/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions scan/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +25,7 @@ func allDetectors() []detection.Detector {
&gitnotes.Detector{},
&message.Detector{},
&toolmention.Detector{},
&branchname.Detector{},
}
}

Expand Down Expand Up @@ -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()

Expand Down
Loading