claude-code: refuse registering what a session runs, so no agent can plant it in the operator's sessions
The tool runner is not told who called, so a hook, a managed setting, a permission rule or a tool server registered through these tools could come from an agent confined on another node and run as the operator there (hq issue 340). They are set at the controller's terminal now; a skill, subagent or command that runs something by itself is refused too, kept hooks and settings are never rendered, and claude_code_registrations lists what is registered and by which node.
This commit is contained in:
@@ -5,7 +5,6 @@ package main
|
||||
// for what the module did not place.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -28,8 +27,8 @@ var kindTools = []kindTool{
|
||||
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
|
||||
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
|
||||
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
|
||||
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
|
||||
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
|
||||
{KindHook, "hook", "a hook — refused (novox/hq issue 340): a hook runs its command in every session it reaches, so it is the operator's, in this module's managed_settings setting at the controller's terminal",
|
||||
"no file any more (novox/hq issue 340): a hook registered before is listed and may be unregistered, and is never rendered"},
|
||||
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
|
||||
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
|
||||
{KindInstructions, "instructions", "a section of instructions every session reads",
|
||||
@@ -96,10 +95,7 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
input["content"] = str("the SKILL.md, when the skill is that one file")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
|
||||
case KindHook:
|
||||
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
|
||||
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
|
||||
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
|
||||
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
|
||||
// Refused whatever is given (novox/hq issue 340): no arguments but the name and scope are described.
|
||||
default:
|
||||
input["content"] = str("the file's content, front matter included")
|
||||
}
|
||||
@@ -108,16 +104,21 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
|
||||
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
|
||||
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
|
||||
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
|
||||
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, at home where the person already has one of that name, and whenever it would run something by itself in a session: a shell line (!`…` or ```!), or hooks, mcpServers, permissionMode or allowed-tools in its front matter (novox/hq issue 340).",
|
||||
Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
if k.kind == KindHook {
|
||||
return nil, errors.New(RunsInASession(KindHook) + ". Nothing was registered")
|
||||
}
|
||||
name := strArg(a, "name")
|
||||
files, err := filesOf(k.kind, name, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
|
||||
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
|
||||
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files}
|
||||
if problem := runsProblem(it); problem != "" {
|
||||
return nil, errors.New(problem + ". Nothing was registered")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -141,52 +142,32 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
|
||||
out = append(out,
|
||||
stdio.Tool{Name: "claude_code_settings_get",
|
||||
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
|
||||
Description: "Claude Code's managed settings on this node as written, and where they came from: the operator's `managed_settings` setting, set at the controller's terminal (ADR 0213, novox/hq issue 340), then the mesh's own keys, which always win. Settings registered through this module before registering was refused are listed, and not rendered.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
written := map[string]any{}
|
||||
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
|
||||
var settings Settings
|
||||
_ = readJSON(p.Settings, &settings)
|
||||
c := ConfigOf(view.Items())
|
||||
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
|
||||
for _, it := range c.Mesh {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for the mesh"] = it.Settings
|
||||
}
|
||||
}
|
||||
for _, it := range c.Node {
|
||||
if it.Kind == KindSettings {
|
||||
layers["registered for this node"] = it.Settings
|
||||
}
|
||||
}
|
||||
return map[string]any{"written": written, "layers": layers,
|
||||
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_set",
|
||||
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
|
||||
Input: map[string]any{
|
||||
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
|
||||
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
|
||||
"scope": settingsScope, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
given, _ := a["settings"].(map[string]any)
|
||||
if len(given) == 0 {
|
||||
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
left, err := List(state, KindSettings)
|
||||
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
|
||||
}
|
||||
return mergeSettings(held, given)
|
||||
}, state, view)
|
||||
return map[string]any{"written": written,
|
||||
"managed_settings setting": settings.ManagedSettings,
|
||||
"order": "the managed_settings setting, then the mesh's own keys",
|
||||
"registered before, not rendered": left}, nil
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_set",
|
||||
Description: "Refused, and says why (novox/hq issue 340): the managed settings say what runs without asking in every session they reach — permissions, hooks, the status line, the environment — and this tool cannot tell an agent from the operator. They are the operator's, in this module's managed_settings setting at the controller's terminal.",
|
||||
Input: map[string]any{
|
||||
"settings": map[string]any{"type": "object", "description": "settings keys"},
|
||||
"scope": settingsScope, "nodes": nodesArg,
|
||||
},
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return nil, errors.New(RunsInASession(KindSettings) + ". Nothing was registered")
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_settings_clear",
|
||||
Description: "Remove the Claude Code settings registered at a scope.",
|
||||
Description: "Remove settings registered at a scope before registering them was refused (novox/hq issue 340). They are not rendered any more; this removes what is left of them on the bus.",
|
||||
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)}
|
||||
@@ -197,48 +178,18 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
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.",
|
||||
Description: "Refused, and says why (novox/hq issue 340): a permission rule says what runs without asking in every session it reaches, and this tool cannot tell an agent from the operator. Permission rules are the operator's, under `permissions` in this module's managed_settings setting at the controller's terminal.",
|
||||
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")
|
||||
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
|
||||
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
|
||||
}
|
||||
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)
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return nil, errors.New(RunsInASession(KindSettings) + ". Nothing was registered")
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_permission_remove",
|
||||
Description: "Remove a permission rule added through this module, at its scope.",
|
||||
Description: "Refused, and says why (novox/hq issue 340): permission rules are the operator's, in this module's managed_settings setting at the controller's terminal, and rules registered through this module before are not rendered; claude_code_settings_clear removes what is left of them.",
|
||||
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")
|
||||
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 {
|
||||
return held // nothing to remove: what the scope holds stays as it is
|
||||
}
|
||||
kept := []any{}
|
||||
for _, r := range rules {
|
||||
if r != rule {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
next := mergeSettings(map[string]any{}, held)
|
||||
np := mergeSettings(map[string]any{}, perms)
|
||||
np[list] = kept
|
||||
next["permissions"] = np
|
||||
return next
|
||||
}, state, view)
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return nil, errors.New("permission rules are set and removed at the controller's terminal only, in this module's " +
|
||||
"managed_settings setting: " + AtTheTerminal + ". Rules registered through this module before are not " +
|
||||
"rendered; claude_code_settings_clear removes what is left of them (novox/hq issue 340). Nothing was changed")
|
||||
}},
|
||||
stdio.Tool{Name: "claude_code_config_list",
|
||||
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
|
||||
@@ -296,6 +247,9 @@ 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")
|
||||
}
|
||||
if problem := runsProblem(it); problem != "" {
|
||||
return nil, errors.New(problem + ". Nothing was registered")
|
||||
}
|
||||
nodes, err := targetNodes(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -339,50 +293,3 @@ func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
|
||||
// the key holds. An empty result removes the key.
|
||||
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
|
||||
state ConfigState, view *ConfigView) (map[string]any, error) {
|
||||
if scope != ScopeMesh && scope != ScopeNode {
|
||||
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
|
||||
}
|
||||
if scope == ScopeMesh {
|
||||
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 {
|
||||
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
|
||||
held := map[string]any{}
|
||||
if raw, found, err := state.Get(it.Key(n)); err != nil {
|
||||
return nil, err
|
||||
} else if found {
|
||||
var was Item
|
||||
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
|
||||
held = was.Settings
|
||||
}
|
||||
}
|
||||
it.Settings = change(held)
|
||||
target := []string(nil)
|
||||
if n != "" {
|
||||
target = []string{n}
|
||||
}
|
||||
var answer map[string]any
|
||||
var err error
|
||||
if len(it.Settings) == 0 {
|
||||
answer, err = Register(p, it, target, true, state, view, writeManaged)
|
||||
} else {
|
||||
answer, err = Register(p, it, target, false, state, view, writeManaged)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer["settings"] = it.Settings
|
||||
answers[it.Key(n)] = answer
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user