claude-code: act on the review of the nox-mesh plugin
Refuse paths with empty, dot or parent segments and a file that is also a directory; take an item only when its key says what it is; write the view under its lock; expand nodes all and check node names; merge the plugin's entries into the operator's own; render every file past one that fails; in the home, never take over the person's file, never write through a symbolic link, keep a deleted file deleted, keep the kind's directory; and refuse settings that would deny the console or the marketplace.
This commit is contained in:
@@ -29,8 +29,9 @@ whenever the node's tool runtime collects the module's tools:
|
||||
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
||||
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
||||
command, output style, or instructions as a rule file — each path recorded in the module's state
|
||||
(`home-placed.json`). It writes, changes and removes only those, never a path the person made, and leaves a
|
||||
placed file alone once it was changed by hand (hq ADR 0182). The status tool reads the rest of the home's
|
||||
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
|
||||
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
||||
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||
items to report them; nothing else is read or written.
|
||||
|
||||
## Over NATS
|
||||
@@ -56,7 +57,8 @@ manager), `claude_code_mcp_list`,
|
||||
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
|
||||
|
||||
The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node`
|
||||
(`nodes`, or this node) or `home` (the operator account's own `~/.claude` on `nodes`, or this node):
|
||||
(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
|
||||
operator account's own `~/.claude` on those nodes):
|
||||
|
||||
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
|
||||
`claude_code_<kind>_list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a
|
||||
|
||||
@@ -69,6 +69,14 @@ const SettingsName = "settings"
|
||||
|
||||
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
// nodeName is what a node may be called in a key.
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
|
||||
// the mesh's console or its marketplace by another way.
|
||||
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
|
||||
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
|
||||
|
||||
// hookEvents are the events a hook may be registered for.
|
||||
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
|
||||
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
|
||||
@@ -139,9 +147,9 @@ func (it Item) Problem() string {
|
||||
if len(it.Settings) == 0 {
|
||||
return "no settings given"
|
||||
}
|
||||
for _, key := range []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins"} {
|
||||
for _, key := range meshOwnedKeys {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys; a registration cannot set it", key)
|
||||
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
|
||||
}
|
||||
}
|
||||
} else if !itemName.MatchString(it.Name) {
|
||||
@@ -159,8 +167,13 @@ func (it Item) Problem() string {
|
||||
}
|
||||
}
|
||||
for rel := range it.Files {
|
||||
if rel == "" || path.IsAbs(rel) || strings.Contains(rel, "\\") || path.Clean(rel) != rel || strings.HasPrefix(rel, "..") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
if problem := pathProblem(rel); problem != "" {
|
||||
return problem
|
||||
}
|
||||
for other := range it.Files {
|
||||
if strings.HasPrefix(other, rel+"/") {
|
||||
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch it.Kind {
|
||||
@@ -179,6 +192,20 @@ func (it Item) Problem() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||
// segment a real name — never empty, `.` or `..`.
|
||||
func pathProblem(rel string) string {
|
||||
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return fmt.Sprintf("%q is not a path inside the item", rel)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (it Item) size() int {
|
||||
raw, _ := json.Marshal(it)
|
||||
return len(raw)
|
||||
@@ -213,14 +240,17 @@ func (v *ConfigView) Take(key, op string, item *Item) bool {
|
||||
if !v.Applies(key) {
|
||||
return false
|
||||
}
|
||||
_, node, _, _, _ := ParseKey(key)
|
||||
v.mu.Lock()
|
||||
if op == "put" && item != nil && item.Problem() == "" {
|
||||
defer v.mu.Unlock()
|
||||
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
|
||||
// lands, and the two must agree — a mesh key holding a home item would land in every home.
|
||||
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
|
||||
v.items[key] = *item
|
||||
} else {
|
||||
delete(v.items, key)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
return v.writeThrough()
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Prune drops what the view holds and the state no longer does: a registration removed while this node
|
||||
@@ -231,13 +261,13 @@ func (v *ConfigView) Prune(present []string) bool {
|
||||
keep[k] = true
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
for k := range v.items {
|
||||
if !keep[k] {
|
||||
delete(v.items, k)
|
||||
}
|
||||
}
|
||||
v.mu.Unlock()
|
||||
return v.writeThrough()
|
||||
return v.writeThroughLocked()
|
||||
}
|
||||
|
||||
// Items is what applies here, by key.
|
||||
@@ -251,8 +281,10 @@ func (v *ConfigView) Items() map[string]Item {
|
||||
return out
|
||||
}
|
||||
|
||||
func (v *ConfigView) writeThrough() bool {
|
||||
now, _ := indented(v.Items())
|
||||
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
|
||||
// an older snapshot never lands after a newer one.
|
||||
func (v *ConfigView) writeThroughLocked() bool {
|
||||
now, _ := indented(v.items)
|
||||
before, _ := os.ReadFile(v.p.config())
|
||||
if string(before) == string(now) {
|
||||
return false
|
||||
@@ -481,9 +513,15 @@ func digest(s string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants: writes what is wanted and absent or
|
||||
// its own, never a path the person made, and removes what it placed and is no longer wanted — unless the
|
||||
// person changed it since. Answers what it did and what it left alone, and why.
|
||||
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
|
||||
// kept until the item is unregistered.
|
||||
const deletedByHand = "deleted-by-hand"
|
||||
|
||||
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
|
||||
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
|
||||
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
|
||||
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
|
||||
// it left alone, and why.
|
||||
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
var placed Placed
|
||||
@@ -498,22 +536,30 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
for _, rel := range paths {
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
content := want[rel]
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
exists := err == nil
|
||||
ours, wasPlaced := placed[rel]
|
||||
switch {
|
||||
case !exists && wasPlaced && ours == deletedByHand:
|
||||
continue
|
||||
case !exists && wasPlaced:
|
||||
placed[rel] = deletedByHand
|
||||
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
|
||||
continue
|
||||
case exists && !wasPlaced:
|
||||
if string(current) == content {
|
||||
placed[rel] = digest(content) // the same bytes: taken over, nothing changes
|
||||
continue
|
||||
}
|
||||
left = append(left, rel+": the person's own, left alone")
|
||||
continue
|
||||
case exists && wasPlaced && digest(string(current)) != ours && digest(string(current)) != digest(content):
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
case exists && ours == deletedByHand:
|
||||
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
|
||||
continue
|
||||
case exists && string(current) == content:
|
||||
placed[rel] = digest(content)
|
||||
continue
|
||||
case exists && digest(string(current)) != ours:
|
||||
left = append(left, rel+": changed by hand since it was placed, left alone")
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
@@ -536,22 +582,50 @@ func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||
if ours == deletedByHand {
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
if why := linkedParent(dir, rel); why != "" {
|
||||
left = append(left, rel+": no longer registered, but "+why+", left alone")
|
||||
continue
|
||||
}
|
||||
current, err := os.ReadFile(full)
|
||||
if err == nil && digest(string(current)) != ours {
|
||||
left = append(left, rel+": no longer registered, but changed by hand, left alone")
|
||||
delete(placed, rel)
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(full)
|
||||
removeEmptyParents(dir, filepath.Dir(full))
|
||||
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
|
||||
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
|
||||
continue
|
||||
}
|
||||
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
|
||||
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
|
||||
delete(placed, rel)
|
||||
done = append(done, rel+": removed")
|
||||
}
|
||||
raw, _ := indented(placed)
|
||||
_ = os.WriteFile(p.placed(), raw, 0o600)
|
||||
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
|
||||
left = append(left, "the record of what was placed could not be saved: "+err.Error())
|
||||
}
|
||||
return done, left
|
||||
}
|
||||
|
||||
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
|
||||
// writing through it would write wherever it points.
|
||||
func linkedParent(dir, rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
at := dir
|
||||
for _, seg := range parts[:len(parts)-1] {
|
||||
at = filepath.Join(at, seg)
|
||||
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return at + " is a symbolic link"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// removeEmptyParents removes empty directories from dir up to, not including, root.
|
||||
func removeEmptyParents(root, dir string) {
|
||||
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
@@ -714,6 +788,8 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
|
||||
}
|
||||
if !unregister && it.Scope == ScopeHome {
|
||||
for _, n := range nodes {
|
||||
@@ -761,10 +837,10 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
}
|
||||
if changed {
|
||||
rendered, err := RenderNow(p, write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["rendered here"] = rendered
|
||||
if err != nil {
|
||||
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||
}
|
||||
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||
} else if v.Applies(keys[0]) || len(keys) > 1 {
|
||||
answer["here"] = "already so"
|
||||
@@ -774,6 +850,35 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func verbOf(unregister bool) string {
|
||||
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
}
|
||||
|
||||
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
|
||||
func nodesProblem(nodes []string) string {
|
||||
for _, n := range nodes {
|
||||
if !nodeName.MatchString(n) {
|
||||
return fmt.Sprintf("%q is not a node's name", n)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExpandNodes turns `all` into every node running the module; anything else is kept.
|
||||
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
|
||||
if len(nodes) != 1 || nodes[0] != "all" {
|
||||
return nodes, nil
|
||||
}
|
||||
all, err := running()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, fmt.Errorf("no node is known to run claude-code")
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
|
||||
func List(state ConfigState, kind string) (map[string]any, error) {
|
||||
keys, err := state.Keys()
|
||||
|
||||
@@ -339,3 +339,119 @@ func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
|
||||
t.Fatalf("after pruning: %v", back.Items())
|
||||
}
|
||||
}
|
||||
|
||||
// The review's findings, each held by a test.
|
||||
|
||||
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
|
||||
for label, files := range map[string]map[string]string{
|
||||
"a dot": {"SKILL.md": "x", ".": "x"},
|
||||
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
|
||||
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
|
||||
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
|
||||
} {
|
||||
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
|
||||
t.Errorf("%s was accepted", label)
|
||||
}
|
||||
}
|
||||
p, _ := node(t, "laptop")
|
||||
v := NewConfigView(p)
|
||||
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
|
||||
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
|
||||
t.Fatal("a mesh key holding a home item was taken")
|
||||
}
|
||||
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
|
||||
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
|
||||
v.Take(key, "put", &mesh)
|
||||
}
|
||||
if len(v.Items()) != 1 {
|
||||
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
|
||||
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
|
||||
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
p, w := node(t, "laptop")
|
||||
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
|
||||
if answer["registered"] != false {
|
||||
t.Fatalf("a node name with a dot was used in a key: %v", answer)
|
||||
}
|
||||
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
|
||||
t.Fatal("a registration could deny the mesh's console")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
|
||||
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
|
||||
"enabledPlugins": map[string]any{"theirs@market": true},
|
||||
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
|
||||
}}, nil, "/h", nil, Config{})
|
||||
var managed struct {
|
||||
Enabled map[string]any `json:"enabledPlugins"`
|
||||
Known map[string]any `json:"extraKnownMarketplaces"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
|
||||
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
|
||||
t.Fatalf("%+v", managed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
dir := filepath.Join(p.Home, ".claude")
|
||||
// An identical file the person made is not taken over, so unregistering never removes it.
|
||||
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
|
||||
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
|
||||
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
|
||||
t.Fatalf("an identical file of the person's was taken over: %v", left)
|
||||
}
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
|
||||
t.Fatal("the person's file was removed")
|
||||
}
|
||||
// A placed file the person deleted stays deleted while it is registered.
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
|
||||
PlaceHome(p, map[string]string{"commands/c.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
|
||||
t.Fatal("a file the person deleted was placed again")
|
||||
}
|
||||
// The kind's own directory stays when the mesh's last item in it goes.
|
||||
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
|
||||
PlaceHome(p, map[string]string{})
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
|
||||
t.Fatal("the item's own directory was left")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
|
||||
t.Fatal("the kind's directory was removed")
|
||||
}
|
||||
// Never through a symbolic link.
|
||||
elsewhere := t.TempDir()
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
|
||||
t.Skip("no symbolic links here")
|
||||
}
|
||||
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
|
||||
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
|
||||
t.Fatal("a file was written through a symbolic link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
w := map[string]string{}
|
||||
failing := func(name, content string) (string, error) {
|
||||
if name == MarketplaceDir+"/" {
|
||||
return "", os.ErrPermission
|
||||
}
|
||||
w[name] = content
|
||||
return name + ": written", nil
|
||||
}
|
||||
if _, err := RenderNow(p, failing); err == nil {
|
||||
t.Fatal("the failure was not reported")
|
||||
}
|
||||
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
|
||||
t.Fatalf("the other files were not written: %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ func strArg(a map[string]any, k string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
|
||||
func targetNodes(a map[string]any) ([]string, error) {
|
||||
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
|
||||
}
|
||||
|
||||
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
|
||||
func scopeOf(a map[string]any) string {
|
||||
if s := strArg(a, "scope"); s != "" {
|
||||
@@ -76,7 +81,7 @@ func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
|
||||
|
||||
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
|
||||
nodesArg := str(`for the node and home scopes: a comma-separated list of nodes; absent is this node`)
|
||||
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
|
||||
var out []stdio.Tool
|
||||
for _, k := range kindTools {
|
||||
k := k
|
||||
@@ -112,14 +117,22 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||
return Register(p, it, nodesOf(a["nodes"]), false, state, view, writeManaged)
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
|
||||
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
|
||||
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
|
||||
return Register(p, it, nodesOf(a["nodes"]), true, state, view, writeManaged)
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
}
|
||||
@@ -160,7 +173,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
if len(given) == 0 {
|
||||
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any {
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
if boolArg(a, "replace") {
|
||||
return given
|
||||
}
|
||||
@@ -172,7 +189,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
|
||||
return Register(p, it, nodesOf(a["nodes"]), true, state, view, writeManaged)
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, true, state, view, writeManaged)
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_add",
|
||||
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
|
||||
@@ -182,7 +203,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any {
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
|
||||
}, state, view)
|
||||
}},
|
||||
@@ -191,7 +216,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
list, rule := strArg(a, "list"), strArg(a, "rule")
|
||||
return SetSettings(p, scopeOf(a), nodesOf(a["nodes"]), func(held map[string]any) map[string]any {
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
|
||||
perms, _ := held["permissions"].(map[string]any)
|
||||
rules, _ := perms[list].([]any)
|
||||
if len(rules) == 0 {
|
||||
@@ -266,7 +295,11 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
if it.Scope == ScopeHome {
|
||||
return nil, errors.New("it is already at home; import it to the mesh or node scope")
|
||||
}
|
||||
return Register(p, it, nodesOf(a["nodes"]), false, state, view, writeManaged)
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Register(p, it, nodes, false, state, view, writeManaged)
|
||||
}},
|
||||
)
|
||||
return out
|
||||
@@ -283,6 +316,8 @@ func SetSettings(p Paths, scope string, nodes []string, change func(held map[str
|
||||
nodes = []string{""}
|
||||
} else if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
} else if problem := nodesProblem(nodes); problem != "" {
|
||||
return map[string]any{"set": false, "reason": problem}, nil
|
||||
}
|
||||
answers := map[string]any{}
|
||||
for _, n := range nodes {
|
||||
|
||||
@@ -141,11 +141,15 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
|
||||
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
|
||||
var out []string
|
||||
var failed []error
|
||||
for _, n := range names {
|
||||
line, err := write(n, files[n])
|
||||
if err != nil {
|
||||
return out, err
|
||||
failed = append(failed, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
@@ -157,7 +161,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
||||
for _, line := range left {
|
||||
out = append(out, "home "+line)
|
||||
}
|
||||
return out, nil
|
||||
return out, errors.Join(failed...)
|
||||
}
|
||||
|
||||
// ---- the licence ----------------------------------------------------------------------------------
|
||||
|
||||
@@ -118,8 +118,19 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
|
||||
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
|
||||
managed["allowAllClaudeAiMcps"] = true
|
||||
delete(managed, "apiKeyHelper")
|
||||
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
|
||||
// entry winning: the operator may know more marketplaces and enable more plugins.
|
||||
for key, value := range MarketplaceKeys() {
|
||||
managed[key] = value
|
||||
entries := map[string]any{}
|
||||
if held, ok := managed[key].(map[string]any); ok {
|
||||
for k, v := range held {
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range value.(map[string]any) {
|
||||
entries[k] = v
|
||||
}
|
||||
managed[key] = entries
|
||||
}
|
||||
if binding != nil && binding.Kind == "api-key" {
|
||||
managed["apiKeyHelper"] = helperPath
|
||||
|
||||
Reference in New Issue
Block a user