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
+14 -4
View File
@@ -495,8 +495,8 @@ func (b *Backups) Restore(ctx context.Context, module, snapshot, path string) (*
}
// besideIsUnsafe says why root must not restore at target, beside the live data: a directory on the way to it
// is a symbolic link, or belongs to an account other than root and the one this process runs as — one that
// could swap a link in while root writes. Empty when every directory on the way is root's or the runtime's.
// is a symbolic link, belongs to an account other than root and the one this process runs as, or may be written
// by its group or everyone — any of which could swap a link in while root writes. Empty when every directory on the way is root's or the runtime's.
func (b *Backups) besideIsUnsafe(ctx context.Context, target string) string {
me := strconv.Itoa(os.Getuid())
dir := filepath.Dir(filepath.Clean(target))
@@ -508,17 +508,27 @@ func (b *Backups) besideIsUnsafe(ctx context.Context, target string) string {
}
}
for i := len(chain) - 1; i >= 0; i-- {
out, err := b.Run(ctx, "stat", "-c", "%u %F", chain[i])
out, err := b.Run(ctx, "stat", "-c", "%u %a %F", chain[i])
if err != nil {
return chain[i] + " could not be read"
}
owner, kind, _ := strings.Cut(strings.TrimSpace(out), " ")
owner, rest, _ := strings.Cut(strings.TrimSpace(out), " ")
perm, kind, _ := strings.Cut(rest, " ")
if kind == "symbolic link" {
return chain[i] + " is a symbolic link"
}
if owner != "0" && owner != me {
return chain[i] + " belongs to another account"
}
// Another account may write into a directory open to its group or to everyone, sticky or not, and so
// put a link where root is about to write.
mode, err := strconv.ParseUint(perm, 8, 32)
if err != nil {
return chain[i] + "'s mode could not be read"
}
if mode&0o022 != 0 {
return chain[i] + " may be written by other accounts"
}
}
return ""
}
@@ -190,8 +190,13 @@ func TestWithTheRealResticAMistakeIsUndoneBesideTheLiveData(t *testing.T) {
settings := filepath.Join(root, "settings.xml")
must(t, os.WriteFile(settings, []byte("<Config>kept</Config>\n"), 0o600))
where := placed(t, "# mail\npath "+store+"\npath "+settings+"\n# pg\nrun echo 'every row' > "+dumps+"/all.dump\npath "+dumps+"\n")
// As whoever runs the test, against its own repository: no sudo.
// As whoever runs the test, against its own repository: no sudo. The test's directory lies in a temporary
// directory everyone may write, which a real restore is right to refuse beside; the directories above the
// test's own are said to be root's here, so the restore beside is what is tested.
run := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "stat" && len(args) > 0 && strings.HasPrefix(root, strings.TrimSuffix(args[len(args)-1], "/")+"/") {
return "0 755 directory\n", nil
}
out, err := exec.CommandContext(ctx, name, args...).Output()
if ee, ok := err.(*exec.ExitError); ok {
return string(out), errors.New(string(ee.Stderr))
@@ -294,9 +299,12 @@ func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) {
ran = append(ran, name+" "+strings.Join(args, " "))
if name == "stat" {
if args[len(args)-1] == "/home/agent" {
return "1001 directory\n", nil
return "1001 700 directory\n", nil
}
return "0 directory\n", nil
if args[len(args)-1] == "/srv/shared" {
return "0 1777 directory\n", nil
}
return "0 755 directory\n", nil
}
return "", nil
}
@@ -307,11 +315,14 @@ func TestARestoreBesideAnotherAccountsDirectoryGoesToTheState(t *testing.T) {
if why := b.besideIsUnsafe(context.Background(), "/srv/data.restored-x"); why != "" {
t.Fatalf("root's directories are safe beside: %q", why)
}
if why := b.besideIsUnsafe(context.Background(), "/srv/shared/data.restored-x"); !strings.Contains(why, "other accounts") {
t.Fatalf("a directory everyone may write is unsafe beside, sticky or not: %q", why)
}
run2 := func(ctx context.Context, name string, args ...string) (string, error) {
if name == "stat" && args[len(args)-1] == "/home" {
return "0 symbolic link\n", nil
return "0 777 symbolic link\n", nil
}
return "0 directory\n", nil
return "0 755 directory\n", nil
}
b.Run = run2
if why := b.besideIsUnsafe(context.Background(), "/home/agent/x"); !strings.Contains(why, "symbolic link") {