From ed1867a2946832fea8ea4cf765df33b9c5c08246 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 22 Oct 2026 15:12:58 +0530 Subject: [PATCH] test(shellmode): make command lookup independent of host PATH - Indirect exec.LookPath through a package variable and stub it in tests so routing results do not depend on installed binaries (e.g. make). - Clear the lookup cache around each stub so results cannot leak between tests. - Add a commit-policy CI job requiring verified signatures and rejecting AI co-author/attribution trailers. - Bump CI Go to 1.27.3 to clear GO-2026-6443 (crypto/x509). --- .github/workflows/ci.yml | 16 +++- internal/features/shellmode/classify.go | 6 +- internal/features/shellmode/classify_test.go | 48 +++++++++++- scripts/check-commit-policy.sh | 80 ++++++++++++++++++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 scripts/check-commit-policy.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae82bb9a3..0a5e633cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.27.2" + GO_VERSION: "1.27.3" # Module resolution: relay is the only GrayCodeAI module dependency and it is # published on the public proxy. Direct-VCS fallback is retained for anything # else. @@ -37,6 +37,20 @@ env: GOWORK: "off" jobs: + commit-policy: + name: commit policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Enforce signed commits and no AI co-authors + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.event.after }} + run: bash scripts/check-commit-policy.sh + version: name: Canonical version metadata runs-on: ubuntu-latest diff --git a/internal/features/shellmode/classify.go b/internal/features/shellmode/classify.go index 56ab7b4bd..efcd2e26a 100644 --- a/internal/features/shellmode/classify.go +++ b/internal/features/shellmode/classify.go @@ -159,6 +159,10 @@ var ( cmdCache = make(map[string]bool) ) +// lookPath is exec.LookPath, indirected so tests can make command lookup +// deterministic instead of depending on which binaries the host has installed. +var lookPath = exec.LookPath + // isValidCommand checks if a word exists as a command in PATH (cached). func isValidCommand(word string) bool { cmdCacheMu.RLock() @@ -167,7 +171,7 @@ func isValidCommand(word string) bool { return v } cmdCacheMu.RUnlock() - _, err := exec.LookPath(word) + _, err := lookPath(word) result := err == nil cmdCacheMu.Lock() cmdCache[word] = result diff --git a/internal/features/shellmode/classify_test.go b/internal/features/shellmode/classify_test.go index dcf4c40c2..7244e06f1 100644 --- a/internal/features/shellmode/classify_test.go +++ b/internal/features/shellmode/classify_test.go @@ -1,8 +1,45 @@ package shellmode -import "testing" +import ( + "os/exec" + "testing" +) + +// stubCommands replaces PATH lookup for the duration of a test, so routing +// results depend only on the input rather than on which binaries the host +// happens to have installed. It also clears the package-level lookup cache so +// earlier tests cannot leak their results into this one. +func stubCommands(t *testing.T, names ...string) { + t.Helper() + allowed := make(map[string]bool, len(names)) + for _, name := range names { + allowed[name] = true + } + + oldLookPath := lookPath + lookPath = func(name string) (string, error) { + if allowed[name] { + return "/usr/bin/" + name, nil + } + return "", exec.ErrNotFound + } + + cmdCacheMu.Lock() + oldCache := cmdCache + cmdCache = make(map[string]bool) + cmdCacheMu.Unlock() + + t.Cleanup(func() { + lookPath = oldLookPath + cmdCacheMu.Lock() + cmdCache = oldCache + cmdCacheMu.Unlock() + }) +} func TestClassifyInput(t *testing.T) { + stubCommands(t, "ls", "git", "echo", "find", "kill", "make", "sort") + tests := []struct { input string want Classification @@ -57,6 +94,15 @@ func TestClassifyInput(t *testing.T) { // shell-syntax evidence (flags/paths/operators) for these ambiguous // command names before trusting them as shell. func TestClassifyInput_AmbiguousWordsWithoutShellSyntax(t *testing.T) { + // Stub every ambiguous verb so this test exercises the shell-syntax + // gate, not whether the host happens to ship these binaries. + stubCommands( + t, + "make", "test", "find", "time", "kill", "man", "more", "less", + "true", "date", "file", "look", "expr", "link", "mount", + "sort", "diff", "patch", + ) + sentences := []string{ "make sure this works", "test the login flow", diff --git a/scripts/check-commit-policy.sh b/scripts/check-commit-policy.sh new file mode 100644 index 000000000..04b661670 --- /dev/null +++ b/scripts/check-commit-policy.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Enforce the repository commit policy: +# 1. Every commit in the change set has a verified signature. +# 2. No commit credits an AI assistant as a co-author or generator. +# +# Works for pull_request and push events. Requires GH_TOKEN (the workflow's +# GITHUB_TOKEN is enough) and GITHUB_REPOSITORY. +set -euo pipefail + +repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +event="${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}" + +ai_author_re='^[[:space:]]*co-authored-by:.*(claude|anthropic|openai|chatgpt|gpt|copilot|cursor|gemini|codex|aider|devin|windsurf|tabnine|cody|replit|amazon[ -]?q|jetbrains[ -]?ai|sourcegraph)' +ai_generated_re='generated (with|by).*(claude|anthropic|openai|chatgpt|gpt|copilot|cursor|gemini|codex|aider|devin|windsurf|tabnine|cody|replit|amazon[ -]?q)' + +commit_shas() { + case "$event" in + pull_request | pull_request_target) + gh api --paginate "repos/$repo/pulls/${PR_NUMBER:?PR_NUMBER is required}/commits" --jq '.[].sha' + ;; + push) + local before="${PUSH_BEFORE:-}" after="${PUSH_AFTER:?PUSH_AFTER is required}" + if [[ -z "$before" || "$before" =~ ^0+$ ]]; then + # New branch: check the pushed head only; its history predates + # this policy. + printf '%s\n' "$after" + else + gh api "repos/$repo/compare/${before}...${after}" --jq '.commits[].sha' + fi + ;; + *) + echo "commit policy: unsupported event '$event'" >&2 + exit 2 + ;; + esac +} + +failed=0 +checked=0 + +while IFS= read -r sha; do + [[ -z "$sha" ]] && continue + checked=$((checked + 1)) + + commit_json=$(gh api "repos/$repo/commits/$sha") + verified=$(jq -r '.commit.verification.verified' <<<"$commit_json") + message=$(jq -r '.commit.message' <<<"$commit_json") + + if [[ "$verified" != "true" ]]; then + echo "::error::Commit ${sha:0:12} is not signed with a verified key." + failed=1 + fi + + if grep -Eiq "$ai_author_re" <<<"$message"; then + echo "::error::Commit ${sha:0:12} credits an AI assistant as co-author." + grep -Ein "$ai_author_re" <<<"$message" | sed 's/^/ /' + failed=1 + fi + + if grep -Eiq "$ai_generated_re" <<<"$message"; then + echo "::error::Commit ${sha:0:12} contains an AI generation attribution." + grep -Ein "$ai_generated_re" <<<"$message" | sed 's/^/ /' + failed=1 + fi +done < <(commit_shas) + +if [[ "$checked" -eq 0 ]]; then + echo "commit policy: no commits to check" + exit 0 +fi + +if [[ "$failed" -ne 0 ]]; then + echo + echo "Commit policy failed." + echo " - Sign commits with a key registered on GitHub (git commit -S)." + echo " - Remove AI co-author / generation trailers from the message." + exit 1 +fi + +echo "commit policy: $checked commit(s) verified, no AI co-author trailers"