Files
mesh-catalog/modules/claude-code/cmd/claude-code/guard_run.go
T
jochen b240085e0a
mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
Close the review's gaps round the agent account (hq ADR 0266)
The managed settings apply to every account's claude, so a key-helper script under the
agent's home would run as the operator, and through sudo as root: the command is now
carried whole in the root-owned managed settings and reads the key only as the agent.
The helper reads only a regular file, bounded, without blocking, and a request it does
not answer fails in time instead of holding every later one; a directory entry it
answers that is not a name is refused. The guard's record is matched on its binary in
the query, a half-named agent account is not taken as the operator's, the kept API key
stays until the agent's home holds one, restic refuses to restore beside a directory
others may write, and the agent-home check cleans a path before judging it.
2026-10-09 23:48:15 +02:00

316 lines
11 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 — or, where the agent runs as an account
// of its own, in the machine's journal (novox/hq ADR 0266) — with why, the command and the machine; 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"
"os/user"
"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"`
}
// JournalRecord is the guard's record where the agent runs as an account of its own (novox/hq ADR 0266): the
// session's account cannot enter the module's state, and must not be able to rewrite what it was refused, so
// each line goes to the machine's journal, which the account may add to and not change. The record's
// location is then this prefix and the journal's identifier.
const JournalRecord = "journal:"
// GuardJournalID is the identifier the guard's lines carry in the journal.
const GuardJournalID = "mesh-claude-code-guard"
// journalSocket is where the journal takes a line; a test points it at its own.
var journalSocket = "/run/systemd/journal/socket"
// 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 id, ok := strings.CutPrefix(path, JournalRecord); ok {
return recordInJournal(id, e)
}
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()
}
// recordInJournal says one line to the journal by its native protocol: the entry as JSON in MESSAGE, one line,
// so nothing in a command can add a field of its own.
func recordInJournal(id string, e GuardEntry) error {
raw, err := json.Marshal(e)
if err != nil {
return err
}
conn, err := net.Dial("unixgram", journalSocket)
if err != nil {
return fmt.Errorf("the journal cannot be reached: %w", err)
}
defer conn.Close()
_, err = conn.Write([]byte("SYSLOG_IDENTIFIER=" + id + "\nPRIORITY=5\nMESSAGE=" + string(raw) + "\n"))
return err
}
// readJournal is the journal's last lines of the guard's record, read as root through the runtime account's
// sudo where this process is not root: what other accounts said is not the runtime account's to read. As JSON,
// with the field the journal itself sets for the program that wrote the line (_EXE), which no writer chooses —
// and matched on it in the query itself, so lines another program wrote under the guard's identifier cannot
// push the guard's own past the last ones read.
var readJournal = func(id string, last int) ([]byte, error) {
args := []string{"journalctl", "-q", "--no-pager", "-o", "json", "--output-fields=MESSAGE,_EXE", "-t", id,
"_EXE=" + HelperPath(), "-n", strconv.Itoa(last)}
if os.Geteuid() != 0 {
args = append([]string{"sudo", "-n"}, args...)
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
return cmd.Output()
}
// guardsOwnLines keeps, of the journal's JSON lines, the messages the guard's own binary wrote (novox/hq ADR
// 0266). The agent's account may add to the journal under any identifier — `logger -t` — and the journal
// says which program wrote each line; a line another program wrote under the guard's identifier is not the
// guard's record. The agent can still run the guard's binary itself, so the record is the guard's words, not
// proof that each refusal happened in a session: what it cannot do is remove one.
func guardsOwnLines(raw []byte, exe string) []byte {
var out []byte
for _, line := range bytes.Split(raw, []byte("\n")) {
var f struct {
Message any `json:"MESSAGE"`
Exe string `json:"_EXE"`
}
if json.Unmarshal(line, &f) != nil || f.Exe != exe {
continue
}
if m, ok := f.Message.(string); ok {
out = append(append(out, m...), '\n')
}
}
return out
}
// ReadGuardLog is the record's last lines, newest last.
func ReadGuardLog(path string, last int) []GuardEntry {
read := os.ReadFile
if id, ok := strings.CutPrefix(path, JournalRecord); ok {
read = func(string) ([]byte, error) {
raw, err := readJournal(id, last)
return guardsOwnLines(raw, HelperPath()), err
}
}
raw, err := read(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
}
// currentAccount is the account the session runs as; a test sets its own.
var currentAccount = func() string {
if u, err := user.Current(); err == nil {
return u.Username
}
return "uid " + strconv.Itoa(os.Getuid())
}
// 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.JudgeAs(currentAccount(), 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
}