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

786 lines
22 KiB
Go

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
}