Close the review's gaps round the agent account (hq ADR 0266)
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.
This commit is contained in:
jochen
2026-10-09 23:48:15 +02:00
parent b382045721
commit b240085e0a
11 changed files with 295 additions and 33 deletions
+84 -8
View File
@@ -32,6 +32,7 @@ import (
"sort"
"strings"
"sync"
"syscall"
"time"
)
@@ -184,6 +185,12 @@ func (f *Files) ReadDir(path string) ([]fs.DirEntry, error) {
}
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
@@ -207,6 +214,11 @@ func (f *Files) WalkDir(root string, fn fs.WalkDirFunc) error {
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() {
@@ -317,6 +329,14 @@ type Helper struct {
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()
@@ -334,6 +354,9 @@ func (h *Helper) Do(op Op) (Answer, error) {
}
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)
}
@@ -343,12 +366,30 @@ func (h *Helper) roundTrip(op Op) (Answer, error) {
if err != nil {
return Answer{}, err
}
if _, err := h.in.Write(append(raw, '\n')); err != nil {
return Answer{}, err
type read struct {
line []byte
err error
}
line, err := readLine(h.out, helperAnswerMax)
if err != nil {
return Answer{}, err
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 {
@@ -430,8 +471,14 @@ func (h *Helper) sudo() (io.WriteCloser, io.Reader, func(), error) {
select {
case <-done:
case <-time.After(2 * time.Second):
_ = cmd.Process.Kill()
<-done
// 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)
@@ -459,6 +506,35 @@ func (l *limited) Write(p []byte) (int, error) {
// ---- 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 {
@@ -487,7 +563,7 @@ func serveOne(op Op) Answer {
var err error
switch op.Op {
case "read":
a.Data, err = os.ReadFile(op.Path)
a.Data, err = readRegular(op.Path)
case "write":
err = os.WriteFile(op.Path, op.Data, mode)
case "create":