Files
mesh-catalog/modules/claude-code/cmd/claude-code/home.go
T
jochen b57e6e8144 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.
2026-10-09 10:14:18 +02:00

449 lines
16 KiB
Go

package main
// The person's own items in the operator account's agent directory — the ones the mesh did not place (novox/hq
// ADR 0216 rule 6) — read and removed through the mesh rather than over a shell on the machine.
//
// Removing one stays the person's act: the remove tool is the act made explicit. It is called on the person's
// word, takes their reason, refuses anything the mesh placed (unregister owns those), keeps a copy outside the
// agent directory before it deletes, and logs what it removed and why. Nothing here removes on its own.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// KindMemory is the account's own instruction file, ~/.claude/CLAUDE.md: never placed by the mesh, read by
// every session of the account.
const KindMemory = "memory"
// memoryName is the one name the memory kind has.
const memoryName = "CLAUDE"
// RemovedDir is where, in the module's state, a removed item is kept: one dated folder per day, one folder
// per removal inside it.
const RemovedDir = "removed-from-home"
// homeKinds are the kinds the home tools take: those the status tool lists, and the memory.
var homeKinds = map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
KindOutputStyle: "output-styles", KindInstructions: "rules", KindMemory: ""}
func (p Paths) removedLog() string { return filepath.Join(p.State, RemovedDir, "removed.log") }
// homeKindOf reads a kind as a person may write it: output_style for output-style, rule for instructions.
func homeKindOf(kind string) string {
switch k := strings.ToLower(strings.TrimSpace(kind)); k {
case "output_style":
return KindOutputStyle
case "rule", "rules":
return KindInstructions
case "claude.md":
return KindMemory
default:
return k
}
}
// HomeItemPath is where one item of the home is, relative to the agent directory: a skill is its folder,
// every other kind one file.
func HomeItemPath(kind, name string) (rel string, folder bool, err error) {
kind = homeKindOf(kind)
sub, ok := homeKinds[kind]
if !ok {
return "", false, fmt.Errorf("kind is skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md), not %q", kind)
}
if kind == KindMemory {
if name != "" && name != memoryName && name != memoryName+".md" {
return "", false, fmt.Errorf("the memory is one file, CLAUDE.md; its name is %s or absent", memoryName)
}
return "CLAUDE.md", false, nil
}
name = strings.TrimSuffix(name, ".md")
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, ".") || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) {
return "", false, fmt.Errorf("%q is not an item's name: its folder, or its file without .md, as the status tool lists it", name)
}
if kind == KindSkill {
return sub + "/" + name, true, nil
}
return sub + "/" + name + ".md", false, nil
}
// placedUnder says whether the mesh placed the path, or anything inside it, and still holds it as its own: a
// path it placed that the person deleted and then made again is theirs (PlaceHome leaves it alone).
func placedUnder(p Paths, rel string) string {
var placed Placed
_ = readJSON(p.placed(), &placed)
for at, ours := range placed {
if ours == deletedByHand {
continue
}
if at == rel || strings.HasPrefix(at, rel+"/") {
return at
}
}
return ""
}
// readItem reads one item's files by path inside it, refusing a symbolic link anywhere on the way or in it:
// what it points at is not the home's.
func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.FileMode, error) {
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, nil, errors.New(why)
}
full := filepath.Join(dir, filepath.FromSlash(rel))
info, err := fsys.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, fmt.Errorf("%s is not in this home", full)
}
return nil, nil, err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, nil, fmt.Errorf("%s is a symbolic link", full)
}
files, modes := map[string]string{}, map[string]fs.FileMode{}
if !folder {
if !info.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%s is not a file", full)
}
raw, err := fsys.ReadFile(full)
if err != nil {
return nil, nil, err
}
files[filepath.Base(full)], modes[filepath.Base(full)] = string(raw), info.Mode().Perm()
return files, modes, nil
}
if !info.IsDir() {
return nil, nil, fmt.Errorf("%s is not a folder", full)
}
err = fsys.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("%s is a symbolic link", at)
}
if d.IsDir() {
return nil
}
if !d.Type().IsRegular() {
return fmt.Errorf("%s is not a file", at)
}
in, _ := filepath.Rel(full, at)
raw, err := fsys.ReadFile(at)
if err != nil {
return err
}
fi, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(in)], modes[filepath.ToSlash(in)] = string(raw), fi.Mode().Perm()
return nil
})
if err != nil {
return nil, nil, err
}
return files, modes, nil
}
// ShowHome answers one item of the home in full: where it is, whether the mesh placed it, and its files.
func ShowHome(p Paths, kind, name string) (map[string]any, error) {
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
files, _, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
answer := map[string]any{"kind": homeKindOf(kind), "path": filepath.Join(dir, filepath.FromSlash(rel)),
"placedByTheMesh": placedUnder(p, rel) != "", "files": files}
if !folder {
for _, content := range files {
answer["content"] = content
}
}
return answer, nil
}
// Removal is what the removed-items log keeps of one removal, and the copy's own note.
type Removal struct {
Action string `json:"action"`
At string `json:"at"`
Node string `json:"node"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why,omitempty"`
Kept string `json:"keptAt"`
Files map[string]KeptFile `json:"files"`
}
// KeptFile is one file of a kept copy as it was in the home: its digest, checked before it is put back, and
// its mode.
type KeptFile struct {
Digest string `json:"sha256"`
Mode string `json:"mode"`
}
// RemoveHome removes one item the person made in the home, on their word and for the reason given: it keeps
// a copy in the module's state first, under a dated folder, checks the copy, then deletes and logs. An item
// the mesh placed is refused — unregistering owns it. Answers where the copy is, so it can be put back.
func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, error) {
if refused := HomeChangeRefused(p, "removing an item of the home"); refused != "" {
return nil, errors.New(refused)
}
why = strings.TrimSpace(why)
if why == "" {
return nil, errors.New("why is required: the person's reason for removing it, kept in the log")
}
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
kind = homeKindOf(kind)
if kind == KindMemory {
name = memoryName
} else {
name = strings.TrimSuffix(name, ".md")
}
if at := placedUnder(p, rel); at != "" {
return nil, fmt.Errorf("the mesh placed %s: remove it with claude_code_%s_unregister at the home scope", at,
strings.ReplaceAll(kind, "-", "_"))
}
dir := filepath.Join(p.Home, ".claude")
files, modes, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
full := filepath.Join(dir, filepath.FromSlash(rel))
// The copy: <state>/removed-from-home/<date>/<time>-<kind>-<name>/<path as it was in the home>.
now = now.UTC()
day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02"))
kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name)
for n := 2; ; n++ {
if _, err := fsys.Lstat(kept); os.IsNotExist(err) {
break
}
kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n))
}
copyRoot := filepath.Join(kept, filepath.FromSlash(rel))
if !folder {
copyRoot = filepath.Dir(copyRoot)
}
names := map[string]KeptFile{}
for in, content := range files {
to := filepath.Join(copyRoot, filepath.FromSlash(in))
if err := fsys.MkdirAll(filepath.Dir(to), 0o700); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if err := fsys.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if back, err := fsys.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
}
names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
}
r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
note, _ := indented(r)
if err := fsys.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err)
}
// The item may have changed while it was copied: only what was copied is removed.
again, _, err := readItem(dir, rel, folder)
if err != nil || len(again) != len(files) {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
for in, content := range again {
if files[in] != content {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
}
if folder {
err = fsys.RemoveAll(full)
} else {
err = fsys.Remove(full)
}
if err != nil {
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
}
logged := logRemoval(p, r)
say("removed %s from the home on the person's word, kept at %s: %s", full, r.Kept, why)
answer := map[string]any{"removed": full, "kind": kind, "name": name, "why": why, "keptAt": r.Kept,
"undo": "copy " + r.Kept + " back to " + full, "log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the removal is noted in " + filepath.Join(kept, "removal.json")
}
return answer, nil
}
// logRemoval appends one line to the removed-items log, and answers whether it could.
func logRemoval(p Paths, r Removal) bool {
line, _ := json.Marshal(r)
f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return false
}
_, werr := f.Write(append(line, '\n'))
return f.Close() == nil && werr == nil
}
// ---- putting a removal back ---------------------------------------------------------------------------
// Kept is one removal whose copy the module keeps, as the list of removals shows it.
type Kept struct {
Removal
Restored string `json:"restored,omitempty"`
}
// KeptRemovals lists every removal the module keeps a copy of, newest first, and whether it was put back.
func KeptRemovals(p Paths) ([]Kept, error) {
root := filepath.Join(p.State, RemovedDir)
notes, _ := filepath.Glob(filepath.Join(root, "*", "*", "removal.json"))
out := []Kept{}
for _, note := range notes {
var k Kept
if !readJSON(note, &k.Removal) {
continue
}
var back Removal
if readJSON(filepath.Join(filepath.Dir(note), "restored.json"), &back) {
k.Restored = back.At
}
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if out[i].At != out[j].At {
return out[i].At > out[j].At
}
return out[i].Kept > out[j].Kept
})
return out, nil
}
// removalFolder finds the folder of one removal from what a removal answered as keptAt (or the folder
// itself): <state>/removed-from-home/<date>/<removal>, and nothing outside it.
func removalFolder(p Paths, kept string) (string, error) {
root := filepath.Join(p.State, RemovedDir)
rel, err := filepath.Rel(root, filepath.Clean(kept))
parts := strings.Split(filepath.ToSlash(rel), "/")
if err != nil || kept == "" || len(parts) < 2 || parts[0] == ".." || parts[0] == "." {
return "", fmt.Errorf("%q is not a copy this module kept: give the keptAt a removal answered, as claude_code_home_removed lists it", kept)
}
return filepath.Join(root, parts[0], parts[1]), nil
}
// RestoreHome puts a removed item back where it was: only when nothing is at that path now, and only when the
// kept copy is exactly what was removed, file by file against the digests its note recorded. Logged as the
// removal was; the copy stays, marked as put back.
func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
if refused := HomeChangeRefused(p, "putting an item back in the home"); refused != "" {
return nil, errors.New(refused)
}
folder, err := removalFolder(p, kept)
if err != nil {
return nil, err
}
var r Removal
if !readJSON(filepath.Join(folder, "removal.json"), &r) {
return nil, fmt.Errorf("%s holds no readable removal.json: nothing to check the copy against, nothing restored", folder)
}
// Where it goes is worked out again from its kind and name, never taken from the note alone.
rel, isFolder, err := HomeItemPath(r.Kind, r.Name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
full := filepath.Join(dir, filepath.FromSlash(rel))
if r.Path != full || r.Kept != filepath.Join(folder, filepath.FromSlash(rel)) {
return nil, fmt.Errorf("%s/removal.json does not describe the copy beside it, nothing restored", folder)
}
// The copy's own integrity: the same files, each with the digest recorded when it was removed.
files, _, err := readItem(folder, rel, isFolder)
if err != nil {
return nil, fmt.Errorf("the kept copy cannot be read, nothing restored: %w", err)
}
if len(files) != len(r.Files) {
return nil, fmt.Errorf("the kept copy holds %d file(s), its note %d: nothing restored", len(files), len(r.Files))
}
for in, content := range files {
want, ok := r.Files[in]
if !ok || digest(content) != want.Digest {
return nil, fmt.Errorf("the kept copy's %s is not what was removed: nothing restored", in)
}
}
// Nothing may be in the way: whatever is there now is the person's, or the mesh's.
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, errors.New(why + ", nothing restored")
}
if _, err := fsys.Lstat(full); err == nil {
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
} else if !os.IsNotExist(err) {
return nil, err
}
base := full
if !isFolder {
base = filepath.Dir(full)
}
written := []string{}
undo := func() {
for _, w := range written {
_ = fsys.Remove(w)
}
if isFolder {
_ = fsys.RemoveAll(full)
}
}
for in, content := range files {
to := filepath.Join(base, filepath.FromSlash(in))
if !isFolder {
to = full
}
mode := os.FileMode(0o644)
var m uint32
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
mode = os.FileMode(m).Perm()
}
if err := fsys.MkdirAll(filepath.Dir(to), 0o755); err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
}
if err := fsys.CreateExclusive(to, []byte(content), mode); err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", to, err)
}
_ = fsys.Chmod(to, mode) // the umask may have taken bits the file had
written = append(written, to)
}
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
note, _ := indented(back)
_ = fsys.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
logged := logRemoval(p, back)
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
"log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the restore is noted in " + filepath.Join(folder, "restored.json")
}
return answer, nil
}