package workflow import ( "os" "path/filepath" "testing" "time" ) // writeSentinel creates a fresh authorization sentinel for kind under dir. func writeSentinel(t *testing.T, dir, kind string) string { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } p := filepath.Join(dir, "git-"+kind+"-authorized") if err := os.WriteFile(p, nil, 0o600); err != nil { t.Fatal(err) } return p } func TestScanGitWriteGuard_UnauthorizedCommitDenies(t *testing.T) { det := newGuardDetector(t) res := ScanGitWriteGuard(det, `git commit -m "fix: x"`, t.TempDir(), t.TempDir(), ".eeco") if res.Decision != decisionDeny { t.Fatalf("unauthorized commit: Decision=%q, want deny", res.Decision) } if len(res.Consumed) != 0 { t.Errorf("a deny must not consume a sentinel, got %v", res.Consumed) } } func TestScanGitWriteGuard_AuthorizedCommitAllowsAndConsumes(t *testing.T) { det := newGuardDetector(t) orig := stagedDiff defer func() { stagedDiff = orig }() stagedDiff = func(string) string { return "" } state := t.TempDir() writeSentinel(t, state, "commit") res := ScanGitWriteGuard(det, `git commit -m "fix: a real change"`, t.TempDir(), state, ".eeco") if res.Decision != decisionAllow { t.Fatalf("authorized clean commit: Decision=%q reason=%q, want allow", res.Decision, res.Reason) } if len(res.Consumed) != 1 || res.Consumed[0] != "commit" { t.Errorf("Consumed=%v, want [commit]", res.Consumed) } } func TestScanGitWriteGuard_StaleSentinelDeniesAndClears(t *testing.T) { det := newGuardDetector(t) state := t.TempDir() p := writeSentinel(t, state, "commit") old := time.Now().Add(-30 * time.Minute) if err := os.Chtimes(p, old, old); err != nil { t.Fatal(err) } res := ScanGitWriteGuard(det, `git commit -m x`, t.TempDir(), state, ".eeco") if res.Decision != decisionDeny { t.Fatalf("stale sentinel: Decision=%q, want deny", res.Decision) } if _, err := os.Stat(p); !os.IsNotExist(err) { t.Errorf("stale sentinel should have been cleared, stat err=%v", err) } } func TestScanGitWriteGuard_AuthorizedCommitWithAttributionDeniesPreserved(t *testing.T) { det := newGuardDetector(t) orig := stagedDiff defer func() { stagedDiff = orig }() stagedDiff = func(string) string { return "" } state := t.TempDir() p := writeSentinel(t, state, "commit") cmd := `git commit -m "fix: x" -m "` + coTrailer() + `"` res := ScanGitWriteGuard(det, cmd, t.TempDir(), state, ".eeco") if res.Decision != decisionDeny { t.Fatalf("authorized commit carrying a trailer: Decision=%q, want deny", res.Decision) } if len(res.Consumed) != 0 { t.Errorf("gate-deny must preserve the sentinel, Consumed=%v", res.Consumed) } if _, err := os.Stat(p); err != nil { t.Errorf("sentinel must survive a gate-deny, stat err=%v", err) } } func TestScanGitWriteGuard_AuthorizedCommitWorkspaceLeakDenies(t *testing.T) { det := newGuardDetector(t) orig := stagedDiff defer func() { stagedDiff = orig }() // Use a neutral workspace name in the fixture (not the repo's real ".eeco") // so the leak literal in this test source does not trip the repo's own // leak-guard, mirroring the other gate tests. stagedDiff = func(string) string { return "diff --git a/x b/x\n+see ws/state/queue.md for details\n" } state := t.TempDir() writeSentinel(t, state, "commit") res := ScanGitWriteGuard(det, `git commit -m "fix"`, t.TempDir(), state, "ws") if res.Decision != decisionDeny { t.Fatalf("staged workspace-path leak: Decision=%q, want deny", res.Decision) } } func TestScanGitWriteGuard_TagMutationGated(t *testing.T) { det := newGuardDetector(t) state := t.TempDir() // Unauthorized mutation denies. if res := ScanGitWriteGuard(det, `git tag -a v1 -m x`, t.TempDir(), state, ".eeco"); res.Decision != decisionDeny { t.Errorf("unauthorized tag mutation: Decision=%q, want deny", res.Decision) } // Authorized mutation allows + consumes. writeSentinel(t, state, "tag") res := ScanGitWriteGuard(det, `git tag v1`, t.TempDir(), state, ".eeco") if res.Decision != decisionAllow { t.Fatalf("authorized tag create: Decision=%q, want allow", res.Decision) } if len(res.Consumed) != 1 || res.Consumed[0] != "tag" { t.Errorf("Consumed=%v, want [tag]", res.Consumed) } } func TestScanGitWriteGuard_ReadOnlyTagAndGitPass(t *testing.T) { det := newGuardDetector(t) for _, cmd := range []string{ `git tag`, `git tag -l`, `git tag -n5`, `git status`, `git log --oneline`, `echo "git commit -m bad"`, `ls -la && pwd`, } { res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco") if res.Decision != decisionAllow { t.Errorf("%q: Decision=%q, want allow", cmd, res.Decision) } } } func TestScanGitWriteGuard_FailClosedOnParseError(t *testing.T) { det := newGuardDetector(t) // An unterminated single quote cannot be tokenized cleanly; the raw text // shows `git commit`, so the guard fails CLOSED and denies. cmd := `git commit -m 'unterminated` res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco") if res.Decision != decisionDeny { t.Fatalf("parse-error with git commit substring: Decision=%q, want deny (fail-closed)", res.Decision) } } func TestScanGitWriteGuard_WrapperBackstopDenies(t *testing.T) { det := newGuardDetector(t) for _, cmd := range []string{ `bash -c "git commit -m x"`, `sh -c 'git tag v9'`, `eval "git commit -m y"`, } { res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco") if res.Decision != decisionDeny { t.Errorf("wrapped write %q: Decision=%q, want deny", cmd, res.Decision) } } } func TestScanGitWriteGuard_ChainedUnauthorizedCommitDenies(t *testing.T) { det := newGuardDetector(t) res := ScanGitWriteGuard(det, `git add . && git commit -m subject`, t.TempDir(), t.TempDir(), ".eeco") if res.Decision != decisionDeny { t.Errorf("chained unauthorized commit: Decision=%q, want deny", res.Decision) } } func TestClassifyGitWrite(t *testing.T) { cases := []struct { words []string verb string mut bool }{ {[]string{"git", "commit", "-m", "x"}, "commit", false}, {[]string{"git", "-C", "/r", "commit"}, "commit", false}, {[]string{"GIT_AUTHOR_NAME=bot", "git", "commit"}, "commit", false}, {[]string{"git", "tag"}, "tag", false}, {[]string{"git", "tag", "-l"}, "tag", false}, {[]string{"git", "tag", "v1"}, "tag", true}, {[]string{"git", "tag", "-a", "v1", "-m", "x"}, "tag", true}, {[]string{"git", "tag", "-d", "v1"}, "tag", true}, {[]string{"git", "status"}, "status", false}, {[]string{"git", "--", "commit"}, "", false}, {[]string{"echo", "git", "commit"}, "", false}, } for _, c := range cases { verb, mut := classifyGitWrite(c.words) if verb != c.verb || mut != c.mut { t.Errorf("classifyGitWrite(%v) = (%q,%v), want (%q,%v)", c.words, verb, mut, c.verb, c.mut) } } } func TestCommandParseOK(t *testing.T) { ok := []string{ `git commit -m "fix"`, `git commit -m 'fix'`, `git commit -m "a \"quoted\" word"`, `git status`, } for _, c := range ok { if !commandParseOK(c) { t.Errorf("commandParseOK(%q) = false, want true", c) } } bad := []string{ `git commit -m 'unterminated`, `git commit -m "open`, } for _, c := range bad { if commandParseOK(c) { t.Errorf("commandParseOK(%q) = true, want false", c) } } }