claude-code: write the agent's home as its own account where it has one

On the control node agents are to run as an account that cannot become root
(hq ADR 0266). The runtime keeps running as the operator, so the token, the
identity and the home-scope items go to the agent's home through a helper
started as that account; the licence and the account file follow the move,
what was placed in the operator's home goes once the agent holds the licence,
and the guard refuses every tool of a session running as anyone else,
recording to the journal. An API key is taken only from the agent's home and
only when it is one, here and in the licence manager, so the tool cannot carry
off the runtime's credential. Unchanged where no agent account is named.
This commit is contained in:
jochen
2026-10-09 10:14:18 +02:00
parent 7339ce8556
commit b57e6e8144
17 changed files with 1467 additions and 102 deletions
@@ -0,0 +1,543 @@
package main
// The agent's home, touched as the agent's account (novox/hq ADR 0266).
//
// On a machine that names an agent account of its own — the control node — agents run as that account, not
// as the operator's, so that no agent there can become root without a person. Its home is its own, 0700:
// the node's tool runtime, which runs this module as the operator's account, can neither read nor write it.
// And this module must: the licence token and the account the agent is logged in as, the items registered
// at the home scope, and the home's own items the status and the removal tools read.
//
// So every path under that home is touched by a helper running as the agent's account: this module's own
// binary, as the plugin carries it under the managed directory (root's, so the agent cannot change it),
// started once through `sudo -n -u <agent>` and kept, speaking one JSON request and one JSON answer per line
// on its stdin and stdout. Once, not per file: a credentials file is looked at every five seconds, and sudo
// says every start in the journal. **The escalation goes down, never up**: the runtime's account may become
// the agent's; nothing here lets the agent's become anything.
//
// Everything else — the module's state, the managed directory, a machine whose agents run as the operator —
// is the plain filesystem, as before. Files routes each path; with no agent account of its own it routes none.
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// HelperArg is the argument that makes this binary the helper (as the agent's account).
const HelperArg = "home-fs"
// Files is the filesystem as this module touches it: a path under the agent's home through the helper when
// the agent's account is not this process's, every other path as it is.
type Files struct {
// Home is the agent's home and Account its login; Helper starts the helper, nil for none.
Home string
Account string
Helper *Helper
}
// fsys is the module's: plain until the launch says whose home the agent's is.
var fsys = &Files{}
// FilesFor is the filesystem for these paths: through the helper when the agent's account is another than the
// one this process runs as.
func FilesFor(p Paths) *Files {
if p.Agent == "" || p.Home == "" {
return &Files{}
}
if me, err := user.Current(); err == nil && me.Username == p.Agent {
return &Files{}
}
return &Files{Home: filepath.Clean(p.Home), Account: p.Agent, Helper: &Helper{Account: p.Agent, Exe: HelperPath()}}
}
// HelperPath is the helper's binary: the guard's copy of this module's binary in the plugin, root's and
// readable by every account, so the agent's account runs exactly what the mesh placed.
func HelperPath() string {
return filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir, "guard")
}
// through says whether a path is touched as the agent.
func (f *Files) through(path string) bool {
if f == nil || f.Helper == nil || f.Home == "" {
return false
}
path = filepath.Clean(path)
return path == f.Home || strings.HasPrefix(path, f.Home+string(os.PathSeparator))
}
// ---- the operations ---------------------------------------------------------------------------------------
func (f *Files) ReadFile(path string) ([]byte, error) {
if !f.through(path) {
return os.ReadFile(path)
}
a, err := f.Helper.Do(Op{Op: "read", Path: path})
return a.Data, err
}
func (f *Files) WriteFile(path string, data []byte, mode os.FileMode) error {
if !f.through(path) {
return os.WriteFile(path, data, mode)
}
_, err := f.Helper.Do(Op{Op: "write", Path: path, Data: data, Mode: uint32(mode.Perm())})
return err
}
// CreateExclusive writes a file that must not be there yet.
func (f *Files) CreateExclusive(path string, data []byte, mode os.FileMode) error {
if !f.through(path) {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
_, werr := file.Write(data)
return errors.Join(werr, file.Close())
}
_, err := f.Helper.Do(Op{Op: "create", Path: path, Data: data, Mode: uint32(mode.Perm())})
return err
}
func (f *Files) Rename(from, to string) error {
if f.through(from) != f.through(to) {
return fmt.Errorf("%s cannot be moved to %s: one is the agent's home and the other is not", from, to)
}
if !f.through(from) {
return os.Rename(from, to)
}
_, err := f.Helper.Do(Op{Op: "rename", Path: from, To: to})
return err
}
func (f *Files) Remove(path string) error {
if !f.through(path) {
return os.Remove(path)
}
_, err := f.Helper.Do(Op{Op: "remove", Path: path})
return err
}
func (f *Files) RemoveAll(path string) error {
if !f.through(path) {
return os.RemoveAll(path)
}
_, err := f.Helper.Do(Op{Op: "remove-all", Path: path})
return err
}
func (f *Files) MkdirAll(path string, mode os.FileMode) error {
if !f.through(path) {
return os.MkdirAll(path, mode)
}
_, err := f.Helper.Do(Op{Op: "mkdir", Path: path, Mode: uint32(mode.Perm())})
return err
}
func (f *Files) Chmod(path string, mode os.FileMode) error {
if !f.through(path) {
return os.Chmod(path, mode)
}
_, err := f.Helper.Do(Op{Op: "chmod", Path: path, Mode: uint32(mode.Perm())})
return err
}
func (f *Files) Stat(path string) (fs.FileInfo, error) {
if !f.through(path) {
return os.Stat(path)
}
a, err := f.Helper.Do(Op{Op: "stat", Path: path})
if err != nil {
return nil, err
}
return a.Info.info(), nil
}
func (f *Files) Lstat(path string) (fs.FileInfo, error) {
if !f.through(path) {
return os.Lstat(path)
}
a, err := f.Helper.Do(Op{Op: "lstat", Path: path})
if err != nil {
return nil, err
}
return a.Info.info(), nil
}
func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) {
if !f.through(path) {
return os.ReadDir(path)
}
a, err := f.Helper.Do(Op{Op: "readdir", Path: path})
if err != nil {
return nil, err
}
out := make([]fs.DirEntry, 0, len(a.Entries))
for _, e := range a.Entries {
out = append(out, fs.FileInfoToDirEntry(e.info()))
}
return out, nil
}
// WalkDir is filepath.WalkDir, through the helper under the agent's home: lexical order, a directory before
// what is in it, a symbolic link reported and never followed.
func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error {
if !f.through(root) {
return filepath.WalkDir(root, fn)
}
info, err := f.Lstat(root)
if err != nil {
err = fn(root, nil, err)
} else {
err = f.walk(root, fs.FileInfoToDirEntry(info), fn)
}
if err == filepath.SkipDir || err == filepath.SkipAll {
return nil
}
return err
}
func (f *Files) walk(path string, d fs.DirEntry, fn fs.WalkDirFunc) error {
if err := fn(path, d, nil); err != nil || !d.IsDir() {
if err == filepath.SkipDir && d.IsDir() {
err = nil
}
return err
}
entries, err := f.ReadDir(path)
if err != nil {
if err = fn(path, d, err); err != nil {
if err == filepath.SkipDir {
err = nil
}
return err
}
}
for _, e := range entries {
if err := f.walk(filepath.Join(path, e.Name()), e, fn); err != nil {
if err == filepath.SkipDir {
break
}
return err
}
}
return nil
}
// ---- the wire ---------------------------------------------------------------------------------------------
// Op is one request to the helper.
type Op struct {
Op string `json:"op"`
Path string `json:"path"`
To string `json:"to,omitempty"`
Data []byte `json:"data,omitempty"`
Mode uint32 `json:"mode,omitempty"`
}
// Answer is the helper's to one Op. Err is said by its kind, so the caller's os.IsNotExist and errors.Is
// read it as they would read the filesystem's own.
type Answer struct {
Data []byte `json:"data,omitempty"`
Info *Info `json:"info,omitempty"`
Entries []*Info `json:"entries,omitempty"`
Err string `json:"err,omitempty"`
Kind string `json:"kind,omitempty"` // not-exist | exist | permission
}
// Info is a file's information as the wire carries it.
type Info struct {
Name string `json:"name"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
ModTime time.Time `json:"modTime"`
}
func infoOf(i fs.FileInfo) *Info {
return &Info{Name: i.Name(), Size: i.Size(), Mode: uint32(i.Mode()), ModTime: i.ModTime()}
}
func (i *Info) info() fs.FileInfo {
if i == nil {
return fileInfo{}
}
return fileInfo{*i}
}
type fileInfo struct{ Info }
func (i fileInfo) Name() string { return i.Info.Name }
func (i fileInfo) Size() int64 { return i.Info.Size }
func (i fileInfo) Mode() fs.FileMode { return fs.FileMode(i.Info.Mode) }
func (i fileInfo) ModTime() time.Time { return i.Info.ModTime }
func (i fileInfo) IsDir() bool { return i.Mode().IsDir() }
func (i fileInfo) Sys() any { return nil }
func errorOf(op, path string, a Answer) error {
if a.Err == "" {
return nil
}
var kind error
switch a.Kind {
case "not-exist":
kind = fs.ErrNotExist
case "exist":
kind = fs.ErrExist
case "permission":
kind = fs.ErrPermission
default:
return errors.New(a.Err)
}
// The kind itself, unwrapped: os.IsNotExist compares rather than unwraps.
return &fs.PathError{Op: op, Path: path, Err: kind}
}
// ---- the client -------------------------------------------------------------------------------------------
// Helper is the helper as this module keeps it: started at the first request, restarted once when it is gone.
type Helper struct {
Account string
Exe string
// Start is how it is started; nil is through sudo as the account. A test starts it in the process.
Start func() (io.WriteCloser, io.Reader, func(), error)
mu sync.Mutex
in io.WriteCloser
out *bufio.Reader
close func()
}
// Do answers one request, starting the helper when it is not running.
func (h *Helper) Do(op Op) (Answer, error) {
h.mu.Lock()
defer h.mu.Unlock()
var last error
for try := 0; try < 2; try++ {
if h.in == nil {
if err := h.start(); err != nil {
return Answer{}, err
}
}
a, err := h.roundTrip(op)
if err == nil {
return a, errorOf(op.Op, op.Path, a)
}
last = err
h.stop()
}
return Answer{}, fmt.Errorf("%s %s as %s: the helper did not answer: %w", op.Op, op.Path, h.Account, last)
}
func (h *Helper) roundTrip(op Op) (Answer, error) {
raw, err := json.Marshal(op)
if err != nil {
return Answer{}, err
}
if _, err := h.in.Write(append(raw, '\n')); err != nil {
return Answer{}, err
}
line, err := readLine(h.out, helperAnswerMax)
if err != nil {
return Answer{}, err
}
var a Answer
if err := json.Unmarshal(line, &a); err != nil {
return Answer{}, fmt.Errorf("the helper said something that is not an answer: %w", err)
}
return a, nil
}
// helperAnswerMax bounds one answer of the helper: far above the largest file this module reads from a home
// (an item is at most 256 KiB), so an answer past it is a helper gone wrong, not a file.
const helperAnswerMax = 16 << 20
// readLine reads one line of at most max bytes; a longer one is an error and the helper is started again.
func readLine(r *bufio.Reader, max int) ([]byte, error) {
var line []byte
for {
chunk, err := r.ReadSlice('\n')
line = append(line, chunk...)
if len(line) > max {
return nil, fmt.Errorf("the helper's answer is larger than %d bytes", max)
}
if err == bufio.ErrBufferFull {
continue
}
return line, err
}
}
func (h *Helper) start() error {
start := h.Start
if start == nil {
start = h.sudo
}
in, out, closer, err := start()
if err != nil {
return err
}
h.in, h.out, h.close = in, bufio.NewReaderSize(out, 1<<20), closer
return nil
}
func (h *Helper) stop() {
if h.in != nil {
_ = h.in.Close()
}
if h.close != nil {
h.close()
}
h.in, h.out, h.close = nil, nil, nil
}
// sudo starts the helper as the agent's account: the runtime's account may become it (the sudo module's
// rule for the operator's account names every account), and nothing the agent's account holds lets it back.
func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) {
if _, err := os.Stat(h.Exe); err != nil {
return nil, nil, nil, fmt.Errorf("the helper that touches %s's home is not placed yet (%s): the managed "+
"directory is written first, and the next render reaches the home", h.Account, h.Exe)
}
cmd := exec.Command("sudo", "-n", "-u", h.Account, "--", h.Exe, HelperArg)
cmd.Dir = "/"
// One language for what sudo says when it refuses, so its words are read the same on every machine.
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
in, err := cmd.StdinPipe()
if err != nil {
return nil, nil, nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, nil, err
}
var stderr strings.Builder
cmd.Stderr = &limited{w: &stderr, left: 4096}
if err := cmd.Start(); err != nil {
return nil, nil, nil, fmt.Errorf("the helper could not be started as %s: %w", h.Account, err)
}
return in, out, func() {
done := make(chan struct{})
go func() { _ = cmd.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
_ = cmd.Process.Kill()
<-done
}
if s := strings.TrimSpace(stderr.String()); s != "" {
say("the helper as %s said: %s", h.Account, s)
}
}, nil
}
type limited struct {
w io.Writer
left int
}
func (l *limited) Write(p []byte) (int, error) {
n := len(p)
if l.left <= 0 {
return n, nil
}
if len(p) > l.left {
p = p[:l.left]
}
l.left -= len(p)
_, _ = l.w.Write(p)
return n, nil
}
// ---- the helper -------------------------------------------------------------------------------------------
// ServeHelper is the helper: one answer per request, until its input ends. It runs as the agent's account and
// does only what that account may, on its own home: the account's permissions are the boundary.
func ServeHelper(in io.Reader, out io.Writer) error {
dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20))
enc := json.NewEncoder(out)
for {
var op Op
if err := dec.Decode(&op); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if err := enc.Encode(serveOne(op)); err != nil {
return err
}
}
}
func serveOne(op Op) Answer {
if !filepath.IsAbs(op.Path) || (op.To != "" && !filepath.IsAbs(op.To)) {
return Answer{Err: "a path must be absolute"}
}
mode := os.FileMode(op.Mode).Perm()
var a Answer
var err error
switch op.Op {
case "read":
a.Data, err = os.ReadFile(op.Path)
case "write":
err = os.WriteFile(op.Path, op.Data, mode)
case "create":
var f *os.File
if f, err = os.OpenFile(op.Path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode); err == nil {
_, werr := f.Write(op.Data)
err = errors.Join(werr, f.Close())
}
case "rename":
err = os.Rename(op.Path, op.To)
case "remove":
err = os.Remove(op.Path)
case "remove-all":
err = os.RemoveAll(op.Path)
case "mkdir":
err = os.MkdirAll(op.Path, mode)
case "chmod":
err = os.Chmod(op.Path, mode)
case "stat", "lstat":
var i fs.FileInfo
if op.Op == "stat" {
i, err = os.Stat(op.Path)
} else {
i, err = os.Lstat(op.Path)
}
if err == nil {
a.Info = infoOf(i)
}
case "readdir":
var entries []fs.DirEntry
entries, err = os.ReadDir(op.Path)
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, e := range entries {
if i, ierr := e.Info(); ierr == nil {
a.Entries = append(a.Entries, infoOf(i))
}
}
default:
return Answer{Err: "no such operation: " + op.Op}
}
if err != nil {
a = Answer{Err: err.Error()}
switch {
case errors.Is(err, fs.ErrNotExist):
a.Kind = "not-exist"
case errors.Is(err, fs.ErrExist):
a.Kind = "exist"
case errors.Is(err, fs.ErrPermission):
a.Kind = "permission"
}
}
return a
}