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
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.
316 lines
11 KiB
Go
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
|
|
}
|