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:
@@ -17,7 +17,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inProcess is a helper served in this process over pipes, counting what it was asked; kill ends it, as a
|
||||
@@ -198,6 +200,10 @@ func TestTheAgentsAccountAndHomeComeFromTheRuntime(t *testing.T) {
|
||||
if p.keyDir() != "/home/agent/.claude/mesh" || p.placed() != "/s/home-placed-agent.json" {
|
||||
t.Fatalf("the key and the record follow the agent's account: %s %s", p.keyDir(), p.placed())
|
||||
}
|
||||
with["MESH_AGENT_HOME"] = ""
|
||||
if _, ok := PathsFrom(envOf(with)); ok {
|
||||
t.Fatal("the agent's account without its home was taken as the operator's")
|
||||
}
|
||||
with["MESH_AGENT_ACCOUNT"], with["MESH_AGENT_HOME"] = "operator", "/home/operator"
|
||||
if p, _ = PathsFrom(envOf(with)); p.Confined() || p.keyDir() != "/s" || p.placed() != "/s/home-placed.json" {
|
||||
t.Fatalf("the agent account named as the operator's is the operator's: %+v", p)
|
||||
@@ -255,8 +261,33 @@ func TestTheTokenTheModulePlacedInTheOperatorsHomeGoes(t *testing.T) {
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatalf("the access-token-only file is the module's and goes: %s", said)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(p.State, "api-key")); err != nil {
|
||||
t.Fatal("the key kept in the state went before the agent's home held one")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p.apiKey()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, p.apiKey(), "k\n")
|
||||
RetireOperatorHome(p)
|
||||
if _, err := os.Stat(filepath.Join(p.State, "api-key")); !os.IsNotExist(err) {
|
||||
t.Fatal("the key kept where the agent's account cannot read it goes")
|
||||
t.Fatal("the key kept where the agent's account cannot read it goes once the agent's home holds it")
|
||||
}
|
||||
}
|
||||
|
||||
// The managed settings apply to every account's claude, root's included: where the agent runs as an account of
|
||||
// its own, the key-helper they name is never a file under any home, and reads the key only as that account.
|
||||
func TestTheKeyHelperIsNeverAFileTheAgentOwns(t *testing.T) {
|
||||
p := confined(t)
|
||||
cmd := p.helperCommand()
|
||||
if strings.HasPrefix(cmd, "/") || strings.HasPrefix(cmd, p.Home) || strings.HasPrefix(cmd, filepath.Dir(p.Home)) {
|
||||
t.Fatalf("the key-helper is a path the agent may own: %q", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, `"$(id -un)" = 'agent'`) || !strings.Contains(cmd, "exec cat -- '"+p.apiKey()+"'") {
|
||||
t.Fatalf("the key-helper reads the key only as the agent's account: %q", cmd)
|
||||
}
|
||||
p.Agent = p.Operator
|
||||
if p.helperCommand() != p.helper() {
|
||||
t.Fatalf("where the agent runs as the operator the helper stays in the state: %q", p.helperCommand())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,3 +484,90 @@ func TestAnAnswerPastTheBoundIsRefused(t *testing.T) {
|
||||
t.Fatalf("a line within it is read whole across the buffer: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A FIFO, a device or a link to one in the agent's home is answered at once, an error: it never holds the
|
||||
// helper, and every request behind it (review of 2026-10-09).
|
||||
func TestTheHelperReadsOnlyARegularFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fifo := filepath.Join(dir, ".credentials.json")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Skip("no FIFO here:", err)
|
||||
}
|
||||
zero := filepath.Join(dir, "zero")
|
||||
if err := os.Symlink("/dev/zero", zero); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{fifo, zero} {
|
||||
done := make(chan Answer, 1)
|
||||
go func() { done <- serveOne(Op{Op: "read", Path: path}) }()
|
||||
select {
|
||||
case a := <-done:
|
||||
if a.Err == "" || len(a.Data) != 0 {
|
||||
t.Fatalf("%s was read: %q", path, a.Data)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("reading %s held the helper", path)
|
||||
}
|
||||
}
|
||||
big := filepath.Join(dir, "big")
|
||||
if err := os.WriteFile(big, make([]byte, helperReadMax+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := serveOne(Op{Op: "read", Path: big}); a.Err == "" {
|
||||
t.Fatal("a file past the bound was read")
|
||||
}
|
||||
}
|
||||
|
||||
// helperAnswering is a helper that answers every request with answer, or never when answer is empty.
|
||||
func helperAnswering(account, answer string) *Helper {
|
||||
return &Helper{Account: account, Start: func() (io.WriteCloser, io.Reader, func(), error) {
|
||||
reqR, reqW := io.Pipe()
|
||||
ansR, ansW := io.Pipe()
|
||||
go func() {
|
||||
r := bufio.NewReader(reqR)
|
||||
for {
|
||||
if _, err := r.ReadBytes('\n'); err != nil {
|
||||
_ = ansW.Close()
|
||||
return
|
||||
}
|
||||
if answer != "" {
|
||||
_, _ = ansW.Write([]byte(answer + "\n"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
return reqW, ansR, func() { _ = reqR.Close(); _ = ansW.Close() }, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// What the helper answers is the agent's word: an entry that is not a name in the directory is refused, so no
|
||||
// path joined from it leaves the home and is read as the runtime's account.
|
||||
func TestAnEntryThatLeavesTheDirectoryIsRefused(t *testing.T) {
|
||||
for _, name := range []string{"../operator/.ssh/id_ed25519", "..", ".", "", "a/b"} {
|
||||
raw, _ := json.Marshal(Answer{Entries: []*Info{{Name: name}}})
|
||||
f := &Files{Home: "/home/agent", Account: "agent", Helper: helperAnswering("agent", string(raw))}
|
||||
if _, err := f.ReadDir("/home/agent/.claude"); err == nil {
|
||||
t.Fatalf("the entry %q was taken", name)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(Answer{Entries: []*Info{{Name: "settings.json"}}})
|
||||
f := &Files{Home: "/home/agent", Account: "agent", Helper: helperAnswering("agent", string(raw))}
|
||||
if got, err := f.ReadDir("/home/agent/.claude"); err != nil || len(got) != 1 {
|
||||
t.Fatalf("a name in the directory was refused: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A helper that does not answer fails the request in time, said, and is not asked again for it.
|
||||
func TestAHelperThatDoesNotAnswerFailsTheRequestInTime(t *testing.T) {
|
||||
was := helperAnswerWithin
|
||||
helperAnswerWithin = 200 * time.Millisecond
|
||||
defer func() { helperAnswerWithin = was }()
|
||||
h := helperAnswering("agent", "")
|
||||
start := time.Now()
|
||||
_, err := h.Do(Op{Op: "read", Path: "/home/agent/.claude.json"})
|
||||
if err == nil || !errors.Is(err, errHelperSlow) {
|
||||
t.Fatalf("a silent helper answered: %v", err)
|
||||
}
|
||||
if time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("the request waited %s", time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user