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:
@@ -11,10 +11,19 @@ package main
|
||||
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
|
||||
// the one place the vendor honours for it:
|
||||
//
|
||||
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
|
||||
// (at the home scope: the home's own directories)
|
||||
// instructions sections of the managed instruction file (home: a rule file)
|
||||
// settings the managed settings file, mesh then node, under the mesh's keys
|
||||
// skill, agent, command, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
|
||||
// (at the home scope: the home's own directories)
|
||||
// instructions sections of the managed instruction file (home: a rule file)
|
||||
//
|
||||
// **What a session runs or obeys is not registered here** (novox/hq issue 340). A hook runs a command, and the
|
||||
// managed settings say what runs without asking, in every session on every node the registration reaches, as
|
||||
// whoever starts it — the operator, whose account holds root. The tool runner acts for any caller of the mesh MCP
|
||||
// server and is not told who called, so a registration through these tools could come from an agent confined on
|
||||
// another node. Hooks, permissions and the rest of the managed settings are the operator's, set at the
|
||||
// controller's terminal in this module's `managed_settings` setting (the controller refuses that key through a
|
||||
// verb); the kinds `hook` and `settings` are refused at registration and never taken from the state, and an
|
||||
// item that would run something itself — a skill's or a command's shell lines, a hook, a server or a mode in its
|
||||
// front matter, tools it pre-approves — is refused too. What may still be registered is text a session reads.
|
||||
//
|
||||
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
|
||||
// would block a plugin's.
|
||||
@@ -72,14 +81,91 @@ 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"}
|
||||
// AtTheTerminal is how a hook, a permission rule or any other managed setting is set now (novox/hq issue 340):
|
||||
// in this module's `managed_settings` setting, at the controller's terminal, which refuses it through a verb.
|
||||
const AtTheTerminal = "mesh-controller settings set claude-code '{\"managed_settings\": {...}}' [--node <node>], " +
|
||||
"typed by the operator at the controller's terminal"
|
||||
|
||||
// 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}
|
||||
// RunsInASession says why a kind is never registered through this module: what it holds runs in, or is obeyed
|
||||
// by, every session it reaches, as whoever starts that session (novox/hq issue 340).
|
||||
func RunsInASession(kind string) string {
|
||||
what := map[string]string{
|
||||
KindHook: "a hook runs its command in every session it reaches",
|
||||
KindSettings: "the managed settings say what runs in every session without asking — permissions, hooks, the status line, the environment",
|
||||
}[kind]
|
||||
return what + ", as whoever starts that session — the operator, whose account holds root — and the tool runner is " +
|
||||
"not told who called it, so an agent on another node could register one. It is set at the controller's " +
|
||||
"terminal only: " + AtTheTerminal + " (novox/hq issue 340)"
|
||||
}
|
||||
|
||||
// runsFrontMatter are the front matter keys of a skill, a subagent or a command that make it run something
|
||||
// itself or loosen what runs (novox/hq issue 340): a hook, a tool server, a permission mode, tools it
|
||||
// pre-approves. Compared lower-case with - and _ taken out.
|
||||
var runsFrontMatter = map[string]string{
|
||||
"hooks": "hooks run a command when the item is used",
|
||||
"mcpservers": "a tool server starts a program",
|
||||
"permissionmode": "a permission mode changes what runs without asking",
|
||||
"allowedtools": "allowed-tools lets the tools it names run without asking",
|
||||
}
|
||||
|
||||
// shellLine is a skill's or a command's shell line, run when it is loaded and its output put in the prompt:
|
||||
// !`command` inline, or a fenced block opened with ```!.
|
||||
var shellLine = regexp.MustCompile("!`[^`\n]+`|(?m)^[ \t]*```!")
|
||||
|
||||
// runsProblem says why an item's text would run something in a session by itself, or "": the shell lines the
|
||||
// agent runs when it loads a skill or a command, and the front matter keys that start, run or pre-approve.
|
||||
func runsProblem(it Item) string {
|
||||
if it.Kind != KindSkill && it.Kind != KindAgent && it.Kind != KindCommand {
|
||||
return ""
|
||||
}
|
||||
rels := make([]string, 0, len(it.Files))
|
||||
for rel := range it.Files {
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
sort.Strings(rels)
|
||||
for _, rel := range rels {
|
||||
if !strings.HasSuffix(rel, ".md") {
|
||||
continue // a script beside a skill runs only when the agent runs it, through the session's own rules
|
||||
}
|
||||
content := it.Files[rel]
|
||||
if shellLine.MatchString(content) {
|
||||
return fmt.Sprintf("%s holds a shell line (!`…` or ```!), which the agent runs when it loads the %s, "+
|
||||
"before any permission is asked; nothing registered through this module runs by itself in a "+
|
||||
"session (novox/hq issue 340)", rel, it.Kind)
|
||||
}
|
||||
for _, key := range frontMatterKeys(content) {
|
||||
norm := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(key))
|
||||
if why, ok := runsFrontMatter[norm]; ok {
|
||||
return fmt.Sprintf("%s says %q in its front matter: %s. Nothing registered through this module "+
|
||||
"runs or loosens what runs in a session; that is the operator's, at the controller's "+
|
||||
"terminal (novox/hq issue 340)", rel, key, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// frontMatterKeys are the top-level keys of a markdown file's front matter: the lines between a first line
|
||||
// `---` and the next, that start a key at the left margin.
|
||||
func frontMatterKeys(content string) []string {
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
for _, line := range lines[1:] {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
break
|
||||
}
|
||||
if line == "" || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-' {
|
||||
continue
|
||||
}
|
||||
if i := strings.Index(line, ":"); i > 0 {
|
||||
keys = append(keys, strings.Trim(strings.TrimSpace(line[:i]), `"'`))
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Item is one registration, as the state keeps it.
|
||||
type Item struct {
|
||||
@@ -137,34 +223,11 @@ func (it Item) Problem() string {
|
||||
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
|
||||
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
|
||||
}
|
||||
if it.Kind == KindSettings {
|
||||
if it.Scope == ScopeHome {
|
||||
return "settings take the mesh and node scopes only: the home's settings file is the person's"
|
||||
}
|
||||
if it.Name != SettingsName {
|
||||
return "a scope holds one set of settings, named " + SettingsName
|
||||
}
|
||||
if len(it.Settings) == 0 {
|
||||
return "no settings given"
|
||||
}
|
||||
for _, key := range meshOwnedKeys {
|
||||
if _, ok := it.Settings[key]; ok {
|
||||
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) {
|
||||
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||
if it.Kind == KindHook || it.Kind == KindSettings {
|
||||
return RunsInASession(it.Kind)
|
||||
}
|
||||
if it.Kind == KindHook {
|
||||
if it.Scope == ScopeHome {
|
||||
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
|
||||
}
|
||||
if !hookEvents[it.Event] {
|
||||
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
|
||||
}
|
||||
if strings.TrimSpace(it.Command) == "" {
|
||||
return "a hook needs a command"
|
||||
}
|
||||
if !itemName.MatchString(it.Name) {
|
||||
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
|
||||
}
|
||||
for rel := range it.Files {
|
||||
if problem := pathProblem(rel); problem != "" {
|
||||
@@ -189,7 +252,7 @@ func (it Item) Problem() string {
|
||||
if n := it.size(); n > MaxItemBytes {
|
||||
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
|
||||
}
|
||||
return ""
|
||||
return runsProblem(it)
|
||||
}
|
||||
|
||||
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
|
||||
@@ -224,9 +287,23 @@ type ConfigView struct {
|
||||
|
||||
// NewConfigView is the view as last written through, or empty.
|
||||
func NewConfigView(p Paths) *ConfigView {
|
||||
v := &ConfigView{p: p, items: map[string]Item{}}
|
||||
_ = readJSON(p.config(), &v.items)
|
||||
return v
|
||||
var kept map[string]Item
|
||||
_ = readJSON(p.config(), &kept)
|
||||
return &ConfigView{p: p, items: Taken(kept)}
|
||||
}
|
||||
|
||||
// Taken is what of the items kept in the module's own file may be rendered: each judged again as the state's watch
|
||||
// judges it, so what was written through before this module refused some kinds — a hook, settings, an item that
|
||||
// runs something by itself — is never rendered (novox/hq issue 340).
|
||||
func Taken(kept map[string]Item) map[string]Item {
|
||||
out := map[string]Item{}
|
||||
for key, it := range kept {
|
||||
_, node, _, _, ok := ParseKey(key)
|
||||
if ok && it.Problem() == "" && it.Key(node) == key {
|
||||
out[key] = it
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Applies says whether a key is this node's to take.
|
||||
@@ -404,17 +481,9 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
|
||||
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindOutputStyle:
|
||||
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
|
||||
case KindHook:
|
||||
for rel, content := range it.Files {
|
||||
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
|
||||
}
|
||||
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
|
||||
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
|
||||
if it.Matcher != "" {
|
||||
entry["matcher"] = it.Matcher
|
||||
}
|
||||
hooks[it.Event] = append(hooks[it.Event], entry)
|
||||
}
|
||||
// A hook is never rendered from a registration (novox/hq issue 340): the plugin's hooks are the mesh's own
|
||||
// guard alone, and any other is the operator's, in the managed settings set at the controller's terminal.
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
|
||||
@@ -435,19 +504,6 @@ func MarketplaceKeys() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
|
||||
func RegisteredSettings(c Config) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, layer := range [][]Item{c.Mesh, c.Node} {
|
||||
for _, it := range layer {
|
||||
if it.Kind == KindSettings {
|
||||
out = mergeSettings(out, it.Settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
|
||||
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
|
||||
func mergeSettings(a, b map[string]any) map[string]any {
|
||||
|
||||
Reference in New Issue
Block a user