claude-code: remove from or restore to only an agent account's own home
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, merge-check-typescript.sh): main's version judged it; the change's judges the pull r…
mesh/delivery delivered

Removing a rule from the operator's own home changes what their sessions
obey as surely as placing one: an agent could remove the rule that
constrains it (hq issue 340).
This commit is contained in:
jochen
2026-10-09 09:51:18 +02:00
parent 3677af6079
commit d984eb84d6
5 changed files with 66 additions and 7 deletions
+3 -1
View File
@@ -127,7 +127,9 @@ The home scope is registered through the tools **only where the home belongs to
the home is the operator's own `~/.claude`, whose rules and skills load in every session the operator starts, and
the tool runner cannot tell the operator from an agent: refused there, and for any other node, whose home cannot be
judged from here (call that node's own tool). A home item on the bus is never placed in the operator's own home,
and what the module placed there before is taken back unless the person changed it. Where it is taken:
and what the module placed there before is taken back unless the person changed it. `claude_code_home_remove` and
`claude_code_home_restore` are refused the same way wherever the home is the operator's own: removing a rule changes
what a session obeys as surely as placing one. Where it is taken:
- for each kind — `skill`, `agent`, `command`, `output_style`, `instructions` — `claude_code_<kind>_list`,
`_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md);
@@ -199,6 +199,9 @@ type KeptFile struct {
// 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")
@@ -348,6 +351,9 @@ func removalFolder(p Paths, kept string) (string, error) {
// 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
@@ -61,7 +61,7 @@ func TestEveryKindIsShownInFull(t *testing.T) {
}
func TestARemovalNeedsItsReason(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "keep.md"), "x")
@@ -74,7 +74,7 @@ func TestARemovalNeedsItsReason(t *testing.T) {
}
func TestARemovalKeepsACopyAndLogsWhy(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
@@ -144,7 +144,7 @@ func TestARemovalKeepsACopyAndLogsWhy(t *testing.T) {
}
func TestTheMemoryIsRemovedAndKept(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# HAL era\n")
answer, err := RemoveHome(p, KindMemory, "", "the managed file says it now", at)
@@ -185,7 +185,7 @@ func TestWhatTheMeshPlacedIsRefused(t *testing.T) {
}
func TestASymbolicLinkIsLeftAlone(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
elsewhere := t.TempDir()
writeFile(t, filepath.Join(elsewhere, "target.md"), "not the home's")
@@ -210,7 +210,7 @@ func TestASymbolicLinkIsLeftAlone(t *testing.T) {
}
func TestARemovalIsListedAndPutBack(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
@@ -275,7 +275,7 @@ func TestARemovalIsListedAndPutBack(t *testing.T) {
}
func TestACopyThatChangedIsNotPutBack(t *testing.T) {
p, _ := node(t, "laptop")
p, _ := confinedNode(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "s"), 0o755)
@@ -78,6 +78,20 @@ func (p Paths) HomeIsTheAgentsOwn() bool {
return home == filepath.Clean(p.AgentHome) && (p.OperatorHome == "" || home != filepath.Clean(p.OperatorHome))
}
// HomeChangeRefused says why this node's home may not be changed through the tools, or "": removing or putting
// back an item of it changes what every session there obeys as surely as placing one — an agent could remove a
// rule that constrains it — so it is done only where the home belongs to an agent account of its own (novox/hq
// issue 340). The operator's own home is the operator's, changed by the person, not through a call.
func HomeChangeRefused(p Paths, what string) string {
if p.HomeIsTheAgentsOwn() {
return ""
}
return what + " on " + p.Node + " would change the operator's own ~/.claude, whose rules and skills every " +
"session the operator starts obeys, and the tool runner cannot tell the operator from an agent. It is done " +
"only where the agent runs as an account of its own and the home is that account's (novox/hq ADR 0266, " +
"issue 340); in the operator's own home it is the person's act, by hand. Nothing was changed"
}
// HomeScopeRefused says why the home scope is not written for a node, or "": only this node's own home, and only
// where it belongs to an agent account of its own (novox/hq issue 340). Another node's home cannot be judged
// from here; that node's own module judges it.
@@ -487,3 +487,40 @@ func TestAHomeItemOnTheBusIsNotPlacedInTheOperatorsHome(t *testing.T) {
t.Errorf("the render did not say it left the item out: %v", out)
}
}
// Removing an item of the operator's own home, or putting one back, changes what every session there obeys as
// surely as placing one: an agent could remove a rule that constrains it (novox/hq issue 340). Through the real
// tool dispatch, both are refused wherever the home is the operator's, and nothing in it changes; where the home
// is an agent account's own they are taken.
func TestTheOperatorsHomeIsNotChangedThroughTheTools(t *testing.T) {
p, _ := node(t, "laptop")
rule := filepath.Join(p.Home, ".claude", "rules", "constraint.md")
_ = os.MkdirAll(filepath.Dir(rule), 0o755)
writeFile(t, rule, "Never push to main.")
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isError := s.call(t, "claude_code_home_remove", map[string]any{"kind": "instructions", "name": "constraint",
"why": "the person asked"})
if !isError || !strings.Contains(text, "issue 340") || !strings.Contains(text, "Nothing was changed") {
t.Errorf("removing the operator's rule was not refused: %s", text)
}
if raw, err := os.ReadFile(rule); err != nil || string(raw) != "Never push to main." {
t.Fatalf("the operator's rule changed: %q %v", raw, err)
}
text, isError = s.call(t, "claude_code_home_restore", map[string]any{"kept": "2026-10-09T00:00:00Z"})
if !isError || !strings.Contains(text, "issue 340") {
t.Errorf("putting back in the operator's home was not refused: %s", text)
}
agent, _ := confinedNode(t, "anchor")
own := filepath.Join(agent.Home, ".claude", "rules", "old.md")
_ = os.MkdirAll(filepath.Dir(own), 0o755)
writeFile(t, own, "x")
a := serve(t, agent, &bus{kept: map[string]map[string]any{}}, memConfig{})
if text, isError := a.call(t, "claude_code_home_remove", map[string]any{"kind": "instructions", "name": "old",
"why": "the person asked"}); isError {
t.Fatalf("removing from the agent account's own home was refused: %s", text)
}
if _, err := os.Stat(own); err == nil {
t.Fatal("the agent's own rule was not removed")
}
}