claude-code: point the agent at the mesh's tools and guard its shell against ssh to the mesh (hq ADR 0245)
The agent kept running ssh <machine> journalctl while the journal verb existed. The managed instructions now carry an "instead of" table generated from what each verb says it replaces, and the plugin carries a PreToolUse guard that refuses ssh to a mesh machine and local work-arounds for a mesh name, naming the tool or saying one must be created. The operator's override is read from the session's start environment and recorded.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
// The guard as the hook runs it (novox/hq ADR 0244): `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
|
||||
}
|
||||
Reference in New Issue
Block a user