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

620 lines
18 KiB
Go

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"
"syscall"
"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 {
// A name is one entry of path, never a way out of it: the helper runs as the agent's account, so what it
// answers is the agent's word, and a name joined to path is read as the runtime's when it leaves the home.
if e == nil || !entryName(e.Name) {
return nil, fmt.Errorf("readdir %s as %s: the helper answered an entry that is not a name in it", path,
f.Helper.Account)
}
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
}
// entryName says whether name is one entry of a directory: not empty, not . or .., and no separator.
func entryName(name string) bool {
return name != "" && name != "." && name != ".." && !strings.ContainsRune(name, '/') && !strings.ContainsRune(name, 0)
}
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()
}
// helperAnswerWithin bounds how long one request may take. The helper's own reads cannot block (serveOne), so
// a helper this slow has gone wrong: it is stopped and the request fails, said, never retried, so no request
// holds every later one behind it.
var helperAnswerWithin = 30 * time.Second
// errHelperSlow is a helper that did not answer in time.
var errHelperSlow = errors.New("the helper did not answer in time")
// 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()
if errors.Is(err, errHelperSlow) {
break
}
}
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
}
type read struct {
line []byte
err error
}
done := make(chan read, 1)
in, out := h.in, h.out
go func() {
if _, err := in.Write(append(raw, '\n')); err != nil {
done <- read{err: err}
return
}
line, err := readLine(out, helperAnswerMax)
done <- read{line, err}
}()
var line []byte
select {
case r := <-done:
if r.err != nil {
return Answer{}, r.err
}
line = r.line
case <-time.After(helperAnswerWithin):
// The goroutine ends when stop closes the pipes and the helper goes.
return Answer{}, fmt.Errorf("%w (%s)", errHelperSlow, helperAnswerWithin)
}
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):
// sudo passes a SIGTERM on to the helper; a SIGKILL would end sudo alone and leave it running.
_ = cmd.Process.Signal(syscall.SIGTERM)
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 -------------------------------------------------------------------------------------------
// helperReadMax bounds a file the helper reads: its answer, as JSON, stays within helperAnswerMax.
const helperReadMax = 10 << 20
// readRegular reads a regular file of at most helperReadMax bytes. Opened without blocking and judged by what was
// opened, so a FIFO, a device or a link to one in the agent's home answers an error at once instead of holding
// the helper, and every request behind it, or filling its memory.
func readRegular(path string) ([]byte, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file (%s)", path, info.Mode().Type())
}
data, err := io.ReadAll(io.LimitReader(f, helperReadMax+1))
if err != nil {
return nil, err
}
if len(data) > helperReadMax {
return nil, fmt.Errorf("%s is larger than %d bytes", path, helperReadMax)
}
return data, nil
}
// 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 = readRegular(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
}