Skip to content

Commit 4fe5cd4

Browse files
authored
test(shellmode): make command lookup independent of host PATH (#353)
- 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).
1 parent 4403e25 commit 4fe5cd4

4 files changed

Lines changed: 147 additions & 3 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ concurrency:
1919
cancel-in-progress: true
2020

2121
env:
22-
GO_VERSION: "1.27.2"
22+
GO_VERSION: "1.27.3"
2323
# Module resolution: relay is the only GrayCodeAI module dependency and it is
2424
# published on the public proxy. Direct-VCS fallback is retained for anything
2525
# else.
@@ -37,6 +37,20 @@ env:
3737
GOWORK: "off"
3838

3939
jobs:
40+
commit-policy:
41+
name: commit policy
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
45+
- name: Enforce signed commits and no AI co-authors
46+
env:
47+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
GITHUB_EVENT_NAME: ${{ github.event_name }}
49+
PR_NUMBER: ${{ github.event.pull_request.number }}
50+
PUSH_BEFORE: ${{ github.event.before }}
51+
PUSH_AFTER: ${{ github.event.after }}
52+
run: bash scripts/check-commit-policy.sh
53+
4054
version:
4155
name: Canonical version metadata
4256
runs-on: ubuntu-latest

‎internal/features/shellmode/classify.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ var (
159159
cmdCache = make(map[string]bool)
160160
)
161161

