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@`, 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"` // Agent is the account every session here must run as, where the machine names an agent account of its // own (novox/hq ADR 0266); empty where agents run as the operator. Agent string `json:"agent,omitempty"` } // 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 | account 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 } // JudgeAs is the verdict on one tool call of a session running as an account. **On a machine whose agents run // as an account of their own, a session running as any other does nothing** (novox/hq ADR 0266): the // operator's account there may become root without a person, and an agent started in it by habit would carry // that into every command. Refused whatever the command, and no override lifts it — the operator's own shell is // how the operator acts as themselves. func (g Guard) JudgeAs(account string, in HookInput) Verdict { if g.Data.Agent != "" && account != g.Data.Agent { return Verdict{Refuse: true, Rule: "account", Message: fmt.Sprintf("Refused: on %s agents run as %s, an "+ "account that cannot become root without a person, and this session runs as %s (novox/hq ADR 0266). "+ "No tool it calls here is let through. End it, and start the agent with `claude-agent`, which runs it as "+ "%s; what needs the operator's own rights is the operator's to run in their own shell.", orThisMachine(g.Data.Node), g.Data.Agent, account, g.Data.Agent)} } return g.Judge(in) } func orThisMachine(node string) string { if node == "" { return "this machine" } return node } // 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 = "" } 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 = "" } 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 }