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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user