Merge pull request 'claude-code: an "instead of" table and a guard on the agent's shell (hq ADR 0245)' (#110) from feat/claude-code-tools-first into main

This commit was merged in pull request #110.
This commit is contained in:
2026-10-07 19:04:17 +00:00
13 changed files with 2075 additions and 23 deletions
+34 -3
View File
@@ -23,8 +23,8 @@ whenever the node's tool runtime collects the module's tools:
|---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook, first. Replaced whole, staged beside and swapped in |
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
@@ -49,9 +49,40 @@ must see, a node that joins later included — kept, so it carries no secret eit
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
## The mesh's tools first (hq ADR 0245)
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
- **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces
(`replaces`, in the seat's definition or the module's manifest). The module asks the controller — `tools`,
`modules`, `nodes`, `node` — at start, every ten minutes and on `claude_code_render`, keeps the answer in its
state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `<node>/<seat>.` and
each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in
the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the
mesh's seats; at most sixteen rows, the rest one `mesh_search` away.
- **The guard.** A PreToolUse hook on `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: this module's own
binary (`claude-code guard`), copied root's into the plugin with what it judges with (`guard.json`: the
machines by name, domain and address, and the replaced commands), run by its path under `/etc/claude-code` so
the session cannot change it. It refuses `ssh`, `scp`, `sftp`, `rsync`, `mosh` and `autossh` to a mesh machine
(any `*.internal` name, a machine's name or a name under its domains, one of its addresses, as `ssh -G` reads
the destination; a jump through one too), writing `/etc/hosts` or `/etc/resolv.conf`, and `HOSTALIASES` —
naming the verb that does the job on that machine when one says it replaces the command, and otherwise that a
missing tool is created in the module that owns it, never worked around. **Stated, not silent:** an ssh login
as `git` is the forge's account, which runs nothing but git, and passes; a git remote is never an ssh command
line anyway.
- **The operator's override**: `MESH_GUARD_OVERRIDE=<why>`, exported in the operator's own shell before the
session starts. It is read from the session's environment as the kernel kept it at exec
(`/proc/<pid>/environ` of the agent's process), so nothing a session does — a command's `export`, a
settings `env` key — can set it, and a command naming it is refused outright. Every override and every
refusal is a line in the module's `guard.log`; an override that cannot be recorded is not honoured.
`claude_code_guard` shows the rules, the data and the record.
It is a guard against the habit, not a sandbox: a command written to hide what it runs can hide it, and the
record is how a habit that found a way round is seen.
## Tools
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_grant` (for the licence
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
manager), `claude_code_mcp_list`,
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
+34 -2
View File
@@ -324,15 +324,41 @@ func ConfigOf(items map[string]Item) Config {
// ---- rendering --------------------------------------------------------------------------------------
// PluginFile is one file of the marketplace: its content and whether it is run.
// PluginFile is one file of the marketplace: its content and whether it is run — or, for a binary, the
// file it is copied from (the guard's, which is this module's own executable).
type PluginFile struct {
Content string
Executable bool
From string `json:",omitempty"`
}
// GuardDir is where the guard on the agent's shell lives in the plugin (novox/hq ADR 0245): its binary and
// what it is given. Outside hooks/, so no registered hook's name can be it.
const GuardDir = "guard"
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
type GuardFiles struct {
Exe string
Data GuardData
}
// GuardMatcher is what the guard is asked about: the shell, and the agent's own file edits.
const GuardMatcher = "Bash|Edit|Write|MultiEdit|NotebookEdit"
// GuardCommand is the hook's command line: by its path under the managed directory, root's — never the
// copy the agent may keep of a plugin, which the session could change.
func GuardCommand() string {
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
}
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
// over a mesh item of the same kind and name: the node's wins.
func Marketplace(c Config) map[string]PluginFile {
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
// MarketplaceWith is the marketplace with the guard on the agent's shell, when there is one to place: its
// hook first, before any registered one.
func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
root := "plugins/" + Plugin + "/"
out := map[string]PluginFile{
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
@@ -359,6 +385,12 @@ func Marketplace(c Config) map[string]PluginFile {
}
sort.Strings(keys)
hooks := map[string][]any{}
if guard != nil && guard.Exe != "" {
out[root+GuardDir+"/guard"] = PluginFile{From: guard.Exe, Executable: true}
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
}
for _, k := range keys {
it := chosen[k]
switch it.Kind {
@@ -94,8 +94,9 @@ func TestEachKindLandsInItsOnePlace(t *testing.T) {
} `json:"hooks"`
}
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
// The mesh's own guard on the shell first (novox/hq ADR 0245), then the hook registered.
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 2 || pre[0].Matcher != GuardMatcher || pre[0].Hooks[0].Command != GuardCommand() ||
pre[1].Matcher != "Bash" || pre[1].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
}
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
@@ -0,0 +1,785 @@
package main
// The guard on the agent's shell (novox/hq ADR 0245): a PreToolUse hook every session on a machine of the
// mesh runs before a shell command or a file edit, delivered in the module's plugin.
//
// It refuses three work-arounds, each with the mesh tool that does the job when one says it replaces the
// command, and otherwise with the rule that a missing tool is created, never worked around:
//
// 1. **ssh to a mesh machine** — `ssh`, `scp`, `sftp`, `rsync`, `mosh` or `autossh` to a machine's name, a
// name under its domains, any `*.internal` name, or one of its addresses; a jump through one too. The
// destination is read as ssh itself reads it (`ssh -G`), so an alias in ~/.ssh/config is no way round.
// **Stated, not silent**: an ssh login as `git` is the forge's account, which runs git and nothing
// else — so `git push` over ssh, and `ssh -T git@<forge>`, reach no machine and pass. A git remote is
// never an ssh command line anyway.
// 2. **writing /etc/hosts or /etc/resolv.conf** — by redirect, tee, sed -i, an editor, cp/mv/install onto
// it, or the agent's own Edit and Write tools: a mesh name is the mesh's resolvers' (ADR 0194), and a
// machine's hosts file is its node-hostname seat's.
// 3. **HOSTALIASES**, named anywhere in a command line.
//
// And it refuses any command that names the operator's override, so the agent never sets it (guard_run.go).
//
// The matching is on the command line as a shell would split it — quotes, separators, `$(…)`, `bash -c` —
// and wrappers such as sudo, env and timeout are looked through. It is a guard against the habit of reaching
// for ssh, not a sandbox: a command built to hide what it runs can hide it, and the record of what was
// refused (the module's `guard.log`, read by `claude_code_guard`) is how a habit that found a way round is seen.
import (
"fmt"
"net"
"net/url"
"path"
"path/filepath"
"sort"
"strings"
)
// GuardData is what the hook is given: this machine, the mesh's machines, what replaces what, and where
// its record is kept. Written beside the hook on every render, root's, so the session cannot change it.
type GuardData struct {
Node string `json:"node"`
Machines []Machine `json:"machines"`
Replacements []Replacement `json:"replacements"`
Log string `json:"log"`
}
// HookInput is what the agent hands a PreToolUse hook.
type HookInput struct {
ToolName string `json:"tool_name"`
ToolInput map[string]any `json:"tool_input"`
Cwd string `json:"cwd"`
SessionID string `json:"session_id"`
}
// Verdict is the guard's answer.
type Verdict struct {
Refuse bool `json:"refuse"`
Rule string `json:"rule,omitempty"` // ssh | hosts-file | hostaliases | override-named
Machine string `json:"machine,omitempty"`
Message string `json:"message,omitempty"`
// Overridable is false for a refusal no override lifts: the agent naming the override itself.
Overridable bool `json:"-"`
// Calls are the tools the refusal named, by address without a machine: what the instructions' table
// puts first, the tools the agent most reached round.
Calls []string `json:"calls,omitempty"`
}
// OverrideVar is the operator's override: set in the operator's own shell before the session starts,
// with why as its value. Read from the session's own environment as it was started, never from what a
// command or a setting put there (guard_run.go).
const OverrideVar = "MESH_GUARD_OVERRIDE"
// ForgeUser is the forge's ssh account: it runs git, never a shell (stated in ADR 0245).
const ForgeUser = "git"
// protectedFiles are the files a session never writes for a mesh name.
var protectedFiles = map[string]bool{"/etc/hosts": true, "/etc/resolv.conf": true}
// SSHView is how ssh itself reads a destination with these options: the host it connects to, the user,
// and the jumps. Empty host when ssh could not say.
type SSHView func(options []string, destination string) (host, user string, jumps []string)
// Guard judges what a session is about to do.
type Guard struct {
Data GuardData
// SSH reads a destination as ssh does (`ssh -G`); nil reads it from the command line alone.
SSH SSHView
// Resolve is a name's addresses, for a name that is none of the mesh's but may point at a machine.
Resolve func(string) []string
}
// Judge is the verdict on one tool call.
func (g Guard) Judge(in HookInput) Verdict {
switch in.ToolName {
case "Bash":
command, _ := in.ToolInput["command"].(string)
return g.judgeLine(command, 0)
case "Edit", "Write", "MultiEdit", "NotebookEdit":
for _, key := range []string{"file_path", "notebook_path"} {
if p, _ := in.ToolInput[key].(string); p != "" && protectedFiles[filepath.Clean(p)] {
return g.hostsFile(filepath.Clean(p), in.ToolName+" "+filepath.Clean(p))
}
}
}
return Verdict{}
}
func (g Guard) judgeLine(line string, depth int) Verdict {
if depth > 4 {
return Verdict{}
}
if strings.Contains(line, OverrideVar) {
return Verdict{Refuse: true, Rule: "override-named", Message: fmt.Sprintf(
"Refused by the mesh's guard (novox/hq ADR 0245): this command names %s, the operator's override. "+
"It is the operator's alone, set in their own shell before a session starts; a session never sets, "+
"reads or passes it.", OverrideVar)}
}
if strings.Contains(line, "HOSTALIASES") {
return g.refuseLocal("hostaliases", "HOSTALIASES", "setting HOSTALIASES")
}
for _, words := range simpleCommands(line) {
if v := g.judgeCommand(words, depth); v.Refuse {
return v
}
// What a word runs in its own shell: `$(…)` and backticks inside a quoted word.
for _, w := range words {
for _, inner := range substitutions(w) {
if v := g.judgeLine(inner, depth+1); v.Refuse {
return v
}
}
}
}
return Verdict{}
}
// wrappers run the command after them; each with the options that take a value.
var wrappers = map[string]string{
"sudo": "ugCDhpTrt", "doas": "uC", "env": "uSC", "timeout": "sk", "nohup": "", "nice": "n", "ionice": "cnpP",
"command": "", "exec": "a", "time": "fo", "stdbuf": "ioe", "xargs": "aEdIiLlnPs", "setsid": "", "unbuffer": "",
"chronic": "", "flock": "wE", "torsocks": "", "proxychains": "f", "proxychains4": "f", "caffeinate": "",
}
// shells run their -c argument as a command line.
var shells = map[string]bool{"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "fish": true, "eval": true}
func (g Guard) judgeCommand(words []string, depth int) Verdict {
words = unwrap(words)
if len(words) == 0 {
return Verdict{}
}
name := path.Base(words[0])
args := words[1:]
if shells[name] {
if name == "eval" {
return g.judgeLine(strings.Join(args, " "), depth+1)
}
for i, a := range args {
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "c") && i+1 < len(args) {
return g.judgeLine(args[i+1], depth+1)
}
}
}
switch name {
case "ssh", "autossh", "mosh", "slogin":
return g.judgeSSH(name, args, strings.Join(words, " "))
case "scp", "sftp", "rsync":
return g.judgeCopy(name, args, strings.Join(words, " "))
}
if target := writesProtected(name, args); target != "" {
return g.hostsFile(target, strings.Join(words, " "))
}
return Verdict{}
}
// unwrap takes off what only runs the command: assignments before it, and wrappers with their options.
func unwrap(words []string) []string {
for len(words) > 0 {
w := words[0]
switch {
case w == "!" || w == "{" || w == "}" || w == "then" || w == "do" || w == "else" || w == "if" || w == "while" ||
w == "until" || w == "elif":
words = words[1:]
case isAssignment(w):
words = words[1:]
default:
opts, wrapper := wrappers[path.Base(w)]
if !wrapper {
return words
}
base := path.Base(w)
words = words[1:]
for len(words) > 0 {
a := words[0]
if a == "--" {
words = words[1:]
break
}
if base == "env" && isAssignment(a) {
words = words[1:]
continue
}
if !strings.HasPrefix(a, "-") || a == "-" {
break
}
words = words[1:]
if strings.HasPrefix(a, "--") {
continue
}
if f := a[len(a)-1:]; strings.Contains(opts, f) && len(words) > 0 {
words = words[1:]
}
}
// timeout's duration, nice's adjustment as a word: a number before the command.
if (base == "timeout") && len(words) > 0 {
words = words[1:]
}
}
}
return words
}
func isAssignment(w string) bool {
i := strings.Index(w, "=")
if i <= 0 {
return false
}
for _, r := range w[:i] {
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9') {
return false
}
}
return true
}
// sshOptionsWithValue are ssh's single-letter options that take a value.
const sshOptionsWithValue = "BbcDEeFIiJLlmOoPpQRSWw"
func (g Guard) judgeSSH(name string, args []string, line string) Verdict {
var options, jumps []string
user := ""
dest, rest := "", []string(nil)
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
if i+1 < len(args) {
dest, rest = args[i+1], args[i+2:]
}
break
}
if name == "autossh" && strings.HasPrefix(a, "-M") {
if a == "-M" {
i++ // autossh's own: the monitoring port
}
continue
}
if strings.HasPrefix(a, "-") && len(a) > 1 {
options = append(options, a)
if name == "mosh" || strings.HasPrefix(a, "--") {
continue // mosh's own options are long, each with its value after `=`
}
for j := 1; j < len(a); j++ {
if !strings.ContainsRune(sshOptionsWithValue, rune(a[j])) {
continue
}
value := a[j+1:]
if value == "" && i+1 < len(args) {
i++
value = args[i]
options = append(options, value)
}
switch a[j] {
case 'J':
jumps = append(jumps, strings.Split(value, ",")...)
case 'l':
user = value
case 'o':
k, v, _ := strings.Cut(value, "=")
switch strings.ToLower(strings.TrimSpace(k)) {
case "proxyjump":
jumps = append(jumps, strings.Split(v, ",")...)
case "user":
user = strings.TrimSpace(v)
}
}
break
}
continue
}
dest, rest = a, args[i+1:]
break
}
if dest == "" {
return Verdict{}
}
host, destUser := hostOf(dest)
if destUser != "" {
user = destUser
}
if g.SSH != nil && name != "mosh" {
if h, u, j := g.SSH(options, dest); h != "" {
host, user = h, u
jumps = append(jumps, j...)
}
}
remote := strings.Join(rest, " ")
for _, jump := range jumps {
jh, ju := hostOf(jump)
if machine, ok := g.meshHost(jh); ok && ju != ForgeUser {
return g.refuseSSH(machine, jh, remote, line)
}
}
if user == ForgeUser {
return Verdict{} // the forge's account: git and nothing else (stated in ADR 0245)
}
if machine, ok := g.meshHost(host); ok {
return g.refuseSSH(machine, host, remote, line)
}
return Verdict{}
}
// judgeCopy reads scp's, sftp's and rsync's remote paths, `[user@]host:path` or `scp://[user@]host/…`.
func (g Guard) judgeCopy(name string, args []string, line string) Verdict {
for i := 0; i < len(args); i++ {
a := args[i]
if strings.HasPrefix(a, "-") {
if name != "rsync" && len(a) == 2 && strings.ContainsRune("cFiJloPSXBRD", rune(a[1])) {
i++
}
if name == "rsync" && (a == "-e" || a == "--rsh") {
i++
}
continue
}
var host, user string
if strings.Contains(a, "://") {
host, user = hostOf(a)
} else if colon := strings.Index(a, ":"); colon > 0 && !strings.Contains(a[:colon], "/") {
host, user = hostOf(a[:colon])
} else if name == "sftp" {
host, user = hostOf(a)
} else {
continue
}
if user == ForgeUser {
continue
}
if g.SSH != nil {
if h, u, _ := g.SSH(nil, host); h != "" {
host = h
if u == ForgeUser && user == "" {
continue
}
}
}
if machine, ok := g.meshHost(host); ok {
return g.refuseSSH(machine, host, "", line)
}
}
return Verdict{}
}
// hostOf reads `[user@]host[:port]`, `ssh://[user@]host[:port]/…` or `[v6]`.
func hostOf(dest string) (host, user string) {
if strings.Contains(dest, "://") {
if u, err := url.Parse(dest); err == nil {
return strings.ToLower(u.Hostname()), u.User.Username()
}
}
if at := strings.LastIndex(dest, "@"); at >= 0 {
user, dest = dest[:at], dest[at+1:]
}
if strings.HasPrefix(dest, "[") {
if end := strings.Index(dest, "]"); end > 0 {
return strings.ToLower(dest[1:end]), user
}
}
if h, _, err := net.SplitHostPort(dest); err == nil && strings.Count(dest, ":") == 1 {
dest = h
}
return strings.TrimSuffix(strings.ToLower(dest), "."), user
}
// meshHost says whether a host is one of the mesh's machines, and which when it can tell.
func (g Guard) meshHost(host string) (string, bool) {
host = strings.TrimSuffix(strings.ToLower(strings.Trim(host, "[]")), ".")
if host == "" {
return "", false
}
if ip := net.ParseIP(host); ip != nil {
for _, m := range g.Data.Machines {
for _, a := range m.Addresses {
if b := net.ParseIP(a); b != nil && b.Equal(ip) {
return m.Name, true
}
}
}
return "", false
}
for _, m := range g.Data.Machines {
if host == strings.ToLower(m.Name) {
return m.Name, true
}
for _, d := range m.Domains {
if host == d || strings.HasSuffix(host, "."+d) {
return m.Name, true
}
}
}
if strings.HasSuffix(host, InternalSuffix) {
return "", true
}
if g.Resolve != nil && !strings.Contains(host, "/") {
for _, a := range g.Resolve(host) {
if m, ok := g.meshHost(a); ok && net.ParseIP(a) != nil {
return m, true
}
}
}
return "", false
}
// writesProtected is the protected file a command writes, or "".
func writesProtected(name string, args []string) string {
protected := func(a string) string {
a = strings.Trim(a, `"'`)
if strings.HasPrefix(a, "of=") {
a = a[3:]
}
if protectedFiles[filepath.Clean(a)] {
return filepath.Clean(a)
}
return ""
}
// A redirect onto it, from any command.
for i, a := range args {
if (a == ">" || a == ">>") && i+1 < len(args) {
if p := protected(args[i+1]); p != "" {
return p
}
}
}
operands := func() []string {
var out []string
for i := 0; i < len(args); i++ {
if args[i] == ">" || args[i] == ">>" || args[i] == "<" {
i++
continue
}
out = append(out, args[i])
}
return out
}()
switch name {
case "tee", "vi", "vim", "nvim", "nano", "emacs", "ed", "ex", "micro", "hx", "helix", "kak", "gedit", "code",
"truncate", "chattr", "chmod", "chown", "rm", "unlink", "shred", "dd", "sponge", "visudo", "vipw":
for _, a := range operands {
if p := protected(a); p != "" {
return p
}
}
case "sed", "perl", "ruby", "awk", "gawk":
inPlace := false
for _, a := range operands {
switch {
case name == "awk" || name == "gawk":
inPlace = inPlace || a == "inplace"
case strings.HasPrefix(a, "--in-place"):
inPlace = true
case strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.Contains(a, "i"):
inPlace = true
}
}
if inPlace {
for _, a := range operands {
if p := protected(a); p != "" {
return p
}
}
}
case "cp", "mv", "install", "ln", "rsync":
var files []string
for _, a := range operands {
if !strings.HasPrefix(a, "-") {
files = append(files, a)
}
}
if len(files) > 1 {
if p := protected(files[len(files)-1]); p != "" {
return p
}
}
}
return ""
}
// replacementsFor are the mesh's tools that replace a command, the most specific first, each address once.
func (g Guard) replacementsFor(command string) []string {
have := commandWordsOf(command)
type found struct {
address string
weight int
replace string
}
var all []found
for _, r := range g.Data.Replacements {
best := 0
which := ""
for _, c := range r.Replaces {
want := commandWordsOf(c)
if inOrderWords(want, have) && len(want) > best {
best, which = len(want), c
}
}
if best > 0 {
all = append(all, found{r.Address, best, which})
}
}
sort.SliceStable(all, func(i, j int) bool { return all[i].weight > all[j].weight })
var out []string
for _, f := range all {
out = append(out, f.address+"\x00"+f.replace)
}
return out
}
// callFor is how an address is called for a machine.
func (g Guard) callFor(address, machine string) string {
for _, r := range g.Data.Replacements {
if r.Address != address || !r.NodeScoped {
continue
}
on := machine
if !r.Seat && len(r.On) > 0 && !containsString(r.On, machine) {
on = r.On[0]
}
if on == "" {
on = "<node>"
}
return on + "/" + address
}
return address
}
func containsString(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// exact is the replacement that names this exact command, or "".
func (g Guard) exact(command string) string {
for _, r := range g.Data.Replacements {
for _, c := range r.Replaces {
if c == command {
return r.Address
}
}
}
return ""
}
const missingTool = "No mesh tool says it replaces this. Do not work around it: a missing tool is created in the module " +
"that owns the thing, on the seat it occupies — say what is missing to the operator. First make sure with " +
"`mesh_search` and the command's own words."
func (g Guard) refuseSSH(machine, host, remote, line string) Verdict {
who := machine
if who == "" {
who = host + " (a mesh name)"
}
var b strings.Builder
var named []string
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): `%s` reaches %s round the mesh's tools.\n", cut(line, 160), who)
switch {
case strings.TrimSpace(remote) == "":
target := machine
if target == "" {
target = "<machine>"
}
fmt.Fprintf(&b, "What a machine serves is `mesh_machine %s`; `mesh_search` with what you meant to run finds its tool.\n", target)
default:
calls := g.replacementsFor(remote)
if len(calls) == 0 {
b.WriteString(missingTool + "\n")
break
}
b.WriteString("Call instead, through `mesh_call`:\n")
for i, c := range calls {
if i == 3 {
break
}
address, replaces, _ := strings.Cut(c, "\x00")
named = append(named, address)
fmt.Fprintf(&b, " %s — replaces `%s`\n", g.callFor(address, machine), replaces)
}
}
return Verdict{Refuse: true, Rule: "ssh", Machine: machine, Message: strings.TrimRight(b.String(), "\n"), Overridable: true,
Calls: named}
}
func (g Guard) refuseLocal(rule, replaced, what string) Verdict {
var b strings.Builder
fmt.Fprintf(&b, "Refused by the mesh's guard (novox/hq ADR 0245): %s is a work-around for a mesh name. "+
"A mesh name is the mesh's resolvers' to answer, and a machine's own lines in /etc/hosts are its node-hostname seat's.\n", what)
var named []string
if address := g.exact(replaced); address != "" {
named = append(named, address)
fmt.Fprintf(&b, "Call instead, through `mesh_call`: %s — replaces `%s`", g.callFor(address, g.Data.Node), replaced)
} else {
b.WriteString(missingTool)
}
return Verdict{Refuse: true, Rule: rule, Machine: g.Data.Node, Message: b.String(), Overridable: true, Calls: named}
}
func (g Guard) hostsFile(file, line string) Verdict {
replaced := "edit " + file
return g.refuseLocal("hosts-file", replaced, fmt.Sprintf("writing %s (`%s`)", file, cut(line, 120)))
}
func cut(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// commandWordsOf are a command line's words as matched against a replaced command (the mesh MCP server's search rule):
// lower-cased, a program by path reduced to its name, sudo left out.
func commandWordsOf(line string) []string {
var out []string
for i, w := range strings.Fields(strings.ToLower(line)) {
if i == 0 || strings.HasPrefix(w, "/") {
w = path.Base(w)
}
if w == "sudo" {
continue
}
out = append(out, w)
}
return out
}
func inOrderWords(want, have []string) bool {
i := 0
for _, h := range have {
if i < len(want) && h == want[i] {
i++
}
}
return len(want) > 0 && i == len(want)
}
// ---- the shell's words ------------------------------------------------------------------------------
// simpleCommands splits a command line as a shell would into its simple commands' words: quotes
// removed, `;`, `&`, `|`, `&&`, `||`, newlines, parentheses and backticks between commands, `$(` opening
// one; `>` and `>>` kept as words of their own, before what they redirect to.
func simpleCommands(line string) [][]string {
var out [][]string
var words []string
var cur strings.Builder
inWord := false
flushWord := func() {
if inWord {
words = append(words, cur.String())
cur.Reset()
inWord = false
}
}
flushCommand := func() {
flushWord()
if len(words) > 0 {
out = append(out, words)
}
words = nil
}
r := []rune(line)
for i := 0; i < len(r); i++ {
c := r[i]
switch {
case c == '\\' && i+1 < len(r):
i++
if r[i] != '\n' {
cur.WriteRune(r[i])
inWord = true
}
case c == '\'':
inWord = true
for i++; i < len(r) && r[i] != '\''; i++ {
cur.WriteRune(r[i])
}
case c == '"':
inWord = true
for i++; i < len(r) && r[i] != '"'; i++ {
if r[i] == '\\' && i+1 < len(r) && strings.ContainsRune("\"\\$`", r[i+1]) {
i++
}
cur.WriteRune(r[i])
}
case c == '$' && i+1 < len(r) && r[i+1] == '(':
flushCommand()
i++
case c == ';' || c == '&' || c == '|' || c == '\n' || c == '(' || c == ')' || c == '`':
flushCommand()
case c == '>' || c == '<':
// A file descriptor before it (`2>`) is not a word.
if inWord && isDigits(cur.String()) {
cur.Reset()
inWord = false
}
flushWord()
op := string(c)
if c == '>' && i+1 < len(r) && r[i+1] == '>' {
op = ">>"
i++
}
if i+1 < len(r) && (r[i+1] == '&' || r[i+1] == '|') {
i++
}
words = append(words, op)
case c == ' ' || c == '\t':
flushWord()
case c == '#' && !inWord:
for i < len(r) && r[i] != '\n' {
i++
}
flushCommand()
default:
cur.WriteRune(c)
inWord = true
}
}
flushCommand()
return out
}
func isDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// substitutions are the command lines a quoted word runs in a shell of its own: `$(…)` and `…`.
func substitutions(word string) []string {
var out []string
for {
i := strings.Index(word, "$(")
if i < 0 {
break
}
rest := word[i+2:]
depth, end := 1, len(rest)
for j, c := range rest {
if c == '(' {
depth++
} else if c == ')' {
depth--
if depth == 0 {
end = j
break
}
}
}
out = append(out, rest[:end])
if end >= len(rest) {
break
}
word = rest[end+1:]
}
parts := strings.Split(word, "`")
for i := 1; i < len(parts); i += 2 {
out = append(out, parts[i])
}
return out
}
@@ -0,0 +1,229 @@
package main
// The guard as the hook runs it (novox/hq ADR 0245): `claude-code guard <guard.json>`, the tool call on
// standard input, exit 2 with the reason on standard error to refuse — the agent reads it — and 0 to let it be.
//
// **The operator's override.** `MESH_GUARD_OVERRIDE=<why>` lets one session through a refusal — never the
// refusal of a command naming the override itself — and only:
//
// - **from the session's environment as it was started**: read from /proc/<pid>/environ of the agent's own
// process, which is fixed at exec. A command's `export`, an `env` key in a settings file, a variable the
// session sets for its tools — none of them reaches it. A nested session the agent starts with the
// variable is refused before it starts: the command names the override;
// - **recorded**: every override is a line in the module's guard.log, with why, the command and the
// machine — and an override that cannot be recorded is not honoured. Refusals are recorded the same way.
//
// A guard that fails — a fault in this code — exits 1, which the agent shows and does not block on: a guard
// that refused every command would stop the session the operator needs to repair it.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// procRoot and parentPID are where the session's process is found; a test points them at its own.
var (
procRoot = "/proc"
parentPID = os.Getppid
)
// GuardLogMax is the size the guard's record grows to before it is kept once as guard.log.1.
const GuardLogMax = 1 << 20
// GuardEntry is one line of the guard's record.
type GuardEntry struct {
At string `json:"at"`
Node string `json:"node,omitempty"`
Decision string `json:"decision"` // refused | overridden
Rule string `json:"rule"`
Machine string `json:"machine,omitempty"`
Tool string `json:"tool"`
Command string `json:"command"`
Why string `json:"why,omitempty"`
// Tools are the tools the refusal named instead.
Tools []string `json:"tools,omitempty"`
Session string `json:"session,omitempty"`
}
// RecordGuard appends one line to the record.
func RecordGuard(path string, e GuardEntry) error {
if path == "" {
return fmt.Errorf("no record is kept here")
}
if info, err := os.Stat(path); err == nil && info.Size() > GuardLogMax {
_ = os.Rename(path, path+".1")
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
raw, _ := json.Marshal(e)
if _, err := f.Write(append(raw, '\n')); err != nil {
f.Close()
return err
}
return f.Close()
}
// ReadGuardLog is the record's last lines, newest last.
func ReadGuardLog(path string, last int) []GuardEntry {
raw, err := os.ReadFile(path)
if err != nil {
return []GuardEntry{}
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) > last {
lines = lines[len(lines)-last:]
}
out := []GuardEntry{}
for _, l := range lines {
var e GuardEntry
if json.Unmarshal([]byte(l), &e) == nil {
out = append(out, e)
}
}
return out
}
// sshDashG reads a destination as ssh does: `ssh -G` prints the configuration it would use and connects
// to nothing, so an alias, a HostName and a ProxyJump in ~/.ssh/config are what is judged.
func sshDashG(options []string, destination string) (host, user string, jumps []string) {
if _, err := exec.LookPath("ssh"); err != nil {
return "", "", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
args := append([]string{"-G"}, options...)
args = append(args, "--", destination)
out, err := exec.CommandContext(ctx, "ssh", args...).Output()
if err != nil {
return "", "", nil
}
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok {
continue
}
switch k {
case "hostname":
host = strings.ToLower(v)
case "user":
user = v
case "proxyjump":
if v != "none" {
jumps = append(jumps, strings.Split(v, ",")...)
}
}
}
return host, user, jumps
}
func quickLookup(name string) []string {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
return addrs
}
// sessionEnvironment is the environment the agent's own process was started with: the nearest ancestor
// that is the agent, read from what the kernel kept at its exec.
func sessionEnvironment(proc string, pid int) map[string]string {
for i := 0; i < 32 && pid > 1; i++ {
cmdline, _ := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "cmdline"))
if isAgent(cmdline) {
raw, err := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "environ"))
if err != nil {
return nil
}
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if k, v, ok := strings.Cut(string(kv), "="); ok {
env[k] = v
}
}
return env
}
stat, err := os.ReadFile(filepath.Join(proc, strconv.Itoa(pid), "stat"))
if err != nil {
return nil
}
// The parent is the fourth field, after the command's name in parentheses (which may hold spaces).
closing := bytes.LastIndexByte(stat, ')')
fields := strings.Fields(string(stat[closing+1:]))
if len(fields) < 2 {
return nil
}
pid, _ = strconv.Atoi(fields[1])
}
return nil
}
// isAgent says whether a process's command line is the agent's: its native binary, or its package run by node.
func isAgent(cmdline []byte) bool {
args := strings.Split(strings.TrimRight(string(cmdline), "\x00"), "\x00")
if len(args) == 0 || args[0] == "" {
return false
}
if filepath.Base(args[0]) == "claude" {
return true
}
if b := filepath.Base(args[0]); (b == "node" || b == "bun") && len(args) > 1 {
return filepath.Base(args[1]) == "claude" || strings.Contains(args[1], "@anthropic-ai/claude-code")
}
return false
}
// runGuard is the hook: the tool call in, the verdict out as the exit code.
func runGuard(dataPath string, stdin io.Reader, stderr io.Writer) (code int) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(stderr, "the mesh's guard failed and judged nothing: %v\n", r)
code = 1
}
}()
var in HookInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
fmt.Fprintf(stderr, "the mesh's guard could not read the tool call: %v\n", err)
return 1
}
var data GuardData
if raw, err := os.ReadFile(dataPath); err == nil {
_ = json.Unmarshal(raw, &data)
}
g := Guard{Data: data, SSH: sshDashG, Resolve: quickLookup}
v := g.Judge(in)
if !v.Refuse {
return 0
}
command, _ := in.ToolInput["command"].(string)
if command == "" {
command, _ = in.ToolInput["file_path"].(string)
}
entry := GuardEntry{At: time.Now().UTC().Format(time.RFC3339), Node: data.Node, Decision: "refused", Rule: v.Rule,
Machine: v.Machine, Tool: in.ToolName, Command: cut(command, 400), Session: in.SessionID, Tools: v.Calls}
if v.Overridable {
if why := strings.TrimSpace(sessionEnvironment(procRoot, parentPID())[OverrideVar]); why != "" {
entry.Decision, entry.Why = "overridden", cut(why, 200)
if err := RecordGuard(data.Log, entry); err == nil {
return 0
} else {
v.Message += fmt.Sprintf("\nThe operator's override is set, and could not be recorded (%v): an override "+
"that is not recorded is not honoured.", err)
entry.Decision, entry.Why = "refused", ""
}
}
}
_ = RecordGuard(data.Log, entry)
fmt.Fprintln(stderr, v.Message)
return 2
}
@@ -0,0 +1,324 @@
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)
}
}
}
@@ -0,0 +1,103 @@
package main
// The "instead of" table in the agent's managed instructions (novox/hq ADR 0245): to do this, call that
// address, not this shell command — generated on every render from what each seat verb and module tool
// says it replaces, so it cannot drift from the tools the mesh has.
//
// Short: a row per seat or module, its top what this machine's agent reached round most — the tools the
// guard named most often in its record.
import (
"fmt"
"sort"
"strings"
)
// InsteadOfRows is how many rows — seats and modules — the table holds; the rest are one search away.
const InsteadOfRows = 16
// insteadOrder is the order rows are taken in: a machine's seat, a module on a machine, the mesh's seat.
func insteadOrder(r Replacement) int {
switch {
case r.Seat && r.NodeScoped:
return 0
case !r.Seat:
return 1
}
return 2
}
func code(s string) string { return "`" + strings.ReplaceAll(s, "`", "'") + "`" }
// insteadGroup is one row: a seat or a module, and its verbs that replace a command.
type insteadGroup struct {
prefix string // `<node>/node-service-manager.` or `mesh-controller.`
verbs []Replacement
refused int
}
// InsteadOf is the section, or "" when the mesh said nothing replaces anything — an older controller,
// or one not yet asked.
//
// One row per seat or module, every verb of it that replaces a command in the row, so a verb is never cut
// for being late in its seat's list; the rows the guard here named most come first, then the machines'
// seats, the modules' tools and the mesh's own seats in the records' order.
func InsteadOf(m *MeshTools) string {
if m == nil || len(m.Replacements) == 0 {
return ""
}
var groups []*insteadGroup
at := map[string]*insteadGroup{}
for order := 0; order <= 2; order++ {
for _, r := range m.Replacements {
if insteadOrder(r) != order {
continue
}
prefix := r.Address[:strings.LastIndex(r.Address, ".")+1]
if r.NodeScoped {
prefix = "<node>/" + prefix
}
g := at[prefix]
if g == nil {
g = &insteadGroup{prefix: prefix}
at[prefix] = g
groups = append(groups, g)
}
g.verbs = append(g.verbs, r)
g.refused += m.Refused[r.Address]
}
}
sort.SliceStable(groups, func(i, j int) bool { return groups[i].refused > groups[j].refused })
for _, g := range groups {
sort.SliceStable(g.verbs, func(i, j int) bool { return m.Refused[g.verbs[i].Address] > m.Refused[g.verbs[j].Address] })
}
more := 0
if len(groups) > InsteadOfRows {
more = len(groups) - InsteadOfRows
groups = groups[:InsteadOfRows]
}
var b strings.Builder
b.WriteString("\n## Instead of a shell command\n\n")
b.WriteString("Generated from what each seat verb and module tool says it replaces (`replaces`, novox/hq ADR 0245),\n")
b.WriteString("rewritten on every render. Call `<address><verb>` through `mesh_call`, `<node>` being the machine;\n")
b.WriteString("`mesh_search` with the command you would have typed finds it too.\n\n")
b.WriteString("| call | the verb — instead of |\n|---|---|\n")
for _, g := range groups {
var cells []string
for _, r := range g.verbs {
var not []string
for _, c := range r.Replaces {
not = append(not, code(c))
}
cells = append(cells, code(r.Address[strings.LastIndex(r.Address, ".")+1:])+" — "+strings.Join(not, ", "))
}
fmt.Fprintf(&b, "| %s | %s |\n", code(g.prefix), strings.ReplaceAll(strings.Join(cells, " · "), "|", "/"))
}
if more > 0 {
fmt.Fprintf(&b, "\n%d more seats and modules: `mesh_search` with the command finds the tool for it.\n", more)
}
b.WriteString("\n- **Never `ssh` to a mesh machine**, and never edit `/etc/hosts` or set `HOSTALIASES` for a mesh\n" +
" name: the guard on this session's shell refuses it and names the tool. Where no tool covers what you\n" +
" need, none is worked around: say so, and the tool is created in the module that owns it, on its seat.\n")
return b.String()
}
@@ -0,0 +1,155 @@
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 0245): 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)
}
}
// Where claude-code runs is read from the controller's `modules` answer as it comes — JSON, from `module list
// --json` — and from the printed list too.
func TestTheMachinesRunningTheModuleAreReadFromTheControllersAnswer(t *testing.T) {
asJSON := json.RawMessage(`{"ok":true,"output":"[...]","answer":[{"module":"zsh","on":["a"]},{"module":"claude-code","on":["a","b"]}]}`)
if got := strings.Join(NodesRunning(asJSON, "claude-code"), ","); got != "a,b" {
t.Errorf("from JSON: %q", got)
}
printed := json.RawMessage(`{"ok":true,"output":"zsh 1 built x on a\nclaude-code 1 built y on a, b\n"}`)
if got := strings.Join(NodesRunning(printed, "claude-code"), ","); got != "a,b" {
t.Errorf("from the printed list: %q", got)
}
if got := NodesRunning(json.RawMessage(`{"ok":true,"answer":[{"module":"zsh","on":["a"]}]}`), "claude-code"); got != nil {
t.Errorf("a module that runs nowhere: %v", got)
}
}
+66 -13
View File
@@ -11,6 +11,7 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -83,7 +84,14 @@ func writeManagedTree(name, content string) (string, error) {
if f.Executable {
mode = 0o755
}
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
content := []byte(f.Content)
if f.From != "" {
var err error
if content, err = os.ReadFile(f.From); err != nil {
return "", err
}
}
if err := os.WriteFile(full, content, mode); err != nil {
return "", err
}
_ = os.Chmod(full, mode)
@@ -116,7 +124,13 @@ func sameTree(dir string, files map[string]PluginFile) bool {
}
raw, err := os.ReadFile(full)
info, ierr := d.Info()
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
want := []byte(f.Content)
if f.From != "" {
if want, err = os.ReadFile(f.From); err != nil {
return err
}
}
if err != nil || ierr != nil || !bytes.Equal(raw, want) || (info.Mode()&0o111 != 0) != f.Executable {
return errors.New("differs")
}
found++
@@ -149,20 +163,37 @@ func nodesRunningMe() ([]string, error) {
if err != nil {
return nil, err
}
var answer struct {
Output string `json:"output"`
return NodesRunning(raw, "claude-code"), nil
}
// NodesRunning reads where a module runs from the controller's `modules` answer. The verb runs `module list
// --json`, so the answer is a JSON list of {module, on}: the lines of the printed list it was once read as
// never came, and every "also on" hint and every `nodes: "all"` named no machine. The printed form, `<module>
// … on a, b`, is still read when that is what came.
func NodesRunning(raw json.RawMessage, module string) []string {
var listed []struct {
Module string `json:"module"`
On []string `json:"on"`
}
text := string(raw)
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
text = answer.Output
if json.Unmarshal(verbAnswer(raw), &listed) == nil {
for _, m := range listed {
if m.Module == module {
return m.On
}
}
return nil
}
text := outputOf(raw)
if text == "" {
text = string(raw)
}
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, "claude-code ") {
if !strings.HasPrefix(line, module+" ") {
continue
}
_, on, ok := strings.Cut(line, " on ")
if !ok || strings.TrimSpace(on) == "nothing" {
return nil, nil
return nil
}
var out []string
for _, n := range strings.Split(on, ",") {
@@ -170,9 +201,9 @@ func nodesRunningMe() ([]string, error) {
out = append(out, n)
}
}
return out, nil
return out
}
return nil, nil
return nil
}
func fingerprintOfFile(path string) any {
@@ -237,10 +268,27 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
Run: func(map[string]any) (any, error) { return status(p), nil }},
{Name: "claude_code_render",
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after asking the controller again what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
Run: func(map[string]any) (any, error) {
answer := map[string]any{}
if m, err := AskMeshTools(ask, lookup); err != nil {
answer["asked"] = "the controller did not answer in full (" + err.Error() + "); what was kept is rendered"
} else {
KeepMeshTools(p, m)
}
out, err := RenderNow(p, writeManaged)
return map[string]any{"rendered": out}, err
answer["rendered"] = out
return answer, err
}},
{Name: "claude_code_guard",
Description: "The guard on the agent's shell on this machine (novox/hq ADR 0245): what it refuses — ssh to a mesh machine, writing /etc/hosts or /etc/resolv.conf, HOSTALIASES — the machines and the replaced commands it judges with, the \"instead of\" table rendered into the agent's instructions, and its record of what it refused and what the operator's override let through, newest last.",
Input: map[string]any{"last": map[string]any{"type": "integer", "description": "how many lines of the record (default 50)"}},
Run: func(a map[string]any) (any, error) {
last := 50
if n, ok := a["last"].(float64); ok && n > 0 {
last = int(n)
}
return GuardStatus(p, last), nil
}},
{Name: "claude_code_pull",
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
@@ -339,6 +387,10 @@ func persist(what string, attempt func() error, done func(refusals int)) {
}
func main() {
// The guard on the agent's shell (novox/hq ADR 0245): this binary, run by the agent's hook.
if len(os.Args) == 3 && os.Args[1] == "guard" {
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
}
p, launched := PathsFrom(os.Getenv)
if !launched {
// Outside a launch — a build, a check — it serves nothing and says why.
@@ -360,6 +412,7 @@ func main() {
}
}
}
go refreshMeshTools(p)
servers := stateOf{stdio.State("servers")}
view := NewServerView(p)
config := stateOf{stdio.State("config")}
+327
View File
@@ -0,0 +1,327 @@
package main
// What the mesh says about its own tools and machines, as the agent's instructions and the guard on its
// shell need it (novox/hq ADR 0245): every seat verb and module tool that says what shell command it
// replaces, and the mesh's machines by every name and address a session could reach one by.
//
// Asked of the controller — `tools` for the seats' verbs, `modules` for the modules' own tools, `nodes`
// and `node` for the machines — at start and every few minutes, kept in the module's state so a render
// never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb that
// gains a `replaces` in the records is in the next render's table and the guard's next refusal.
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// Replacement is one verb or tool and the shell commands it is the mesh's way to do.
type Replacement struct {
// Address is how it is called: `<seat>.<verb>`, or `<module>.<tool>`; NodeScoped says a machine goes
// before it, `<node>/`.
Address string `json:"address"`
NodeScoped bool `json:"node_scoped,omitempty"`
Seat bool `json:"seat,omitempty"`
Does string `json:"does,omitempty"`
Replaces []string `json:"replaces"`
// On is where a module's tool runs; empty for a seat's verb, which every holder serves.
On []string `json:"on,omitempty"`
}
// Machine is one of the mesh's machines and the names and addresses it is reached by.
type Machine struct {
Name string `json:"name"`
Domains []string `json:"domains,omitempty"`
Addresses []string `json:"addresses,omitempty"`
}
// MeshTools is what the controller said, as kept in the module's state.
type MeshTools struct {
Replacements []Replacement `json:"replacements"`
Machines []Machine `json:"machines"`
// Asked is when the controller last answered.
Asked string `json:"asked,omitempty"`
// Refused is how often the guard here named each tool instead of a work-around, from its record: the
// table's order. Never kept — read from the record at each render.
Refused map[string]int `json:"-"`
}
// RefusedCounts are how often the guard's record named each tool, over its last lines.
func RefusedCounts(log string) map[string]int {
out := map[string]int{}
for _, e := range ReadGuardLog(log, 2000) {
for _, t := range e.Tools {
out[t]++
}
}
return out
}
// InternalSuffix is the mesh's own names' domain: every machine and service is `<name>.internal`
// (novox/hq ADR 0194), and a session reaches none of them by ssh.
const InternalSuffix = ".internal"
func (p Paths) meshTools() string { return filepath.Join(p.State, "mesh-tools.json") }
// ReadMeshTools is what was kept, or nil.
func ReadMeshTools(p Paths) *MeshTools {
var m MeshTools
if !readJSON(p.meshTools(), &m) {
return nil
}
return &m
}
// verbAnswer is a controller verb's answer: the command's printed output, and its JSON when it printed one.
func verbAnswer(raw json.RawMessage) json.RawMessage {
var r struct {
Output string `json:"output"`
Answer json.RawMessage `json:"answer"`
}
if json.Unmarshal(raw, &r) != nil {
return raw
}
if len(r.Answer) > 0 && string(r.Answer) != "null" {
return r.Answer
}
if r.Output != "" {
t := strings.TrimSpace(r.Output)
for _, open := range []string{"[", "{"} {
if strings.HasPrefix(t, open) {
return json.RawMessage(t)
}
if i := strings.Index(t, "\n"+open); i >= 0 {
return json.RawMessage(strings.TrimSpace(t[i+1:]))
}
}
return json.RawMessage(nil)
}
return raw
}
func outputOf(raw json.RawMessage) string {
var r struct {
Output string `json:"output"`
}
_ = json.Unmarshal(raw, &r)
return r.Output
}
var publicDomain = regexp.MustCompile(`(?m)^\s*public domain\s+(\S+)\s*$`)
// firstSentence is what a verb does, short: its description to the first full stop, at most 70 characters.
func firstSentence(s string) string {
s = strings.TrimSpace(s)
if i := strings.Index(s, ". "); i >= 0 {
s = s[:i]
}
s = strings.TrimSuffix(s, ".")
if i := strings.IndexAny(s, ":;("); i > 20 {
s = strings.TrimSpace(s[:i])
}
if r := []rune(s); len(r) > 70 {
s = strings.TrimSpace(string(r[:69])) + "…"
}
return s
}
// ReplacementsOf reads the controller's `tools` and `modules` answers: the seats' verbs first, in the
// records' order, then the modules' own tools by module and tool.
func ReplacementsOf(tools, modules json.RawMessage) []Replacement {
var out []Replacement
var seats struct {
Seats []struct {
Seat string `json:"seat"`
Scope string `json:"scope"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Replaces []string `json:"replaces"`
} `json:"tools"`
} `json:"seats"`
}
if json.Unmarshal(tools, &seats) == nil {
for _, s := range seats.Seats {
for _, t := range s.Tools {
if len(t.Replaces) == 0 {
continue
}
out = append(out, Replacement{Address: s.Seat + "." + t.Name, NodeScoped: s.Scope == "node", Seat: true,
Does: firstSentence(t.Description), Replaces: t.Replaces})
}
}
}
var listed []struct {
Module string `json:"module"`
On []string `json:"on"`
Replaces map[string][]string `json:"replaces"`
}
if json.Unmarshal(modules, &listed) == nil {
sort.SliceStable(listed, func(i, j int) bool { return listed[i].Module < listed[j].Module })
for _, m := range listed {
names := make([]string, 0, len(m.Replaces))
for t := range m.Replaces {
names = append(names, t)
}
sort.Strings(names)
for _, t := range names {
if len(m.Replaces[t]) == 0 {
continue
}
out = append(out, Replacement{Address: m.Module + "." + t, NodeScoped: true, Replaces: m.Replaces[t], On: m.On,
Does: strings.ReplaceAll(strings.TrimPrefix(t, strings.ReplaceAll(m.Module, "-", "_")+"_"), "_", " ")})
}
}
}
return out
}
// lookup resolves one name, briefly: a name that does not resolve here is no address.
func lookup(name string) []string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
addrs, _ := net.DefaultResolver.LookupHost(ctx, name)
return addrs
}
// AskMeshTools asks the controller now. A part that does not answer is left out and said in the error;
// what did answer is kept.
func AskMeshTools(ask Ask, resolve func(string) []string) (MeshTools, error) {
var m MeshTools
tools, terr := ask("seat:mesh-controller.tools", map[string]any{})
modules, merr := ask("seat:mesh-controller.modules", map[string]any{})
if terr != nil && merr != nil {
return m, terr
}
m.Replacements = ReplacementsOf(verbAnswer(tools), verbAnswer(modules))
nodesRaw, err := ask("seat:mesh-controller.nodes", map[string]any{})
if err != nil {
return m, err
}
var nodes []struct {
Name string `json:"name"`
}
_ = json.Unmarshal(verbAnswer(nodesRaw), &nodes)
for _, n := range nodes {
if n.Name == "" {
continue
}
machine := Machine{Name: n.Name, Domains: []string{n.Name + InternalSuffix}}
if shown, err := ask("seat:mesh-controller.node", map[string]any{"node": n.Name}); err == nil {
if d := publicDomain.FindStringSubmatch(outputOf(shown)); d != nil {
machine.Domains = append(machine.Domains, strings.ToLower(d[1]))
}
}
seen := map[string]bool{}
for _, name := range append([]string{n.Name}, machine.Domains...) {
for _, a := range resolve(name) {
if !seen[a] && !net.ParseIP(a).IsLoopback() {
seen[a] = true
machine.Addresses = append(machine.Addresses, a)
}
}
}
sort.Strings(machine.Addresses)
m.Machines = append(m.Machines, machine)
}
sort.Slice(m.Machines, func(i, j int) bool { return m.Machines[i].Name < m.Machines[j].Name })
return m, nil
}
// KeepMeshTools writes what was asked to the module's state when it differs from what was kept, and
// answers whether it did. When was asked is not a difference.
func KeepMeshTools(p Paths, m MeshTools) bool {
if was := ReadMeshTools(p); was != nil {
a, b := *was, m
a.Asked, b.Asked = "", ""
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
if string(x) == string(y) {
return false
}
}
m.Asked = time.Now().UTC().Format(time.RFC3339)
raw, _ := indented(m)
tmp := p.meshTools() + ".tmp"
if os.WriteFile(tmp, raw, 0o600) != nil {
return false
}
return os.Rename(tmp, p.meshTools()) == nil
}
// GuardDataOf is what the guard judges with on this machine.
func GuardDataOf(p Paths, m *MeshTools) GuardData {
d := GuardData{Node: p.Node, Log: filepath.Join(p.State, "guard.log"), Machines: []Machine{}, Replacements: []Replacement{}}
if m != nil {
d.Machines, d.Replacements = m.Machines, m.Replacements
}
return d
}
// guardFiles is the guard to place: this module's own executable and what it judges with. None where the
// executable cannot be named — then no hook is written, rather than one that cannot run.
func guardFiles(p Paths, m *MeshTools) *GuardFiles {
exe, err := os.Executable()
if err != nil {
return nil
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
}
// MeshToolsEvery is how often the controller is asked again.
const MeshToolsEvery = 10 * time.Minute
// refreshMeshTools asks the controller at start and every few minutes, and renders when what it said changed.
func refreshMeshTools(p Paths) {
for {
m, err := AskMeshTools(ask, lookup)
if err != nil {
say("asking the controller what each tool replaces: %v", err)
}
if (err == nil || len(m.Replacements) > 0) && KeepMeshTools(p, m) {
if _, err := RenderNow(p, writeManaged); err != nil {
say("rendering what the controller said of its tools: %v", err)
} else {
say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines))
}
}
time.Sleep(MeshToolsEvery)
}
}
// GuardStatus is what claude_code_guard answers.
func GuardStatus(p Paths, last int) map[string]any {
m := ReadMeshTools(p)
d := GuardDataOf(p, m)
asked := "never: the controller has not answered yet"
if m != nil {
asked = m.Asked
}
return map[string]any{
"refuses": []string{
"ssh, scp, sftp, rsync, mosh or autossh to a mesh machine: its name, a name under its domains, any *" + InternalSuffix +
" name or one of its addresses, read as ssh reads it (ssh -G); a jump through one too. An ssh login as `" +
ForgeUser + "` is the forge's account and passes",
"writing /etc/hosts or /etc/resolv.conf, by the shell or the agent's Edit and Write",
"HOSTALIASES, named anywhere in a command",
"any command naming " + OverrideVar,
},
"override": OverrideVar + "=<why>, set in the operator's own shell before the session starts; read from the session's " +
"environment as it was started, recorded with why, and not honoured when it cannot be recorded",
"hook": GuardCommand(),
"asked": asked,
"machines": d.Machines,
"replacements": d.Replacements,
"instead_of": InsteadOf(m),
"record": ReadGuardLog(d.Log, last),
}
}
+6 -2
View File
@@ -127,8 +127,12 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
var items map[string]Item
_ = readJSON(p.config(), &items)
config := ConfigOf(items)
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
tree, _ := json.Marshal(Marketplace(config))
mesh := ReadMeshTools(p)
if mesh != nil {
mesh.Refused = RefusedCounts(GuardDataOf(p, mesh).Log)
}
files := RenderWithMesh(facts, settings, binding, p.helper(), Registered(p), config, mesh)
tree, _ := json.Marshal(MarketplaceWith(config, guardFiles(p, mesh)))
files[MarketplaceDir+"/"] = string(tree)
names := make([]string, 0, len(files))
for n := range files {
@@ -100,6 +100,13 @@ func jsonFile(v any) string {
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
return RenderWithMesh(facts, settings, binding, helperPath, registered, config, nil)
}
// RenderWithMesh is Render with what the mesh said about its tools (novox/hq ADR 0245): the "instead of"
// table, after the mesh's own text and before the sections registered for the agent.
func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config,
mesh *MeshTools) map[string]string {
servers := map[string]any{}
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
for name, entry := range layer {
@@ -142,6 +149,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
return map[string]string{
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
"managed-settings.json": jsonFile(managed),
"CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
"CLAUDE.md": instructionsText(facts.Node, role) + InsteadOf(mesh) + InstructionSections(config),
}
}
+1
View File
@@ -22,6 +22,7 @@
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_guard",
"claude_code_pull",
"claude_code_grant",
"claude_code_add_api_key",