Files
mesh-catalog/modules/claude-code/cmd/claude-code/guard_run.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

230 lines
7.4 KiB
Go

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
}