From d984eb84d6d5b87bb2f6b923504fd5d40d91798a Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 09:51:18 +0200 Subject: [PATCH] claude-code: remove from or restore to only an agent account's own home 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). --- modules/claude-code/README.md | 4 +- modules/claude-code/cmd/claude-code/home.go | 6 +++ .../claude-code/cmd/claude-code/home_test.go | 12 +++--- modules/claude-code/cmd/claude-code/node.go | 14 +++++++ .../cmd/claude-code/terminal_only_test.go | 37 +++++++++++++++++++ 5 files changed, 66 insertions(+), 7 deletions(-) diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index 4ae1f51e..cbdde015 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -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__list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md); diff --git a/modules/claude-code/cmd/claude-code/home.go b/modules/claude-code/cmd/claude-code/home.go index 4a80b74a..94576679 100644 --- a/modules/claude-code/cmd/claude-code/home.go +++ b/modules/claude-code/cmd/claude-code/home.go @@ -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 diff --git a/modules/claude-code/cmd/claude-code/home_test.go b/modules/claude-code/cmd/claude-code/home_test.go index 45644796..623cf9bc 100644 --- a/modules/claude-code/cmd/claude-code/home_test.go +++ b/modules/claude-code/cmd/claude-code/home_test.go @@ -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) diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 082962f7..f17c9430 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -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. diff --git a/modules/claude-code/cmd/claude-code/terminal_only_test.go b/modules/claude-code/cmd/claude-code/terminal_only_test.go index 243a9371..2445d5a0 100644 --- a/modules/claude-code/cmd/claude-code/terminal_only_test.go +++ b/modules/claude-code/cmd/claude-code/terminal_only_test.go @@ -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") + } +}