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