claude-code: point the agent at the mesh's tools and guard its shell against ssh to the mesh (hq ADR 0245)
The agent kept running ssh <machine> journalctl while the journal verb existed. The managed instructions now carry an "instead of" table generated from what each verb says it replaces, and the plugin carries a PreToolUse guard that refuses ssh to a mesh machine and local work-arounds for a mesh name, naming the tool or saying one must be created. The operator's override is read from the session's start environment and recorded.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// askingAController answers the controller's verbs as the seat does: `tools` in-process, the rest as the
|
||||
// command's output and its JSON.
|
||||
func askingAController(t *testing.T) Ask {
|
||||
t.Helper()
|
||||
wrap := func(output string, answer any) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"ok": true, "output": output, "answer": answer})
|
||||
return raw
|
||||
}
|
||||
return func(address string, args any) (json.RawMessage, error) {
|
||||
switch address {
|
||||
case "seat:mesh-controller.tools":
|
||||
return json.RawMessage(`{"seats":[
|
||||
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do, what it is assigned, and why.","replaces":["hostnamectl","uptime"]},{"name":"nodes","description":"Every machine."}]},
|
||||
{"seat":"node-service-manager","scope":"node","tools":[
|
||||
{"name":"status","description":"One unit as the service manager sees it now: its states, whether it starts at boot.","replaces":["systemctl status","systemctl is-active"]},
|
||||
{"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]}]}`), nil
|
||||
case "seat:mesh-controller.modules":
|
||||
return wrap("[...]", []any{map[string]any{"module": "docker", "on": []string{"anchor"},
|
||||
"replaces": map[string][]string{"docker_logs": {"docker logs"}}}, map[string]any{"module": "zsh", "on": []string{"anchor"}}}), nil
|
||||
case "seat:mesh-controller.nodes":
|
||||
return wrap("", []any{map[string]any{"name": "anchor"}, map[string]any{"name": "laptop"}}), nil
|
||||
case "seat:mesh-controller.node":
|
||||
node, _ := args.(map[string]any)["node"].(string)
|
||||
if node == "anchor" {
|
||||
return wrap("anchor\n mode converged\n public domain Anchor.Example\n\n what it runs\n", nil), nil
|
||||
}
|
||||
return wrap("laptop\n mode converged\n", nil), nil
|
||||
}
|
||||
return nil, errors.New("no such verb")
|
||||
}
|
||||
}
|
||||
|
||||
func resolving(name string) []string {
|
||||
return map[string][]string{"anchor": {"10.10.0.1"}, "anchor.internal": {"10.10.0.1"}, "anchor.example": {"192.0.2.10"},
|
||||
"laptop": {"127.0.0.1", "10.10.0.4"}}[name]
|
||||
}
|
||||
|
||||
// What the controller says of its tools and machines is read whole: the seats' verbs, the modules' own tools,
|
||||
// every machine with its domains and addresses — a loopback address never one of them.
|
||||
func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) {
|
||||
m, err := AskMeshTools(askingAController(t), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var addresses []string
|
||||
for _, r := range m.Replacements {
|
||||
addresses = append(addresses, r.Address)
|
||||
}
|
||||
if got := strings.Join(addresses, ","); got != "mesh-controller.node,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
|
||||
t.Errorf("replacements: %s", got)
|
||||
}
|
||||
if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" ||
|
||||
strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" {
|
||||
t.Errorf("machines: %+v", m.Machines)
|
||||
}
|
||||
if m.Replacements[3].Does != "logs" || !m.Replacements[2].NodeScoped || m.Replacements[0].NodeScoped {
|
||||
t.Errorf("the rows: %+v", m.Replacements)
|
||||
}
|
||||
}
|
||||
|
||||
// **The table is generated, not written** (novox/hq ADR 0244): what the records say a verb replaces is a row,
|
||||
// the machines' seats first, and nothing is rendered where the records say nothing.
|
||||
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
m, _ := AskMeshTools(askingAController(t), resolving)
|
||||
table := InsteadOf(&m)
|
||||
for _, want := range []string{
|
||||
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
||||
"| `<node>/docker.` | `docker_logs` — `docker logs` |",
|
||||
"| `mesh-controller.` | `node` — `hostnamectl`, `uptime` |",
|
||||
"Never `ssh` to a mesh machine",
|
||||
} {
|
||||
if !strings.Contains(table, want) {
|
||||
t.Errorf("the table lacks %q:\n%s", want, table)
|
||||
}
|
||||
}
|
||||
if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") ||
|
||||
strings.Index(table, "docker.") > strings.Index(table, "`mesh-controller.`") {
|
||||
t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table)
|
||||
}
|
||||
// What the guard here named most comes first: its row, and in its row its verb.
|
||||
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-controller.node": 1}
|
||||
ranked := InsteadOf(&m)
|
||||
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
|
||||
strings.Index(ranked, "`mesh-controller.`") > strings.Index(ranked, "`<node>/docker.`") {
|
||||
t.Errorf("not ranked by the guard's record:\n%s", ranked)
|
||||
}
|
||||
m.Refused = nil
|
||||
if InsteadOf(nil) != "" || InsteadOf(&MeshTools{}) != "" {
|
||||
t.Error("a table from nothing")
|
||||
}
|
||||
// Into the instructions after the mesh's own text, before what was registered.
|
||||
out := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil,
|
||||
Config{Mesh: []Item{{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: map[string]string{"conventions.md": "Be brief."}}}}, &m)
|
||||
md := out["CLAUDE.md"]
|
||||
if i, j, k := strings.Index(md, "## Conventions"), strings.Index(md, "## Instead of a shell command"), strings.Index(md, "### conventions"); !(i < j && j < k && i >= 0) {
|
||||
t.Errorf("the table is not between the mesh's text and the registered sections:\n%s", md)
|
||||
}
|
||||
if os.Getenv("SHOW_TABLE") != "" {
|
||||
t.Log(table)
|
||||
}
|
||||
// A long list is cut, and says how the rest is found.
|
||||
var many MeshTools
|
||||
for i := 0; i < InsteadOfRows+3; i++ {
|
||||
many.Replacements = append(many.Replacements, Replacement{Address: "s" + string(rune('a'+i)) + ".v", Seat: true, Replaces: []string{"c"}})
|
||||
}
|
||||
if t2 := InsteadOf(&many); strings.Count(t2, "| `s") != InsteadOfRows || !strings.Contains(t2, "3 more seats and modules") {
|
||||
t.Errorf("not cut to %d rows:\n%s", InsteadOfRows, t2)
|
||||
}
|
||||
}
|
||||
|
||||
// Asked again with the same answer, nothing is written and nothing renders.
|
||||
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
||||
p := Paths{State: t.TempDir()}
|
||||
m, _ := AskMeshTools(askingAController(t), resolving)
|
||||
if !KeepMeshTools(p, m) {
|
||||
t.Fatal("the first answer was not kept")
|
||||
}
|
||||
if KeepMeshTools(p, m) {
|
||||
t.Fatal("the same answer was kept again")
|
||||
}
|
||||
m.Machines = m.Machines[:1]
|
||||
if !KeepMeshTools(p, m) || len(ReadMeshTools(p).Machines) != 1 {
|
||||
t.Fatal("a changed answer was not kept")
|
||||
}
|
||||
d := GuardDataOf(Paths{State: p.State, Node: "laptop"}, ReadMeshTools(p))
|
||||
if d.Node != "laptop" || len(d.Replacements) != 4 || !strings.HasSuffix(d.Log, "guard.log") {
|
||||
t.Errorf("the guard's data: %+v", d)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user