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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user