Files
mesh-catalog/modules/claude-code/cmd/claude-code/guard_test.go
T
jochen f72a435487
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
claude-code: read where it runs from the controller's JSON answer
nodesRunningMe looked for printed lines in what is a JSON list, so every register tool's "also on"
hint and every nodes: "all" named no machine.
2026-10-07 20:28:06 +02:00

325 lines
14 KiB
Go

package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the
// controller's records say them.
func meshForTheGuard() GuardData {
tools := json.RawMessage(`{"seats":[
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do.","replaces":["hostnamectl","uptime"]}]},
{"seat":"node-service-manager","scope":"node","tools":[
{"name":"status","description":"One unit as the service manager sees it now: its states.","replaces":["systemctl status","systemctl is-active"]},
{"name":"restart","description":"Restart one unit.","replaces":["systemctl restart"]},
{"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]},
{"seat":"node-hostname","scope":"node","tools":[
{"name":"add","description":"Add one address and its names to the operator's lines of /etc/hosts.","replaces":["edit /etc/hosts","HOSTALIASES"]}]}]}`)
modules := json.RawMessage(`[{"module":"docker","on":["anchor"],"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}},
{"module":"systemd","on":["anchor","laptop"]}]`)
return GuardData{Node: "laptop", Replacements: ReplacementsOf(tools, modules), Machines: []Machine{
{Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}},
{Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}},
}}
}
// noSSH reads a destination from the command line alone; aliases reads one alias as ssh's configuration would.
func aliases(options []string, destination string) (string, string, []string) {
host, user := hostOf(destination)
switch host {
case "a":
return "anchor.example", "operator", nil
case "via-anchor":
return "203.0.113.5", "operator", []string{"anchor"}
case "forge":
return "git.anchor" + InternalSuffix, "git", nil
}
if user == "" {
user = "operator"
}
for i, o := range options {
if o == "-l" && i+1 < len(options) {
user = options[i+1]
}
}
return host, user, nil
}
func guard() Guard {
return Guard{Data: meshForTheGuard(), SSH: aliases, Resolve: func(name string) []string {
if name == "nas.lan" {
return []string{"192.0.2.10"}
}
return nil
}}
}
func bash(command string) HookInput {
return HookInput{ToolName: "Bash", ToolInput: map[string]any{"command": command}}
}
// **ssh to a mesh machine is refused**, by every name and address it has and however the shell spells it,
// and the refusal names the verb that does the job on that machine (novox/hq ADR 0245).
func TestSSHToAMeshMachineIsRefusedNamingTheTool(t *testing.T) {
g := guard()
for command, want := range map[string]string{
"ssh anchor journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
"ssh anchor 'sudo journalctl -fu sshd'": "anchor/node-service-manager.journal",
"ssh -p 22 root@anchor.example systemctl status nats": "anchor/node-service-manager.status",
"ssh anchor.internal systemctl restart nats": "anchor/node-service-manager.restart",
"ssh -o StrictHostKeyChecking=no 10.10.0.1 docker ps -a": "anchor/docker.docker_list",
"ssh anchor docker logs --tail 100 nats": "anchor/docker.docker_logs",
"ssh laptop docker logs x": "anchor/docker.docker_logs",
"ssh a uptime": "mesh-controller.node",
"cd /tmp && ssh anchor journalctl": "anchor/node-service-manager.journal",
"sudo -u root ssh anchor journalctl": "anchor/node-service-manager.journal",
"timeout 10 ssh anchor journalctl": "anchor/node-service-manager.journal",
"env LANG=C ssh anchor journalctl": "anchor/node-service-manager.journal",
"bash -c 'ssh anchor journalctl -u x'": "anchor/node-service-manager.journal",
`echo "$(ssh anchor journalctl -n 5)" | tail`: "anchor/node-service-manager.journal",
"for n in anchor laptop; do ssh $n uptime; done; ssh anchor uptime": "mesh-controller.node",
"ssh -J anchor 203.0.113.9 journalctl": "anchor/node-service-manager.journal",
"ssh via-anchor journalctl": "anchor/node-service-manager.journal",
"ssh nas.lan journalctl": "anchor/node-service-manager.journal",
"ssh ssh://anchor:2222 journalctl": "anchor/node-service-manager.journal",
} {
v := g.Judge(bash(command))
if !v.Refuse || v.Rule != "ssh" {
t.Errorf("%q was not refused: %+v", command, v)
continue
}
if !strings.Contains(v.Message, want) {
t.Errorf("%q: the refusal does not name %s:\n%s", command, want, v.Message)
}
}
}
// What no tool replaces is refused all the same, saying a tool is created, never worked around; a shell
// on a machine is pointed at what the machine serves; copying a file off one is refused too.
func TestSSHWithNoToolForItSaysCreateOne(t *testing.T) {
g := guard()
for command, want := range map[string]string{
"ssh anchor cat /var/lib/thing/state.json": "a missing tool is created in the module",
"ssh anchor": "mesh_machine anchor",
"ssh build.internal ls": "a missing tool is created",
"scp anchor:/etc/nats/nats.conf /tmp/": "mesh_machine anchor",
"rsync -av root@10.10.0.4:/srv/ ./srv/": "mesh_machine laptop",
"sftp anchor.example": "mesh_machine anchor",
"mosh anchor": "mesh_machine anchor",
"autossh -M 0 -N -L 5432:localhost:5432 anchor": "mesh_machine anchor",
} {
v := g.Judge(bash(command))
if !v.Refuse {
t.Errorf("%q was not refused", command)
continue
}
if !strings.Contains(v.Message, want) {
t.Errorf("%q: want %q in:\n%s", command, want, v.Message)
}
}
}
// **What is not a work-around passes**: git over ssh to the forge above all, and ssh beyond the mesh, and
// every command that merely mentions ssh or a machine.
func TestWhatIsNotAWorkAroundPasses(t *testing.T) {
g := guard()
for _, command := range []string{
"git push -u origin feat/x",
"git clone ssh://git@git.anchor.internal:222/novox/hq.git",
"git fetch ssh://git@git.anchor.internal:222/novox/hq.git main",
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
"git -c core.sshCommand='ssh -p 222' pull",
"ssh -T git@git.anchor.internal -p 222",
"ssh forge",
"scp git@git.anchor.internal:novox/hq.git .",
"ssh github.com",
"ssh -T git@github.com",
"ssh user@203.0.113.7 uptime",
"ssh -V",
"ssh-keygen -t ed25519 -f ~/.ssh/x",
"ssh-add -l",
"man ssh",
"grep -r 'ssh anchor' docs/",
`git commit -m "Stop running ssh anchor journalctl"`,
"echo anchor && journalctl --user -n 5",
"cat /etc/hosts",
"getent hosts anchor.internal",
"cp /etc/hosts /tmp/hosts.copy",
"sed -n 1,5p /etc/hosts",
"curl -s http://anchor.internal:8080/health",
"go test ./...",
} {
if v := g.Judge(bash(command)); v.Refuse {
t.Errorf("%q was refused:\n%s", command, v.Message)
}
}
if v := g.Judge(HookInput{ToolName: "Read", ToolInput: map[string]any{"file_path": "/etc/hosts"}}); v.Refuse {
t.Errorf("reading /etc/hosts was refused")
}
if v := g.Judge(HookInput{ToolName: "Write", ToolInput: map[string]any{"file_path": "/tmp/hosts"}}); v.Refuse {
t.Errorf("writing a file named hosts elsewhere was refused")
}
}
// **The local work-arounds for a mesh name are refused** with the machine's own hosts verb: writing
// /etc/hosts by any means, the agent's own Edit and Write included, and HOSTALIASES.
func TestTheLocalWorkAroundsForAMeshNameAreRefused(t *testing.T) {
g := guard()
for _, command := range []string{
"echo '10.10.0.1 anchor' | sudo tee -a /etc/hosts",
"sudo sh -c 'echo 10.10.0.1 anchor >> /etc/hosts'",
"echo x >>/etc/hosts",
"sudo sed -i 's/old/new/' /etc/hosts",
"sudo sed -Ei.bak 's/a/b/' /etc/hosts",
"sudo vim /etc/hosts",
"sudo cp /tmp/hosts /etc/hosts",
"sudo install -m 644 hosts /etc/hosts",
"HOSTALIASES=~/.hosts curl http://anchor/",
"export HOSTALIASES=/tmp/aliases",
} {
v := g.Judge(bash(command))
if !v.Refuse {
t.Errorf("%q was not refused", command)
continue
}
if !strings.Contains(v.Message, "laptop/node-hostname.add") {
t.Errorf("%q: the refusal does not name this machine's hosts verb:\n%s", command, v.Message)
}
}
for _, tool := range []string{"Edit", "Write", "MultiEdit"} {
v := g.Judge(HookInput{ToolName: tool, ToolInput: map[string]any{"file_path": "/etc/../etc/hosts"}})
if !v.Refuse || !strings.Contains(v.Message, "node-hostname.add") {
t.Errorf("%s of /etc/hosts was not refused with the verb: %+v", tool, v)
}
}
// The resolver file has no verb that replaces writing it: the refusal says to create one.
v := g.Judge(bash("echo nameserver 192.0.2.53 | sudo tee /etc/resolv.conf"))
if !v.Refuse || !strings.Contains(v.Message, "a missing tool is created") {
t.Errorf("writing the resolver file: %+v", v)
}
}
// The agent never names the operator's override, and no override lifts that refusal.
func TestACommandNamingTheOverrideIsRefused(t *testing.T) {
g := guard()
for _, command := range []string{
"export " + OverrideVar + "=because",
OverrideVar + "=x claude -p 'ssh anchor uptime'",
"env " + OverrideVar + "=1 bash",
} {
v := g.Judge(bash(command))
if !v.Refuse || v.Rule != "override-named" || v.Overridable {
t.Errorf("%q: %+v", command, v)
}
}
}
// Without the mesh's answer — a controller not asked yet — the mesh's own names are still refused.
func TestWithoutTheMeshsAnswerItsNamesAreStillRefused(t *testing.T) {
g := Guard{}
if v := g.Judge(bash("ssh anchor.internal journalctl")); !v.Refuse || !strings.Contains(v.Message, "a missing tool is created") {
t.Errorf("an internal name without data: %+v", v)
}
if v := g.Judge(bash("echo x | sudo tee -a /etc/hosts")); !v.Refuse {
t.Errorf("the hosts file without data: %+v", v)
}
}
// fakeProc lays out a process tree: the agent started with env, a shell under it, the hook under that.
func fakeProc(t *testing.T, agentEnv []string) (root string, hookParent int) {
t.Helper()
root = t.TempDir()
write := func(pid, ppid int, cmdline []string, env []string) {
dir := filepath.Join(root, strconv.Itoa(pid))
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "cmdline"), []byte(strings.Join(cmdline, "\x00")+"\x00"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")), 0o644)
_ = os.WriteFile(filepath.Join(dir, "stat"), []byte(strconv.Itoa(pid)+" (some (odd) name) S "+strconv.Itoa(ppid)+" 1 1"), 0o644)
}
write(100, 1, []string{"/opt/claude-code/bin/claude", "--resume"}, agentEnv)
write(200, 100, []string{"/bin/sh", "-c", "hook"}, []string{OverrideVar + "=set-by-the-session"})
return root, 200
}
// **The override is the operator's, from the session as it was started, and recorded** — and a value a
// command or a setting put in the session's later environment is not it.
func TestTheOverrideIsTheOperatorsAndRecorded(t *testing.T) {
data := meshForTheGuard()
data.Log = filepath.Join(t.TempDir(), "guard.log")
path := filepath.Join(t.TempDir(), "guard.json")
raw, _ := json.Marshal(data)
_ = os.WriteFile(path, raw, 0o644)
was, wasPID := procRoot, parentPID
t.Cleanup(func() { procRoot, parentPID = was, wasPID })
run := func(command string) (int, string) {
var stderr bytes.Buffer
in, _ := json.Marshal(bash(command))
return runGuard(path, bytes.NewReader(in), &stderr), stderr.String()
}
// Not set where the session started: the later shell's value is not the operator's.
root, hook := fakeProc(t, []string{"HOME=/home/operator"})
procRoot, parentPID = root, func() int { return hook }
if code, says := run("ssh anchor journalctl"); code != 2 || !strings.Contains(says, "anchor/node-service-manager.journal") {
t.Fatalf("refused with %d: %s", code, says)
}
// Set by the operator before the session: let through, with why on record.
root, hook = fakeProc(t, []string{"HOME=/home/operator", OverrideVar + "=reading the broker's log by hand while the systemd module is down"})
procRoot, parentPID = root, func() int { return hook }
if code, says := run("ssh anchor journalctl -u nats"); code != 0 {
t.Fatalf("the operator's override was not honoured: %d %s", code, says)
}
// Never for a command naming the override itself.
if code, _ := run("export " + OverrideVar + "=x"); code != 2 {
t.Fatalf("a command naming the override passed under it: %d", code)
}
record := ReadGuardLog(data.Log, 10)
if len(record) != 3 || record[0].Decision != "refused" || record[1].Decision != "overridden" ||
!strings.Contains(record[1].Why, "systemd module is down") || record[1].Machine != "anchor" || record[2].Rule != "override-named" {
t.Fatalf("the record: %+v", record)
}
// An override that cannot be recorded is not honoured.
data.Log = filepath.Join(t.TempDir(), "no", "such", "dir", "guard.log")
raw, _ = json.Marshal(data)
_ = os.WriteFile(path, raw, 0o644)
if code, says := run("ssh anchor journalctl"); code != 2 || !strings.Contains(says, "could not be recorded") {
t.Fatalf("an override not recorded was honoured: %d %s", code, says)
}
}
// What passes is not recorded, and a guard that cannot read the call says so and blocks nothing.
func TestTheHookExitsAsTheAgentReadsIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "guard.json")
_ = os.WriteFile(path, []byte(`{}`), 0o644)
var stderr bytes.Buffer
in, _ := json.Marshal(bash("git push"))
if code := runGuard(path, bytes.NewReader(in), &stderr); code != 0 || stderr.Len() != 0 {
t.Errorf("git push: %d %q", code, stderr.String())
}
if code := runGuard(path, strings.NewReader("not json"), &stderr); code != 1 {
t.Errorf("an unreadable call: %d", code)
}
}
func TestTheAgentsProcessIsKnown(t *testing.T) {
for cmdline, want := range map[string]bool{
"/opt/claude-code/bin/claude\x00--resume\x00": true,
"claude\x00": true,
"node\x00/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js\x00": true,
"/usr/bin/node\x00/home/x/.local/bin/claude\x00": true,
"/bin/zsh\x00": false,
"node\x00server.js\x00": false,
"/usr/bin/claude-code-tools\x00": false,
} {
if isAgent([]byte(cmdline)) != want {
t.Errorf("%q: want %v", cmdline, want)
}
}
}