package main import ( "encoding/json" "strings" "testing" ) // Origin. Each fixture is a process tree in the shape a real // box produces; the facts function is what the platform files hand the // shared walk, so every OS's rule is exercised from the Linux CI run. func factsOf(m map[int]procFacts) func(int) (procFacts, bool) { return func(pid int) (procFacts, bool) { f, ok := m[pid] return f, ok } } func TestClassifyOrigin(t *testing.T) { cases := []struct { name string tree map[int]procFacts pid int observed map[int]bool extra originExtra want string signal string }{ { // The headline shape: cron -> sh -> python agent, no tty. name: "cron job on linux", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 800: {PID: 800, PPID: 1, Comm: "cron", Unit: "cron.service"}, 9001: {PID: 9001, PPID: 800, Comm: "sh"}, 9002: {PID: 9002, PPID: 9001, Comm: "python3"}, }, pid: 9002, want: originScheduler, signal: "scheduler-parent", }, { name: "systemd timer-activated unit", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 4100: {PID: 4100, PPID: 1, Comm: "python3", Unit: "nightly-agent.service"}, }, pid: 4100, extra: originExtra{TimerUnits: map[string]string{"nightly-agent.service": "nightly-agent.timer"}}, want: originScheduler, signal: "systemd-timer", }, { name: "interactive terminal", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 2000: {PID: 2000, PPID: 1, Comm: "gnome-terminal-", Interactive: true, Detail: "tty pts/0"}, 2001: {PID: 2001, PPID: 2000, Comm: "bash", Interactive: true, Detail: "tty pts/0"}, 2002: {PID: 2002, PPID: 2001, Comm: "claude", Interactive: true, Detail: "tty pts/0"}, }, pid: 2002, want: originInteractive, signal: "session", }, { // A shell under sshd has a tty too; the sshd ancestor wins. name: "ssh session", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 700: {PID: 700, PPID: 1, Comm: "sshd", Unit: "ssh.service"}, 7001: {PID: 7001, PPID: 700, Comm: "sshd-session"}, 7002: {PID: 7002, PPID: 7001, Comm: "bash", Interactive: true, Detail: "tty pts/2"}, 7003: {PID: 7003, PPID: 7002, Comm: "aider", Interactive: true, Detail: "tty pts/2"}, }, pid: 7003, want: originRemote, signal: "remote-parent", }, { name: "sub-agent forked by an observed agent", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 3000: {PID: 3000, PPID: 1, Comm: "claude", Interactive: true, Detail: "tty pts/1"}, 3001: {PID: 3001, PPID: 3000, Comm: "bash", Interactive: true, Detail: "tty pts/1"}, 3002: {PID: 3002, PPID: 3001, Comm: "codex", Interactive: true, Detail: "tty pts/1"}, }, pid: 3002, observed: map[int]bool{3000: true}, want: originAgent, signal: "parent-agent", }, { name: "systemd service, no tty", tree: map[int]procFacts{ 1: {PID: 1, Comm: "systemd"}, 5000: {PID: 5000, PPID: 1, Comm: "node", Unit: "openclaw.service"}, }, pid: 5000, want: originService, signal: "service-parent", }, { // Windows: a task under the Schedule svchost, session 0. name: "windows scheduled task", tree: map[int]procFacts{ 4: {PID: 4, Comm: "system"}, 600: {PID: 600, PPID: 4, Comm: "wininit"}, 700: {PID: 700, PPID: 600, Comm: "services"}, 1500: {PID: 1500, PPID: 700, Comm: "svchost"}, 8800: {PID: 8800, PPID: 1500, Comm: "node"}, }, pid: 8800, extra: originExtra{SchedulerPIDs: map[int]bool{1500: true}}, want: originScheduler, signal: "task-scheduler", }, { // Windows: a service under services.exe with no scheduler pid known. name: "windows service session 0", tree: map[int]procFacts{ 700: {PID: 700, PPID: 600, Comm: "services"}, 1501: {PID: 1501, PPID: 700, Comm: "svchost"}, 8801: {PID: 8801, PPID: 1501, Comm: "python"}, }, pid: 8801, want: originService, signal: "service-parent", }, { name: "windows desktop session", tree: map[int]procFacts{ 900: {PID: 900, PPID: 700, Comm: "explorer", Interactive: true, Detail: "session 1"}, 9100: {PID: 9100, PPID: 900, Comm: "windowsterminal", Interactive: true, Detail: "session 1"}, 9101: {PID: 9101, PPID: 9100, Comm: "node", Interactive: true, Detail: "session 1"}, }, pid: 9101, want: originInteractive, signal: "session", }, { // macOS: launchd is everyone's parent; an interval job is told by // its executable matching the launchd inventory. name: "launchd interval job", tree: map[int]procFacts{ 1: {PID: 1, Comm: "launchd"}, 420: {PID: 420, PPID: 1, Comm: "python3", Exe: "/usr/local/bin/python3"}, }, pid: 420, extra: originExtra{ScheduledExes: map[string]string{"/usr/local/bin/python3": "com.example.nightly"}}, want: originScheduler, signal: "scheduled-job", }, { name: "launchd daemon with no schedule", tree: map[int]procFacts{ 1: {PID: 1, Comm: "launchd"}, 421: {PID: 421, PPID: 1, Comm: "node", Exe: "/usr/local/bin/node"}, }, pid: 421, want: originService, signal: "service-parent", }, { name: "unreadable process", tree: map[int]procFacts{}, pid: 1, want: originUnknown, signal: "origin", }, { // An unreadable ancestor mid-walk: decide from what we hold. name: "unreadable ancestor, no tty", tree: map[int]procFacts{ 6000: {PID: 6000, PPID: 5999, Comm: "python3"}, }, pid: 6000, want: originUnknown, signal: "origin", }, { name: "parent map cycle terminates", tree: map[int]procFacts{ 10: {PID: 10, PPID: 11, Comm: "a"}, 11: {PID: 11, PPID: 10, Comm: "b"}, }, pid: 10, want: originUnknown, signal: "origin", }, } for _, c := range cases { got, ev := classifyOrigin(c.pid, factsOf(c.tree), c.observed, c.extra) if got != c.want { t.Errorf("%s: origin = %q, want %q (evidence %v)", c.name, got, c.want, ev) continue } if len(ev) == 0 || ev[0].Signal != c.signal { t.Errorf("%s: evidence = %v, want first signal %q", c.name, ev, c.signal) } if len(ev) > 0 && ev[0].Tier == "" { t.Errorf("%s: evidence carries no tier", c.name) } } } // ── automation kind + promotion ───────────────────────────────────────────── func TestMatchSignatureAutomation(t *testing.T) { cases := []struct { argv []string sig string auto bool ok bool }{ {[]string{"claude"}, "claude-code", false, true}, {[]string{"/usr/bin/xdotool", "type", "hello"}, "xdotool", true, true}, {[]string{"chromedriver.exe", "--port=9515"}, "chromedriver", true, true}, {[]string{"node", "/app/node_modules/playwright/cli.js", "test"}, "playwright", true, true}, {[]string{"python3", "-m", "pytest", "tests/selenium_smoke.py"}, "selenium", true, true}, {[]string{"bash", "-c", "echo playwright"}, "", false, false}, // not an interpreter {[]string{"node", "server.js"}, "", false, false}, // an agent that also drives a browser is an agent first {[]string{"node", "/x/@anthropic-ai/claude-code/cli.js", "--playwright"}, "claude-code", false, true}, } for _, c := range cases { sig, auto, ok := matchSignature(c.argv) if ok != c.ok || sig != c.sig || auto != c.auto { t.Errorf("matchSignature(%v) = (%q,%v,%v), want (%q,%v,%v)", c.argv, sig, auto, ok, c.sig, c.auto, c.ok) } } } func TestAutomationKindAndPromotion(t *testing.T) { byPid := map[int]agentObs{ 100: {PID: 100, Signature: "xdotool", Cmdline: "xdotool type hi", raw: "xdotool type hi", automation: true}, 200: {PID: 200, Signature: "playwright", Cmdline: "node playwright/cli.js", raw: "node playwright/cli.js", automation: true}, 300: {PID: 300, Signature: "claude-code", Cmdline: "claude", raw: "claude"}, } parent := func(pid int) int { return 1 } classifyObservations(byPid, parent) if byPid[100].Kind != kindAutomation || byPid[200].Kind != kindAutomation || byPid[300].Kind != kindAgent { t.Fatalf("kinds after classify: %q %q %q", byPid[100].Kind, byPid[200].Kind, byPid[300].Kind) } // 200 talks to a model: promoted. 100 only hits an ambiguous port: stays automation. conns := []connObs{ {PID: 200, RemoteIP: "1.2.3.4", RemotePort: 443}, {PID: 100, RemoteIP: "127.0.0.1", RemotePort: 8080}, } cloud := map[string]string{"1.2.3.4": "api.anthropic.com"} strong, weak := map[int]string{}, map[int]string{8080: "llama.cpp/localai"} info := func(pid int) procDetail { return procDetail{} } mergeConnections(byPid, conns, cloud, strong, weak, info, parent) if byPid[200].Kind != kindAgent { t.Errorf("playwright with model traffic should be an agent, got %q", byPid[200].Kind) } if byPid[100].Kind != kindAutomation || len(byPid[100].LLMEndpoints) != 1 { t.Errorf("xdotool on an ambiguous port should stay automation with the annotation, got %q %v", byPid[100].Kind, byPid[100].LLMEndpoints) } // evidence tiers follow the label ev := endpointEvidence(byPid[100].LLMEndpoints) if len(ev) != 1 || ev[0].Tier != tierAmbiguous { t.Errorf("ambiguous port evidence = %+v", ev) } ev = endpointEvidence(byPid[200].LLMEndpoints) if len(ev) != 1 || ev[0].Tier != tierVerified || ev[0].Value != "api.anthropic.com" { t.Errorf("cloud endpoint evidence = %+v", ev) } } func TestAgentObsWireShape(t *testing.T) { a := agentObs{PID: 1, Kind: kindAgent, Signature: "claude-code", Origin: originScheduler, Evidence: []evidence{{Signal: "scheduler-parent", Tier: tierVerified, Value: "cron pid 800"}}, automation: true} b, _ := json.Marshal(a) s := string(b) for _, want := range []string{`"origin":"scheduler"`, `"evidence":[{"signal":"scheduler-parent","tier":"verified","value":"cron pid 800"}]`} { if !strings.Contains(s, want) { t.Errorf("wire form lacks %s: %s", want, s) } } if strings.Contains(s, "automation") || strings.Contains(s, "raw") { t.Errorf("unexported fields leaked to the wire: %s", s) } }