diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index 95311de..ebd6de1 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -4511,7 +4511,9 @@ func cloneHermesConfig(cfg *driver.HermesConfig) *driver.HermesConfig { return nil } return &driver.HermesConfig{ - AllowTools: append([]string(nil), cfg.AllowTools...), + AllowTools: append([]string(nil), cfg.AllowTools...), + DisableTools: append([]string(nil), cfg.DisableTools...), + AllowSilent: cfg.AllowSilent, } } diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index c393792..ffdab3a 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -59,6 +59,28 @@ func TestMergeResolvedSkills(t *testing.T) { } } +func TestCloneHermesConfigPreservesAllPodSettings(t *testing.T) { + source := &driver.HermesConfig{ + AllowTools: []string{"terminal"}, + DisableTools: []string{"skill_manage", "session_search"}, + AllowSilent: true, + } + + got := cloneHermesConfig(source) + if got == nil { + t.Fatal("expected cloned Hermes config") + } + if !reflect.DeepEqual(got, source) { + t.Fatalf("cloned Hermes config lost settings: got %+v, want %+v", got, source) + } + + got.AllowTools[0] = "changed" + got.DisableTools[0] = "changed" + if source.AllowTools[0] != "terminal" || source.DisableTools[0] != "skill_manage" { + t.Fatal("cloneHermesConfig must not alias source slices") + } +} + func TestMergeModelSlots(t *testing.T) { tests := []struct { name string diff --git a/internal/driver/hermes/config.go b/internal/driver/hermes/config.go index b19f46d..3ab276d 100644 --- a/internal/driver/hermes/config.go +++ b/internal/driver/hermes/config.go @@ -434,15 +434,33 @@ func hasHandle(rc *driver.ResolvedClaw, platform string) bool { } func resolveDisabledHermesTools(rc *driver.ResolvedClaw) []string { - if !hasDiscordHandle(rc) && !hasSlackHandle(rc) { - return nil + disabled := make([]string, 0) + seen := make(map[string]struct{}) + addDisabled := func(tool string) { + tool = strings.TrimSpace(tool) + if tool == "" { + return + } + if _, exists := seen[tool]; exists { + return + } + seen[tool] = struct{}{} + disabled = append(disabled, tool) } - disabled := []string{hermesTextToSpeechTool} - if rc == nil || rc.Hermes == nil || len(rc.Hermes.AllowTools) == 0 { - return disabled + if hasDiscordHandle(rc) || hasSlackHandle(rc) { + addDisabled(hermesTextToSpeechTool) } + if rc == nil || rc.Hermes == nil { + return disabled + } + for _, tool := range rc.Hermes.DisableTools { + addDisabled(tool) + } + if len(rc.Hermes.AllowTools) == 0 { + return disabled + } allowSet := make(map[string]struct{}, len(rc.Hermes.AllowTools)) for _, tool := range rc.Hermes.AllowTools { tool = strings.TrimSpace(tool) diff --git a/internal/driver/hermes/config_test.go b/internal/driver/hermes/config_test.go index fea176b..d812c78 100644 --- a/internal/driver/hermes/config_test.go +++ b/internal/driver/hermes/config_test.go @@ -877,6 +877,42 @@ func TestGenerateEnvFileKeepsTTSDisabledForUnrelatedAllowTool(t *testing.T) { } } +func TestGenerateEnvFileAddsExplicitDisabledTools(t *testing.T) { + rc := &driver.ResolvedClaw{ + Hermes: &driver.HermesConfig{ + DisableTools: []string{"skill_manage", "session_search", "skill_manage"}, + }, + } + data, err := GenerateEnvFile(rc, &modelConfig{Env: map[string]string{}}) + if err != nil { + t.Fatalf("GenerateEnvFile returned error: %v", err) + } + + want := clawdapusDisabledToolsEnv + "=skill_manage,session_search\n" + if !strings.Contains(string(data), want) { + t.Fatalf("expected %q for a handle-less service, got:\n%s", want, data) + } +} + +func TestGenerateEnvFileHermesAllowToolsWinsOverDisableTools(t *testing.T) { + rc := &driver.ResolvedClaw{ + Handles: map[string]*driver.HandleInfo{"discord": {}}, + Hermes: &driver.HermesConfig{ + AllowTools: []string{"skill_manage"}, + DisableTools: []string{"skill_manage", "session_search"}, + }, + } + data, err := GenerateEnvFile(rc, &modelConfig{Env: map[string]string{}}) + if err != nil { + t.Fatalf("GenerateEnvFile returned error: %v", err) + } + + want := clawdapusDisabledToolsEnv + "=" + hermesTextToSpeechTool + ",session_search\n" + if !strings.Contains(string(data), want) { + t.Fatalf("expected allow-tools to remove the conflicting deny entry; want %q, got:\n%s", want, data) + } +} + func TestGenerateEnvFileDoesNotDisableTTSForTelegramOnly(t *testing.T) { rc := &driver.ResolvedClaw{ Handles: map[string]*driver.HandleInfo{"telegram": {}}, diff --git a/internal/driver/hermes/driver.go b/internal/driver/hermes/driver.go index f8dae23..813b60a 100644 --- a/internal/driver/hermes/driver.go +++ b/internal/driver/hermes/driver.go @@ -200,9 +200,6 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt env["HERMES_TOOL_ONLY_MODE"] = "1" env[hermesAllowSilentFinalEnv] = "1" env[hermesToolProgressModeEnv] = "off" - if disabled := resolveDisabledHermesTools(rc); len(disabled) > 0 { - env[clawdapusDisabledToolsEnv] = strings.Join(disabled, ",") - } value, err := resolvedEnvValue(rc, hermesToolProgressModeEnv) if err != nil { return nil, fmt.Errorf("hermes driver: %w", err) @@ -211,6 +208,9 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt env[hermesToolProgressModeEnv] = value } } + if disabled := resolveDisabledHermesTools(rc); len(disabled) > 0 { + env[clawdapusDisabledToolsEnv] = strings.Join(disabled, ",") + } if hasManagedChatHandle(rc) { env[hermesChatStatusDeliveryEnv] = "off" value, err := resolvedEnvValue(rc, hermesChatStatusDeliveryEnv) diff --git a/internal/driver/hermes/driver_test.go b/internal/driver/hermes/driver_test.go index 20440c2..655d5a1 100644 --- a/internal/driver/hermes/driver_test.go +++ b/internal/driver/hermes/driver_test.go @@ -676,6 +676,38 @@ func TestMaterializeHonorsHermesAllowToolsOptIn(t *testing.T) { } } +func TestMaterializeWritesExplicitDisabledToolsForTelegramOnlyService(t *testing.T) { + rc, tmp := newTestRC(t) + rc.Handles = map[string]*driver.HandleInfo{"telegram": {}} + rc.Environment["TELEGRAM_BOT_TOKEN"] = "telegram-token" + rc.Hermes = &driver.HermesConfig{DisableTools: []string{"skill_manage"}} + runtimeDir := filepath.Join(tmp, "runtime") + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + + if err := (&Driver{}).Validate(rc); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + + result, err := (&Driver{}).Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test"}) + if err != nil { + t.Fatalf("Materialize returned error: %v", err) + } + + if got := result.Environment[clawdapusDisabledToolsEnv]; got != "skill_manage" { + t.Fatalf("expected %s=skill_manage in container env, got %q", clawdapusDisabledToolsEnv, got) + } + + envData, err := os.ReadFile(filepath.Join(runtimeDir, "hermes-home", ".env")) + if err != nil { + t.Fatalf("read .env: %v", err) + } + if !strings.Contains(string(envData), clawdapusDisabledToolsEnv+"=skill_manage\n") { + t.Fatalf("expected explicit disabled tool in .env, got:\n%s", envData) + } +} + func TestMaterializeWritesAllowSilentEnv(t *testing.T) { rc, tmp := newTestRC(t) rc.Hermes = &driver.HermesConfig{AllowSilent: true} diff --git a/internal/driver/types.go b/internal/driver/types.go index f033f8f..7a3ed78 100644 --- a/internal/driver/types.go +++ b/internal/driver/types.go @@ -93,8 +93,9 @@ type ResolvedClaw struct { } type HermesConfig struct { - AllowTools []string - AllowSilent bool + AllowTools []string + DisableTools []string + AllowSilent bool } // HandleInfo is the full contact card for an agent on a platform. diff --git a/internal/pod/parser.go b/internal/pod/parser.go index dd6bffd..db662f6 100644 --- a/internal/pod/parser.go +++ b/internal/pod/parser.go @@ -115,8 +115,9 @@ type rawMCPStdioBlock struct { } type rawHermesConfig struct { - AllowTools []string `yaml:"allow-tools"` - AllowSilent bool `yaml:"allow-silent"` + AllowTools []string `yaml:"allow-tools"` + DisableTools []string `yaml:"disable-tools"` + AllowSilent bool `yaml:"allow-silent"` } type rawFeedEntry struct { @@ -993,7 +994,7 @@ func parseMCPStdio(serviceName string, raw *rawMCPStdioBlock, agent string, clla } func parseHermesConfig(raw *rawHermesConfig) (*driver.HermesConfig, error) { - if raw == nil || (len(raw.AllowTools) == 0 && !raw.AllowSilent) { + if raw == nil || (len(raw.AllowTools) == 0 && len(raw.DisableTools) == 0 && !raw.AllowSilent) { return nil, nil } @@ -1005,12 +1006,21 @@ func parseHermesConfig(raw *rawHermesConfig) (*driver.HermesConfig, error) { } allowTools = append(allowTools, tool) } - if len(allowTools) == 0 && !raw.AllowSilent { + disableTools := make([]string, 0, len(raw.DisableTools)) + for i, tool := range raw.DisableTools { + tool = strings.TrimSpace(tool) + if tool == "" { + return nil, fmt.Errorf("disable-tools[%d] must not be empty", i) + } + disableTools = append(disableTools, tool) + } + if len(allowTools) == 0 && len(disableTools) == 0 && !raw.AllowSilent { return nil, nil } return &driver.HermesConfig{ - AllowTools: allowTools, - AllowSilent: raw.AllowSilent, + AllowTools: allowTools, + DisableTools: disableTools, + AllowSilent: raw.AllowSilent, }, nil } diff --git a/internal/pod/parser_hermes_test.go b/internal/pod/parser_hermes_test.go index 2f5bc0f..1bbe174 100644 --- a/internal/pod/parser_hermes_test.go +++ b/internal/pod/parser_hermes_test.go @@ -30,6 +30,31 @@ services: } } +func TestParseHermesDisableTools(t *testing.T) { + p, err := Parse(strings.NewReader(` +services: + analyst: + image: ghcr.io/example/analyst:latest + x-claw: + agent: ./AGENTS.md + hermes: + disable-tools: + - skill_manage + - session_search +`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hermes := p.Services["analyst"].Claw.Hermes + if hermes == nil { + t.Fatal("expected Hermes config") + } + if len(hermes.DisableTools) != 2 || hermes.DisableTools[0] != "skill_manage" || hermes.DisableTools[1] != "session_search" { + t.Fatalf("unexpected Hermes disable-tools: %+v", hermes.DisableTools) + } +} + func TestParseHermesAllowSilent(t *testing.T) { p, err := Parse(strings.NewReader(` services: @@ -96,3 +121,23 @@ services: t.Fatalf("unexpected error: %v", err) } } + +func TestParseHermesDisableToolsRejectsEmptyItem(t *testing.T) { + _, err := Parse(strings.NewReader(` +services: + analyst: + image: ghcr.io/example/analyst:latest + x-claw: + agent: ./AGENTS.md + hermes: + disable-tools: + - skill_manage + - " " +`)) + if err == nil { + t.Fatal("expected empty disable-tools item to fail") + } + if !strings.Contains(err.Error(), "disable-tools[1] must not be empty") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/site/guide/hermes.md b/site/guide/hermes.md index 10875c0..46f05f9 100644 --- a/site/guide/hermes.md +++ b/site/guide/hermes.md @@ -113,7 +113,34 @@ For an interactive/debug service, opt visible status back in with `HERMES_CHAT_STATUS_DELIVERY` preserves upstream Hermes behavior; Clawdapus sets it to `off` for managed chat services. -### 6. gateway.log: the first diagnostic surface +### 6. Native tool policy + +Hermes ships native tools in its own toolsets. Restrict those tools at compile +time in the service's `x-claw.hermes` block: + +```yaml +services: + assistant: + x-claw: + hermes: + disable-tools: [skill_manage, session_search] +``` + +The driver writes the resolved list into both the container environment and the +Hermes `.env`; no cllama rule or image patch is needed. `allow-tools` subtracts +from the disabled set and wins if a tool appears in both lists: + +```yaml + hermes: + disable-tools: [skill_manage, session_search] + allow-tools: [session_search] +``` + +This leaves `skill_manage` disabled and enables `session_search`. Explicit +`disable-tools` entries apply even when the service has no Discord or Slack +handle. + +### 7. gateway.log: the first diagnostic surface ```bash claw compose exec assistant cat /root/.hermes/logs/gateway.log