bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Kind classification. The fixture below is the measured shape
|
||||
// that produced the false alarm: the Claude DESKTOP app on Windows, installed
|
||||
// from the Store, with its eight Chromium helpers — every one of them
|
||||
// `claude.exe`, every one of them matched as "claude-code".
|
||||
|
||||
const winClaudeExe = `"C:\Program Files\WindowsApps\Claude_1.40609.1.0_x64__pzs8sxrjxfjjc\app\claude.exe"`
|
||||
|
||||
func claudeDesktopSnapshot() (map[int]agentObs, func(int) int) {
|
||||
helpers := map[int]string{
|
||||
5948: ` --type=renderer --user-data-dir="C:\Users\u\AppData\Roaming\Claude" --standard-schemes`,
|
||||
11168: ` --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US`,
|
||||
28084: ` --type=gpu-process --user-data-dir="C:\Users\u\AppData\Roaming\Claude"`,
|
||||
29304: ` --type=crashpad-handler --user-data-dir=C:\Users\u\AppData\Roaming\Claude`,
|
||||
33640: ` --type=utility --utility-sub-type=video_capture.mojom.VideoCaptureService`,
|
||||
37528: ` --type=renderer --user-data-dir="C:\Users\u\AppData\Roaming\Claude"`,
|
||||
37616: ` --type=utility --utility-sub-type=audio.mojom.AudioService --lang=en-US`,
|
||||
38520: ` --type=utility --utility-sub-type=node.mojom.NodeService --lang=en-US`,
|
||||
}
|
||||
byPid := map[int]agentObs{
|
||||
4532: {PID: 4532, Signature: "claude-code", Cmdline: winClaudeExe, raw: winClaudeExe},
|
||||
// Claude Code itself, in a terminal: stays an agent.
|
||||
7000: {PID: 7000, Signature: "claude-code", Cmdline: "claude --resume", raw: "claude --resume"},
|
||||
// A macOS app bundle with no helper of its own in the candidate set.
|
||||
8000: {PID: 8000, Signature: "cursor", Cmdline: "/Applications/Cursor.app/Contents/MacOS/Cursor",
|
||||
raw: "/Applications/Cursor.app/Contents/MacOS/Cursor"},
|
||||
}
|
||||
for pid, args := range helpers {
|
||||
full := winClaudeExe + args
|
||||
byPid[pid] = agentObs{PID: pid, Signature: "claude-code", Cmdline: truncate(full, 200), raw: full}
|
||||
}
|
||||
parent := func(pid int) int {
|
||||
switch {
|
||||
case helpers[pid] != "":
|
||||
return 4532
|
||||
case pid == 4532:
|
||||
return 1200 // explorer
|
||||
case pid == 7000:
|
||||
return 6000 // a shell
|
||||
case pid == 8000:
|
||||
return 1 // launchd
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return byPid, parent
|
||||
}
|
||||
|
||||
func TestArgv0AndBundleRoot(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
winClaudeExe + ` --type=renderer`: `C:\Program Files\WindowsApps\Claude_1.40609.1.0_x64__pzs8sxrjxfjjc\app\claude.exe`,
|
||||
"/usr/local/bin/claude --resume": "/usr/local/bin/claude",
|
||||
"claude": "claude",
|
||||
"'/Applications/Foo Bar.app/Contents/MacOS/foo' --type=gpu": "/Applications/Foo Bar.app/Contents/MacOS/foo",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := argv0(in); got != want {
|
||||
t.Errorf("argv0(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := bundleRoot("/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper"); got != "/applications/google chrome.app/" {
|
||||
t.Errorf("bundleRoot must be the OUTERMOST bundle, got %q", got)
|
||||
}
|
||||
if got := bundleRoot("/usr/local/bin/claude"); got != "" {
|
||||
t.Errorf("no bundle = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper is a --type= process whose PARENT runs the same program. The flag
|
||||
// alone must never be enough: that would let any process vanish by adding it.
|
||||
func TestIsGUIHelperNeedsSameProgramParent(t *testing.T) {
|
||||
helper := winClaudeExe + ` --type=renderer --user-data-dir="C:\x"`
|
||||
if !isGUIHelper(helper, winClaudeExe) {
|
||||
t.Error("a --type= child of the same exe is a helper")
|
||||
}
|
||||
if !isGUIHelper("/opt/Claude/claude --type=zygote", "/opt/Claude/claude") {
|
||||
t.Error("linux same-binary helper")
|
||||
}
|
||||
chromeHelper := "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper (GPU).app/Contents/MacOS/Google Chrome Helper (GPU) --type=gpu-process"
|
||||
if !isGUIHelper(chromeHelper, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") {
|
||||
t.Error("macOS helper in the same outer bundle is a helper")
|
||||
}
|
||||
// The evasion / false-positive reproduced here: an agent whose
|
||||
// PROMPT mentions the flag, under a shell.
|
||||
if isGUIHelper("claude -p add a --type=json flag to the export command", "-bash") {
|
||||
t.Error("an agent mentioning --type= under a shell is NOT a helper")
|
||||
}
|
||||
if isGUIHelper("foo --type=x", "") {
|
||||
t.Error("no parent command line: never a helper")
|
||||
}
|
||||
if isGUIHelper(winClaudeExe, winClaudeExe) {
|
||||
t.Error("no --type= flag: not a helper even under the same exe")
|
||||
}
|
||||
if hasTypeFlag("node cli.js --type-check") {
|
||||
t.Error("a flag that merely starts with the marker is not the marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyObservationsClaudeDesktop(t *testing.T) {
|
||||
byPid, parent := claudeDesktopSnapshot()
|
||||
helperRoot := classifyObservations(byPid, parent)
|
||||
|
||||
if len(helperRoot) != 8 {
|
||||
t.Fatalf("want 8 helpers collapsed, got %d: %v", len(helperRoot), helperRoot)
|
||||
}
|
||||
for pid, root := range helperRoot {
|
||||
if root != 4532 {
|
||||
t.Errorf("helper %d attributed to %d, want 4532", pid, root)
|
||||
}
|
||||
if _, still := byPid[pid]; still {
|
||||
t.Errorf("helper %d must be dropped from the report", pid)
|
||||
}
|
||||
}
|
||||
if len(byPid) != 3 {
|
||||
t.Fatalf("want 3 observations (app, agent, bundle app), got %d: %+v", len(byPid), byPid)
|
||||
}
|
||||
|
||||
app := byPid[4532]
|
||||
if app.Kind != kindApp || app.Signature != "claude-desktop" || app.PPID != 1200 {
|
||||
t.Errorf("the desktop app = %+v, want kind=app signature=claude-desktop ppid=1200", app)
|
||||
}
|
||||
cli := byPid[7000]
|
||||
if cli.Kind != kindAgent || cli.Signature != "claude-code" || cli.PPID != 6000 {
|
||||
t.Errorf("claude code in a terminal = %+v, want kind=agent signature=claude-code", cli)
|
||||
}
|
||||
bundle := byPid[8000]
|
||||
if bundle.Kind != kindApp || bundle.Signature != "cursor-app" {
|
||||
t.Errorf("a .app bundle = %+v, want kind=app signature=cursor-app", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// Four rules pinned here: an agent that mentions the flag
|
||||
// stays an agent; a bundle path as an ARGUMENT is not a bundle; a helper with
|
||||
// an unreadable parent is never pinned to pid 0; nested helpers resolve to
|
||||
// the app.
|
||||
func TestClassifyObservationsEdges(t *testing.T) {
|
||||
mention := "claude -p add a --type=json flag to the export command"
|
||||
byPid := map[int]agentObs{
|
||||
7000: {PID: 7000, Signature: "claude-code", Cmdline: mention, raw: mention},
|
||||
7001: {PID: 7001, Signature: "aider", Cmdline: "aider /Applications/Foo.app/Contents/MacOS/foo.swift",
|
||||
raw: "aider /Applications/Foo.app/Contents/MacOS/foo.swift"},
|
||||
// a --type= candidate whose parent cannot be read
|
||||
7002: {PID: 7002, Signature: "claude-code", Cmdline: winClaudeExe + " --type=gpu-process", raw: winClaudeExe + " --type=gpu-process"},
|
||||
// zygote pattern: browser → zygote helper → renderer helper
|
||||
4532: {PID: 4532, Signature: "claude-code", Cmdline: "/opt/Claude/claude", raw: "/opt/Claude/claude"},
|
||||
4600: {PID: 4600, Signature: "claude-code", Cmdline: "/opt/Claude/claude --type=zygote", raw: "/opt/Claude/claude --type=zygote"},
|
||||
4601: {PID: 4601, Signature: "claude-code", Cmdline: "/opt/Claude/claude --type=renderer", raw: "/opt/Claude/claude --type=renderer"},
|
||||
}
|
||||
parents := map[int]int{7000: 6000, 7001: 6000, 7002: 0, 4532: 1200, 4600: 4532, 4601: 4600}
|
||||
helperRoot := classifyObservations(byPid, func(pid int) int { return parents[pid] })
|
||||
|
||||
if a := byPid[7000]; a.Kind != kindAgent || a.Signature != "claude-code" {
|
||||
t.Errorf("an agent mentioning --type= must stay an agent, got %+v", a)
|
||||
}
|
||||
if a := byPid[7001]; a.Kind != kindAgent || a.Signature != "aider" {
|
||||
t.Errorf("a bundle path as an argument must not make an app, got %+v", a)
|
||||
}
|
||||
if a, ok := byPid[7002]; !ok || a.Kind != kindAgent {
|
||||
t.Errorf("a --type= candidate with an unreadable parent stays reported as what it matched, got %+v (present=%v)", a, ok)
|
||||
}
|
||||
if _, phantom := helperRoot[7002]; phantom {
|
||||
t.Error("an unreadable parent must never produce a helper root")
|
||||
}
|
||||
if helperRoot[4600] != 4532 || helperRoot[4601] != 4532 {
|
||||
t.Errorf("nested helpers must resolve to the app, got %v", helperRoot)
|
||||
}
|
||||
if a := byPid[4532]; a.Kind != kindApp || a.Signature != "claude-desktop" {
|
||||
t.Errorf("the app owning nested helpers = %+v", a)
|
||||
}
|
||||
for _, pid := range []int{4600, 4601} {
|
||||
if _, still := byPid[pid]; still {
|
||||
t.Errorf("helper %d must be dropped", pid)
|
||||
}
|
||||
}
|
||||
if _, phantom := byPid[0]; phantom {
|
||||
t.Error("pid 0 must never be observed")
|
||||
}
|
||||
}
|
||||
|
||||
// The helper that actually holds the socket (network.mojom.NetworkService)
|
||||
// is credited to the app, so "Claude desktop, talking to api.anthropic.com"
|
||||
// is one observation — and a helper never becomes a "client".
|
||||
func TestMergeConnectionsAttributesHelperSocketsToTheApp(t *testing.T) {
|
||||
byPid, parent := claudeDesktopSnapshot()
|
||||
helperRoot := classifyObservations(byPid, parent)
|
||||
cloud := map[string]string{"1.2.3.4": "api.anthropic.com"}
|
||||
strong := map[int]string{11434: "ollama"}
|
||||
weak := map[int]string{}
|
||||
info := func(pid int) procDetail {
|
||||
return procDetail{User: "u", Cmdline: byPid[pid].raw, Exe: byPid[pid].Exe}
|
||||
}
|
||||
|
||||
conns := []connObs{{PID: 11168, RemoteIP: "1.2.3.4", RemotePort: 443}}
|
||||
for i := range conns {
|
||||
if root, ok := helperRoot[conns[i].PID]; ok {
|
||||
conns[i].PID, conns[i].ViaHelper = root, true
|
||||
}
|
||||
}
|
||||
mergeConnections(byPid, conns, cloud, strong, weak, info, parent)
|
||||
if got := byPid[4532].LLMEndpoints; len(got) != 1 || got[0] != "api.anthropic.com" {
|
||||
t.Fatalf("the app must carry its helper's endpoint, got %v", got)
|
||||
}
|
||||
if _, leaked := byPid[11168]; leaked {
|
||||
t.Fatal("a helper must never appear as its own observation")
|
||||
}
|
||||
if byPid[4532].Kind != kindApp {
|
||||
t.Fatalf("annotating must not change the kind, got %+v", byPid[4532])
|
||||
}
|
||||
if _, phantom := byPid[0]; phantom {
|
||||
t.Fatal("pid 0 must never be observed")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown Chromium helper (a browser tab calling a model API) is the
|
||||
// user's APP, attributed to its parent — never a disguised-agent "client".
|
||||
// An unknown process that merely MENTIONS the flag is an ordinary client, and
|
||||
// the shell above it is never blamed.
|
||||
func TestMergeConnectionsUnknownHelperBecomesAnApp(t *testing.T) {
|
||||
cloud := map[string]string{"1.2.3.4": "api.openai.com"}
|
||||
strong := map[int]string{11434: "ollama"}
|
||||
weak := map[int]string{}
|
||||
chrome := `"C:\Program Files\Google\Chrome\Application\chrome.exe"`
|
||||
cmd := map[int]string{
|
||||
700: chrome,
|
||||
750: chrome + ` --type=zygote`,
|
||||
800: chrome + ` --type=utility --utility-sub-type=network.mojom.NetworkService`,
|
||||
900: `"C:\x\orphan.exe" --type=renderer`,
|
||||
1000: "-bash",
|
||||
1001: "python3 agent.py --type=fast", // mentions the flag; parent is a shell
|
||||
}
|
||||
parents := map[int]int{800: 750, 750: 700, 700: 4, 900: 1, 1001: 1000, 1000: 5}
|
||||
// The kernel's executable path per pid. A helper and its app run the same
|
||||
// binary in life; here they are given DIFFERENT paths on purpose, so the
|
||||
// assertion below can tell which one the observation kept.
|
||||
exe := map[int]string{
|
||||
700: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
||||
750: `C:\Program Files\Google\Chrome\Application\zygote.exe`,
|
||||
800: `C:\Program Files\Google\Chrome\Application\helper.exe`,
|
||||
900: `C:\x\orphan.exe`,
|
||||
1001: `/usr/bin/python3`,
|
||||
}
|
||||
info := func(pid int) procDetail {
|
||||
return procDetail{User: "u", Cmdline: cmd[pid], Exe: exe[pid]}
|
||||
}
|
||||
parent := func(pid int) int { return parents[pid] }
|
||||
|
||||
byPid := map[int]agentObs{}
|
||||
mergeConnections(byPid, []connObs{
|
||||
{PID: 800, RemoteIP: "1.2.3.4", RemotePort: 443},
|
||||
{PID: 900, RemoteIP: "1.2.3.4", RemotePort: 443}, // parent is init: dropped
|
||||
{PID: 1001, RemoteIP: "1.2.3.4", RemotePort: 443}, // not a helper: a client
|
||||
}, cloud, strong, weak, info, parent)
|
||||
|
||||
for _, pid := range []int{800, 750, 0, 1, 1000} {
|
||||
if _, ok := byPid[pid]; ok {
|
||||
t.Errorf("pid %d must not be observed: %+v", pid, byPid[pid])
|
||||
}
|
||||
}
|
||||
// A --type= process whose parent is init (or unreadable) has no app to be
|
||||
// credited to: it is reported under its OWN pid as a client — visible and
|
||||
// containable — never as a phantom pid-0 app, never silently dropped.
|
||||
if c, ok := byPid[900]; !ok || c.Kind != kindClient || c.PID != 900 {
|
||||
t.Errorf("an orphaned --type= process is a client under its own pid, got %+v", byPid[900])
|
||||
}
|
||||
app, ok := byPid[700]
|
||||
if !ok || app.Kind != kindApp || app.Signature != "gui-app" || app.PPID != 4 {
|
||||
t.Fatalf("the browser must be observed as an app (through the zygote), got %+v", byPid)
|
||||
}
|
||||
if len(app.LLMEndpoints) != 1 || app.LLMEndpoints[0] != "api.openai.com" {
|
||||
t.Errorf("endpoints = %v", app.LLMEndpoints)
|
||||
}
|
||||
// The observation is the APP, so the file it names must be the APP's. Keeping
|
||||
// the helper's would key a mark (sanction.go) on a file that is not the
|
||||
// program the row names — the user would vouch for one thing by clicking
|
||||
// another.
|
||||
//
|
||||
// MUTATION: leave `exe` untouched when the walk resolves to the app
|
||||
// (drop `exe = rd.Exe` from the app branch of mergeConnections).
|
||||
// RESULT: red — the app row carried helper.exe.
|
||||
if want := exe[700]; app.Exe != want {
|
||||
t.Errorf("the app's row carries exe %q, want its own %q", app.Exe, want)
|
||||
}
|
||||
if c, ok := byPid[1001]; !ok || c.Kind != kindClient || c.Signature != "llm-client" {
|
||||
t.Errorf("a process mentioning the flag under a shell is a client, got %+v", byPid[1001])
|
||||
}
|
||||
// MUTATION: drop `Exe: exe` from the agentObs literal mergeConnections builds.
|
||||
// RESULT: red — an observation the network signal created could never be
|
||||
// marked as yours, because it carried no file at all.
|
||||
if got := byPid[1001].Exe; got != "/usr/bin/python3" {
|
||||
t.Errorf("a client observation must carry the kernel's path, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescendantsOf(t *testing.T) {
|
||||
parents := map[int]int{
|
||||
101: 50, 102: 101, 103: 102, 104: 101, // a tree under 101
|
||||
200: 50, 201: 200, // a sibling tree
|
||||
300: 300, // self-parent junk must not loop
|
||||
}
|
||||
got := descendantsOf(parents, 101)
|
||||
sort.Ints(got)
|
||||
want := []int{102, 103, 104}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("descendantsOf(101) = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("descendantsOf(101) = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
if got := descendantsOf(parents, 103); len(got) != 0 {
|
||||
t.Errorf("a leaf has no descendants, got %v", got)
|
||||
}
|
||||
if got := descendantsOf(parents, 999); len(got) != 0 {
|
||||
t.Errorf("an unknown pid has no descendants, got %v", got)
|
||||
}
|
||||
if got := descendantsOf(parents, 300); len(got) != 0 {
|
||||
t.Errorf("a self-parented pid must not recurse, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcStatPPID(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"1234 (claude) S 999 1234 1234 0 -1": 999,
|
||||
"1234 (a (weird) name) R 77 1 1 0 -1": 77, // parens in comm
|
||||
"1234 (x y) S 5 1": 5, // spaces in comm
|
||||
"garbage": 0,
|
||||
"": 0,
|
||||
"1 (systemd) S 0 1 1 0 -1 4194560 1 0 0 0 0 0 0": 0,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseProcStatPPID(in); got != want {
|
||||
t.Errorf("parseProcStatPPID(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contain takes the agent's tree, refuses an app, and spares the bastion's own
|
||||
// local agent child — decided from stubs, so nothing real is ever signalled.
|
||||
func TestMaybeContainKillsTreeAndRefusesApps(t *testing.T) {
|
||||
prevObs, prevTree, prevKill := observedAgents, processTree, killProcess
|
||||
defer func() { observedAgents, processTree, killProcess = prevObs, prevTree, prevKill }()
|
||||
|
||||
self := os.Getpid()
|
||||
observedAgents = func() ([]agentObs, error) {
|
||||
return []agentObs{
|
||||
{PID: 101, Kind: kindAgent, Signature: "claude-code"},
|
||||
{PID: 4532, Kind: kindApp, Signature: "claude-desktop"},
|
||||
{PID: 202, Kind: kindClient, Signature: "llm-client"},
|
||||
}, nil
|
||||
}
|
||||
processTree = func() map[int]int {
|
||||
return map[int]int{
|
||||
102: 101, 103: 102, // the agent's shell and its child
|
||||
104: 101, // our own local agent, launched under the agent's shell
|
||||
4533: 4532, // the app's helper
|
||||
self: 101, // a corrupt map naming us as the agent's child
|
||||
1: 101, // ...and init
|
||||
}
|
||||
}
|
||||
faTrackPID(104)
|
||||
defer faUntrackPID(104)
|
||||
killed := []int{}
|
||||
killProcess = func(pid int) error { killed = append(killed, pid); return nil }
|
||||
|
||||
maybeContain("", "", []byte(`{"contain":[101, 4532, 202, 303]}`))
|
||||
sort.Ints(killed)
|
||||
want := []int{101, 102, 103, 202}
|
||||
if len(killed) != len(want) {
|
||||
t.Fatalf("killed %v, want %v", killed, want)
|
||||
}
|
||||
for i := range want {
|
||||
if killed[i] != want[i] {
|
||||
t.Fatalf("killed %v, want %v", killed, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user