162+
// lookPath is exec.LookPath, indirected so tests can make command lookup
163+
// deterministic instead of depending on which binaries the host has installed.
164+
var lookPath = exec.LookPath
165+
162166
// isValidCommand checks if a word exists as a command in PATH (cached).
163167
func isValidCommand(word string) bool {
164168
cmdCacheMu.RLock()
@@ -167,7 +171,7 @@ func isValidCommand(word string) bool {
167171
return v
168172
}
169173
cmdCacheMu.RUnlock()
170-
_, err := exec.LookPath(word)
174+
_, err := lookPath(word)
171175
result := err == nil
172176
cmdCacheMu.Lock()
173177
cmdCache[word] = result

‎internal/features/shellmode/classify_test.go‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
11
package shellmode
22

3-
import "testing"
3+
import (
4+
"os/exec"
5+
"testing"
6+
)
7+
8+
// stubCommands replaces PATH lookup for the duration of a test, so routing
9+
// results depend only on the input rather than on which binaries the host
10+
// happens to have installed. It also clears the package-level lookup cache so
11+
// earlier tests cannot leak their results into this one.
12+
func stubCommands(t *testing.T, names ...string) {
13+
t.Helper()
14+
allowed := make(map[string]bool, len(names))
15+
for _, name := range names {
16+
allowed[name] = true
17+
}
18+
19+
oldLookPath := lookPath
20+
lookPath = func(name string) (string, error) {
21+
if allowed[name] {
22+
return "/usr/bin/" + name, nil
23+
}
24+
return "", exec.ErrNotFound
25+
}
26+
27+
cmdCacheMu.Lock()
28+
oldCache := cmdCache
29+
cmdCache = make(map[string]bool)
30+
cmdCacheMu.Unlock()
31+
32+
t.Cleanup(func() {
33+
lookPath = oldLookPath
34+
cmdCacheMu.Lock()
35+
cmdCache = oldCache
36+
cmdCacheMu.Unlock()
37+
})
38+
}
439

540
func TestClassifyInput(t *testing.T) {
41+
stubCommands(t, "ls", "git", "echo", "find", "kill", "make", "sort")
42+
643
tests := []struct {
744
input string
845
want Classification
@@ -57,6 +94,15 @@ func TestClassifyInput(t *testing.T) {
5794
// shell-syntax evidence (flags/paths/operators) for these ambiguous
5895
// command names before trusting them as shell.
5996
func TestClassifyInput_AmbiguousWordsWithoutShellSyntax(t *testing.T) {
97+
// Stub every ambiguous verb so this test exercises the shell-syntax
98+
// gate, not whether the host happens to ship these binaries.
99+
stubCommands(
100+
t,
101+
"make", "test", "find", "time", "kill", "man", "more", "less",
102+
"true", "date", "file", "look", "expr", "link", "mount",
103+
"sort", "diff", "patch",
104+
)
105+
60106
sentences := []string{
61107
"make sure this works",
62108
"test the login flow",

‎scripts/check-commit-policy.sh‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env bash
2+
# Enforce the repository commit policy:
3+
# 1. Every commit in the change set has a verified signature.
4+
# 2. No commit credits an AI assistant as a co-author or generator.
5+
#
6+
# Works for pull_request and push events. Requires GH_TOKEN (the workflow's
7+
# GITHUB_TOKEN is enough) and GITHUB_REPOSITORY.
8+
set -euo pipefail
9+
10+
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
11+
event="${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME is required}"
12+
13+
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)'
14+
ai_generated_re='generated (with|by).*(claude|anthropic|openai|chatgpt|gpt|copilot|cursor|gemini|codex|aider|devin|windsurf|tabnine|cody|replit|amazon[ -]?q)'
15+
16+
commit_shas() {
17+
case "$event" in
18+
pull_request | pull_request_target)
19+
gh api --paginate "repos/$repo/pulls/${PR_NUMBER:?PR_NUMBER is required}/commits" --jq '.[].sha'
20+
;;
21+
push)
22+
local before="${PUSH_BEFORE:-}" after="${PUSH_AFTER:?PUSH_AFTER is required}"
23+
if [[ -z "$before" || "$before" =~ ^0+$ ]]; then
24+
# New branch: check the pushed head only; its history predates
25+
# this policy.
26+
printf '%s\n' "$after"
27+
else
28+
gh api "repos/$repo/compare/${before}...${after}" --jq '.commits[].sha'
29+
fi
30+
;;
31+
*)
32+
echo "commit policy: unsupported event '$event'" >&2
33+
exit 2
34+
;;
35+
esac
36+
}
37+
38+
failed=0
39+
checked=0
40+
41+
while IFS= read -r sha; do
42+
[[ -z "$sha" ]] && continue
43+
checked=$((checked + 1))
44+
45+
commit_json=$(gh api "repos/$repo/commits/$sha")
46+
verified=$(jq -r '.commit.verification.verified' <<<"$commit_json")
47+
message=$(jq -r '.commit.message' <<<"$commit_json")
48+
49+
if [[ "$verified" != "true" ]]; then
50+
echo "::error::Commit ${sha:0:12} is not signed with a verified key."
51+
failed=1
52+
fi
53+
54+
if grep -Eiq "$ai_author_re" <<<"$message"; then
55+
echo "::error::Commit ${sha:0:12} credits an AI assistant as co-author."
56+
grep -Ein "$ai_author_re" <<<"$message" | sed 's/^/ /'
57+
failed=1
58+
fi
59+
60+
if grep -Eiq "$ai_generated_re" <<<"$message"; then
61+
echo "::error::Commit ${sha:0:12} contains an AI generation attribution."
62+
grep -Ein "$ai_generated_re" <<<"$message" | sed 's/^/ /'
63+
failed=1
64+
fi
65+
done < <(commit_shas)
66+
67+
if [[ "$checked" -eq 0 ]]; then
68+
echo "commit policy: no commits to check"
69+
exit 0
70+
fi
71+
72+
if [[ "$failed" -ne 0 ]]; then
73+
echo
74+
echo "Commit policy failed."
75+
echo " - Sign commits with a key registered on GitHub (git commit -S)."
76+
echo " - Remove AI co-author / generation trailers from the message."
77+
exit 1
78+
fi
79+
80+
echo "commit policy: $checked commit(s) verified, no AI co-author trailers"

0 commit comments

Comments
 (0)