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.
325 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|