From 4b1ce0fdba6fef1a579374a91450e2c8cb2e7e07 Mon Sep 17 00:00:00 2001 From: HananINouman Date: Wed, 1 Jul 2026 18:40:12 +0300 Subject: [PATCH 1/2] fix(hermes): unblock pay-agent buys from obol.stack chat Default command_allowlist includes tirith:lookalike_tld for tunnel .dev seller URLs, raise terminal timeout to match pay-agent streaming, and union operator allowlist entries across agent sync. Co-authored-by: Cursor --- internal/hermes/hermes.go | 88 +++++++++++++++++++++++++++++++++- internal/hermes/hermes_test.go | 55 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index c9dd6d98..beec87b2 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -695,6 +695,10 @@ func writeDeploymentFiles(cfg *config.Config, id, deploymentDir, agentBaseURL st if err != nil { return err } + configData, err = mergePreservedHermesConfigKeys(cfg, id, configData) + if err != nil { + return err + } if err := os.WriteFile(filepath.Join(deploymentDir, valuesFileName), []byte(generateValues(namespace, hostname, dashboardHost, agentBaseURL, token, primary, configData)), 0o600); err != nil { return fmt.Errorf("failed to write %s: %w", valuesFileName, err) @@ -1149,10 +1153,14 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { "terminal": map[string]any{ "backend": "local", "cwd": "/data/.hermes/workspace", - "timeout": 180, - "lifetime_seconds": 300, + // pay-agent streams up to 1h; chat buys must not die at 180s. + "timeout": 3600, + "lifetime_seconds": 3700, "docker_mount_cwd_to_workspace": false, }, + // Tirith blocks .dev seller URLs in pay-agent shell commands unless + // explicitly allowed. Stack-managed buyers need this for tunnel hosts. + "command_allowlist": []string{"tirith:lookalike_tld"}, "skills": map[string]any{ "external_dirs": []string{"/data/.hermes/" + obolSkillsDirName}, }, @@ -1160,6 +1168,82 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { return yaml.Marshal(payload) } +// mergePreservedHermesConfigKeys carries operator-edited Hermes keys across +// obol agent sync. generateConfig only knows stack-managed defaults; security +// knobs like command_allowlist set via `hermes config` must survive re-render. +func mergePreservedHermesConfigKeys(cfg *config.Config, id string, generated []byte) ([]byte, error) { + path := filepath.Join(agentruntime.HomePath(cfg, agentruntime.Hermes, id), "config.yaml") + existingData, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return generated, nil + } + return nil, fmt.Errorf("read existing Hermes config %s: %w", path, err) + } + + var gen map[string]any + var exist map[string]any + if err := yaml.Unmarshal(generated, &gen); err != nil { + return nil, fmt.Errorf("parse generated Hermes config: %w", err) + } + if err := yaml.Unmarshal(existingData, &exist); err != nil { + return generated, nil + } + + gen["command_allowlist"] = mergeCommandAllowlist( + stringSliceFromConfig(gen["command_allowlist"]), + stringSliceFromConfig(exist["command_allowlist"]), + ) + + out, err := yaml.Marshal(gen) + if err != nil { + return nil, fmt.Errorf("marshal merged Hermes config: %w", err) + } + return out, nil +} + +func mergeCommandAllowlist(generated, existing []string) []string { + seen := make(map[string]struct{}, len(generated)+len(existing)) + out := make([]string, 0, len(generated)+len(existing)) + for _, list := range [][]string{generated, existing} { + for _, entry := range list { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if _, ok := seen[entry]; ok { + continue + } + seen[entry] = struct{}{} + out = append(out, entry) + } + } + return out +} + +func stringSliceFromConfig(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + case string: + s := strings.TrimSpace(t) + if s == "" { + return nil + } + return []string{s} + default: + return nil + } +} + func currentAgentBaseURL(deploymentDir string) string { raw, err := os.ReadFile(filepath.Join(deploymentDir, valuesFileName)) if err != nil { diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index 90a75a5b..672798ef 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -417,3 +417,58 @@ func mkdirInstance(t *testing.T, cfg *config.Config, id string) { t.Fatalf("create Hermes instance %q: %v", id, err) } } + +func TestGenerateConfig_IncludesPayAgentAllowlist(t *testing.T) { + raw, err := generateConfig(testConfig(t), "claude-sonnet-4-6") + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + allowlist, ok := cfg["command_allowlist"].([]any) + if !ok || len(allowlist) == 0 { + t.Fatalf("command_allowlist = %#v, want non-empty list", cfg["command_allowlist"]) + } + if allowlist[0] != "tirith:lookalike_tld" { + t.Fatalf("command_allowlist[0] = %v, want tirith:lookalike_tld", allowlist[0]) + } + term, ok := cfg["terminal"].(map[string]any) + if !ok { + t.Fatal("terminal config missing") + } + if term["timeout"] != 3600 { + t.Fatalf("terminal.timeout = %v, want 3600 for pay-agent", term["timeout"]) + } +} + +func TestMergePreservedHermesConfigKeys_UnionsAllowlist(t *testing.T) { + cfg := testConfig(t) + id := "obol-agent" + home := agentruntime.HomePath(cfg, agentruntime.Hermes, id) + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatalf("mkdir home: %v", err) + } + existing := []byte("command_allowlist:\n - custom:rule\n") + if err := os.WriteFile(filepath.Join(home, "config.yaml"), existing, 0o600); err != nil { + t.Fatalf("write existing config: %v", err) + } + generated, err := generateConfig(cfg, "claude-sonnet-4-6") + if err != nil { + t.Fatalf("generateConfig: %v", err) + } + merged, err := mergePreservedHermesConfigKeys(cfg, id, generated) + if err != nil { + t.Fatalf("mergePreservedHermesConfigKeys: %v", err) + } + var out map[string]any + if err := yaml.Unmarshal(merged, &out); err != nil { + t.Fatalf("yaml.Unmarshal merged: %v", err) + } + got := stringSliceFromConfig(out["command_allowlist"]) + want := []string{"tirith:lookalike_tld", "custom:rule"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("command_allowlist = %#v, want %#v", got, want) + } +} From 32d02f82fd13cba691a3bd29f28fb192e2503a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Wed, 1 Jul 2026 22:53:41 +0100 Subject: [PATCH 2/2] chore: address security scan --- internal/hermes/hermes.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index beec87b2..479ca5a5 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -1151,8 +1151,8 @@ func generateConfig(cfg *config.Config, primary string) ([]byte, error) { "api_key": litellmMasterKey(cfg), }, "terminal": map[string]any{ - "backend": "local", - "cwd": "/data/.hermes/workspace", + "backend": "local", + "cwd": "/data/.hermes/workspace", // pay-agent streams up to 1h; chat buys must not die at 180s. "timeout": 3600, "lifetime_seconds": 3700, @@ -1203,8 +1203,12 @@ func mergePreservedHermesConfigKeys(cfg *config.Config, id string, generated []b } func mergeCommandAllowlist(generated, existing []string) []string { - seen := make(map[string]struct{}, len(generated)+len(existing)) - out := make([]string, 0, len(generated)+len(existing)) + // No capacity hints: the command_allowlist is an operator config list of a + // handful of entries, so preallocation is noise — and summing the two + // lengths trips CodeQL's allocation-size-overflow heuristic (a false + // positive here, but not worth a standing dismissal). Grow on demand. + seen := make(map[string]struct{}) + out := make([]string, 0) for _, list := range [][]string{generated, existing} { for _, entry := range list { entry = strings.TrimSpace(entry)