Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/claw/compose_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
22 changes: 22 additions & 0 deletions cmd/claw/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions internal/driver/hermes/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions internal/driver/hermes/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}},
Expand Down
6 changes: 3 additions & 3 deletions internal/driver/hermes/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/driver/hermes/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
5 changes: 3 additions & 2 deletions internal/driver/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions internal/pod/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
45 changes: 45 additions & 0 deletions internal/pod/parser_hermes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
}
29 changes: 28 additions & 1 deletion site/guide/hermes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading