From 7e9d00b80b76fe39786f7f00d8a6d584d3ea32b0 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 16:42:48 -0400 Subject: [PATCH] Port allowed-context matching to evidence package Co-Authored-By: Claude Opus 4.8 --- internal/evidence/context.go | 96 +++++++++++++++++++++++++++++++ internal/evidence/context_test.go | 75 ++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 internal/evidence/context.go create mode 100644 internal/evidence/context_test.go diff --git a/internal/evidence/context.go b/internal/evidence/context.go new file mode 100644 index 0000000..c9877d2 --- /dev/null +++ b/internal/evidence/context.go @@ -0,0 +1,96 @@ +package evidence + +import ( + "strings" + + "antidrift/internal/domain" +) + +// MatchesAllowed reports whether a window (class/title) is on-task per ctx. +// M3 matches on window class and title only; the domain and command helpers are +// ported for completeness and tests but have no live data source yet. +func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool { + return windowClassAllowed(ctx, class) || windowTitleAllowed(ctx, title) +} + +func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool { + candidate = normalizeCasefolded(candidate) + if candidate == "" { + return false + } + for _, allowed := range ctx.WindowClasses { + if normalizeCasefolded(allowed) == candidate { + return true + } + } + return false +} + +func windowTitleAllowed(ctx domain.AllowedContext, candidate string) bool { + candidate = normalizeCasefolded(candidate) + if candidate == "" { + return false + } + for _, allowed := range ctx.WindowTitleSubstrings { + a := normalizeCasefolded(allowed) + if a != "" && strings.Contains(candidate, a) { + return true + } + } + return false +} + +func domainAllowed(ctx domain.AllowedContext, candidate string) bool { + candidate = normalizeDomain(candidate) + if candidate == "" { + return false + } + for _, allowed := range ctx.Domains { + a := normalizeDomain(allowed) + if a == "" { + continue + } + if candidate == a { + return true + } + if strings.HasSuffix(candidate, a) && strings.HasSuffix(candidate[:len(candidate)-len(a)], ".") { + return true + } + } + return false +} + +func commandAllowed(ctx domain.AllowedContext, candidate string) bool { + candidate = executableBasename(candidate) + if candidate == "" { + return false + } + for _, allowed := range ctx.Commands { + if executableBasename(allowed) == candidate { + return true + } + } + return false +} + +func normalizeDomain(v string) string { + return strings.ToLower(strings.TrimRight(strings.TrimSpace(v), ".")) +} + +func normalizeCasefolded(v string) string { + return strings.ToLower(strings.TrimSpace(v)) +} + +// executableBasename returns the lowercased basename of the first whitespace- +// separated token (the executable), splitting on both / and \. +func executableBasename(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return "" + } + exe := fields[0] + if i := strings.LastIndexAny(exe, `/\`); i >= 0 { + exe = exe[i+1:] + } + return strings.ToLower(strings.TrimSpace(exe)) +} diff --git a/internal/evidence/context_test.go b/internal/evidence/context_test.go new file mode 100644 index 0000000..de307b9 --- /dev/null +++ b/internal/evidence/context_test.go @@ -0,0 +1,75 @@ +package evidence + +import ( + "testing" + + "antidrift/internal/domain" +) + +func TestWindowClassMatchesCaseAndTrim(t *testing.T) { + ctx := domain.AllowedContext{WindowClasses: []string{" code "}} + if !windowClassAllowed(ctx, " Code ") { + t.Fatal("class should match case-insensitively after trim") + } + if windowClassAllowed(ctx, "firefox") { + t.Fatal("unrelated class must not match") + } + if windowClassAllowed(ctx, " ") { + t.Fatal("empty candidate must not match") + } +} + +func TestWindowTitleMatchesSubstring(t *testing.T) { + ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}} + if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") { + t.Fatal("title substring should match after trim/casefold") + } + if windowTitleAllowed(ctx, "random video") { + t.Fatal("unrelated title must not match") + } +} + +func TestDomainMatchesExactAndSubdomain(t *testing.T) { + ctx := domain.AllowedContext{Domains: []string{" GitHub.COM. "}} + if !domainAllowed(ctx, "github.com") { + t.Fatal("exact domain should match") + } + if !domainAllowed(ctx, " DOCS.GITHUB.COM. ") { + t.Fatal("subdomain should match after normalization") + } + if domainAllowed(ctx, "evilgithub.com") { + t.Fatal("suffix-without-dot must not match") + } +} + +func TestCommandMatchesExecutableBasename(t *testing.T) { + ctx := domain.AllowedContext{Commands: []string{"cargo"}} + if !commandAllowed(ctx, "/home/felixm/.cargo/bin/cargo test") { + t.Fatal("basename of full path should match") + } + if !commandAllowed(ctx, "cargo") { + t.Fatal("bare command should match") + } + if commandAllowed(ctx, "/usr/bin/git status") { + t.Fatal("unrelated command must not match") + } +} + +func TestMatchesAllowedClassOrTitle(t *testing.T) { + ctx := domain.AllowedContext{ + WindowClasses: []string{"code"}, + WindowTitleSubstrings: []string{"antidrift"}, + } + if !MatchesAllowed(ctx, "Code", "anything") { + t.Fatal("class match should satisfy MatchesAllowed") + } + if !MatchesAllowed(ctx, "firefox", "my AntiDrift tab") { + t.Fatal("title match should satisfy MatchesAllowed") + } + if MatchesAllowed(ctx, "firefox", "youtube") { + t.Fatal("neither match must be off-task") + } + if MatchesAllowed(domain.AllowedContext{}, "code", "antidrift") { + t.Fatal("empty context matches nothing") + } +}