package main // The guard as the hook runs it (novox/hq ADR 0245): `claude-code guard `, 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=` 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//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 }