Skip to content

Commit 71c0609

Browse files
jkyberneeesclaude
andauthored
fix(loop): deliver recovered tool-panic message to the LLM + review cleanups (#26)
* fix(loop): deliver recovered tool-panic message to the LLM + review cleanups A panicking tool's recovered error message was discarded: the goroutine returned before results[idx] was assigned, so the LLM received an empty tool result and the consecutive-error tracker never counted the failure. Drop the early return so the panic message flows through like any other tool error. The existing TestToolPanic_DoesNotKillAgent never exercised this path (its message-count trigger never matched with no system prompt); switch it to a call counter and assert the panic message reaches the LLM. Review cleanups from the #25 follow-up: - loop: name the thrice-repeated anonymous interface as trustAllSetter - telegram: parse ODEK_TELEGRAM_ALLOW_ALL with strconv.ParseBool for a consistent truthy grammar (malformed values fail closed) - telegram: add TelegramConfig.HasAllowlist() and use it in both ValidateConfig and the open-bot startup warning - telegram: correct HandlerConfig allowlist comments (AllowAllUsers is only consulted when BOTH lists are empty) and document on HandleUpdate that every routed handler must enforce isAllowed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(telegram): pin strict ALLOW_ALL grammar and HasAllowlist behavior Verification-protocol remediation for #26: the claim that malformed or extended truthy values ("yes", "on", "junk") fail closed had no corresponding test, and HasAllowlist had no direct unit test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e9fd0fe commit 71c0609

6 files changed

Lines changed: 98 additions & 25 deletions

File tree

cmd/odek/telegram.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func telegramCmd(args []string) error {
169169
// Loud warning when running without an allowlist (only reachable when the
170170
// operator explicitly set ODEK_TELEGRAM_ALLOW_ALL; ValidateConfig blocks
171171
// the accidental case).
172-
if cfg.AllowAllUsers && len(cfg.AllowedChats) == 0 && len(cfg.AllowedUsers) == 0 {
172+
if cfg.AllowAllUsers && !cfg.HasAllowlist() {
173173
handlerLog.Warn("telegram bot is running with NO allowlist — ANY user can drive the agent (ODEK_TELEGRAM_ALLOW_ALL=true)")
174174
}
175175

internal/loop/loop.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,10 @@ func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (s
520520
return e.runLoop(ctx, messages)
521521
}
522522

523+
// trustAllSetter is implemented by approvers (wsApprover, TelegramApprover)
524+
// whose tool-level prompts auto-pass while a batch approval grant is active.
525+
type trustAllSetter interface{ SetTrustAll(bool) }
526+
523527
// runLoop is the shared core of Run and RunWithMessages.
524528
// It runs the ReAct loop on the given messages and returns the final
525529
// answer plus the complete updated message history.
@@ -534,7 +538,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
534538
// the same approver (wsApprover is per-connection, TelegramApprover is
535539
// per-chat). The per-iteration reset below is the primary mechanism; this
536540
// defer only covers abnormal exits mid-iteration.
537-
if ta, ok := e.approver.(interface{ SetTrustAll(bool) }); ok {
541+
if ta, ok := e.approver.(trustAllSetter); ok {
538542
defer ta.SetTrustAll(false)
539543
}
540544

@@ -847,7 +851,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
847851
// (not via defer, which only fires when runLoop returns) — otherwise a
848852
// single approved batch would auto-approve every dangerous tool for the
849853
// remainder of the run.
850-
var trustAllApprover interface{ SetTrustAll(bool) }
854+
var trustAllApprover trustAllSetter
851855
if e.approver != nil && len(result.ToolCalls) > 1 {
852856
// Classify each tool call and filter to only those needing approval.
853857
type riskyCall struct {
@@ -901,7 +905,7 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
901905
// Approved: set trustAll on the approver if supported, so
902906
// individual tool-level PromptCommand calls auto-pass.
903907
if !batchDenied {
904-
if ta, ok := e.approver.(interface{ SetTrustAll(bool) }); ok {
908+
if ta, ok := e.approver.(trustAllSetter); ok {
905909
ta.SetTrustAll(true)
906910
trustAllApprover = ta
907911
}
@@ -939,12 +943,13 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
939943
ctxTool.SetContext(ctx)
940944
}
941945
// Capture any panic from the tool so it does not kill the agent.
942-
var toolPanicked bool
946+
// The recovered message falls through to results[idx] like any
947+
// other tool error, so the LLM sees it and the consecutive-error
948+
// tracking counts it.
943949
func() {
944950
defer func() {
945951
if r := recover(); r != nil {
946952
output = fmt.Sprintf("error: tool %q panicked: %v", tcRef.Function.Name, r)
947-
toolPanicked = true
948953
}
949954
}()
950955
res, err := t.Call(tcRef.Function.Arguments)
@@ -954,9 +959,6 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
954959
output = redact.RedactSecrets(res)
955960
}
956961
}()
957-
if toolPanicked {
958-
return
959-
}
960962
}
961963
results[idx] = execResult{output: output}
962964
}(i, tc)

internal/loop/loop_test.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"sort"
1111
"strings"
1212
"sync"
13+
"sync/atomic"
1314
"testing"
1415
"time"
1516

@@ -2007,8 +2008,12 @@ func (p *panicTool) Call(args string) (string, error) {
20072008
}
20082009

20092010
// TestToolPanic_DoesNotKillAgent verifies that a panicking tool call
2010-
// does not crash the agent. The agent should recover and continue.
2011+
// does not crash the agent. The agent should recover and continue, and the
2012+
// recovered panic message must reach the LLM as the tool result (regression:
2013+
// it used to be discarded, leaving an empty tool result).
20112014
func TestToolPanic_DoesNotKillAgent(t *testing.T) {
2015+
var toolResult atomic.Value // string: content of the tool message the LLM saw
2016+
var callNum atomic.Int32
20122017
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20132018
var body struct {
20142019
Messages []llm.Message `json:"messages"`
@@ -2017,11 +2022,16 @@ func TestToolPanic_DoesNotKillAgent(t *testing.T) {
20172022
t.Fatal(err)
20182023
}
20192024
// First call: return tool calls with panic tool
2020-
if len(body.Messages) == 2 {
2025+
if callNum.Add(1) == 1 {
20212026
fmt.Fprint(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"panic_tool","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}`)
20222027
return
20232028
}
2024-
// Second call (after tool panic): return content
2029+
// Second call (after tool panic): capture the tool result, return content
2030+
for _, m := range body.Messages {
2031+
if m.Role == "tool" {
2032+
toolResult.Store(m.Content)
2033+
}
2034+
}
20252035
fmt.Fprint(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"Agent survived the panic!"},"finish_reason":"stop"}]}`)
20262036
}))
20272037
defer server.Close()
@@ -2035,6 +2045,10 @@ func TestToolPanic_DoesNotKillAgent(t *testing.T) {
20352045
if !strings.Contains(result, "Agent survived") {
20362046
t.Errorf("agent result = %q, want 'Agent survived'", result)
20372047
}
2048+
tr, _ := toolResult.Load().(string)
2049+
if !strings.Contains(tr, "panicked") {
2050+
t.Errorf("tool result sent to LLM = %q, want the recovered panic message (containing %q)", tr, "panicked")
2051+
}
20382052
}
20392053

20402054
// ── Context Length Error Detection ────────────────────────────────────

internal/telegram/config.go

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ func ConfigFromEnv(base TelegramConfig) TelegramConfig {
5858
cfg.AllowedUsers = parseInt64List(v)
5959
}
6060
if v := os.Getenv("ODEK_TELEGRAM_ALLOW_ALL"); v != "" {
61-
cfg.AllowAllUsers = parseBool(v)
61+
// strconv.ParseBool keeps the truthy grammar consistent with the rest
62+
// of the config surface; a malformed value fails closed (false).
63+
b, err := strconv.ParseBool(strings.TrimSpace(v))
64+
cfg.AllowAllUsers = err == nil && b
6265
}
6366
if v := os.Getenv("ODEK_TELEGRAM_BOT_USERNAME"); v != "" {
6467
cfg.BotUsername = v
@@ -139,23 +142,19 @@ func ValidateConfig(cfg TelegramConfig) error {
139142
// operator explicitly opts in. An empty allowlist would otherwise let ANY
140143
// Telegram user drive the agent (and its shell/file tools). Checked last so
141144
// field-level validation errors surface first.
142-
if len(cfg.AllowedChats) == 0 && len(cfg.AllowedUsers) == 0 && !cfg.AllowAllUsers {
145+
if !cfg.HasAllowlist() && !cfg.AllowAllUsers {
143146
return fmt.Errorf("telegram: no allowlist configured — set ODEK_TELEGRAM_ALLOWED_CHATS " +
144147
"and/or ODEK_TELEGRAM_ALLOWED_USERS to restrict access, or set " +
145148
"ODEK_TELEGRAM_ALLOW_ALL=true to explicitly run an open bot (NOT recommended)")
146149
}
147150
return nil
148151
}
149152

150-
// parseBool parses common truthy string values ("true", "1", "yes", "on",
151-
// case-insensitive). Anything else is false.
152-
func parseBool(s string) bool {
153-
switch strings.ToLower(strings.TrimSpace(s)) {
154-
case "true", "1", "yes", "on":
155-
return true
156-
default:
157-
return false
158-
}
153+
// HasAllowlist reports whether at least one allowlist (chats or users) is
154+
// configured. With no allowlist the bot is open to every Telegram user, which
155+
// requires the explicit AllowAllUsers opt-in (see ValidateConfig).
156+
func (c TelegramConfig) HasAllowlist() bool {
157+
return len(c.AllowedChats) > 0 || len(c.AllowedUsers) > 0
159158
}
160159

161160
// parseInt64List parses a comma-separated string of integers into a slice of int64.

internal/telegram/config_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,59 @@ func TestConfigFromEnv_allowAll(t *testing.T) {
462462
}
463463
}
464464

465+
// TestConfigFromEnv_allowAllStrictGrammar pins the strconv.ParseBool grammar:
466+
// only its truthy forms enable the open-bot flag; anything else — including
467+
// the formerly-accepted "yes"/"on" and malformed values — fails closed.
468+
func TestConfigFromEnv_allowAllStrictGrammar(t *testing.T) {
469+
tests := []struct {
470+
value string
471+
want bool
472+
}{
473+
{"true", true},
474+
{"TRUE", true},
475+
{"1", true},
476+
{" true ", true}, // surrounding whitespace is trimmed
477+
{"false", false},
478+
{"0", false},
479+
{"yes", false}, // not part of strconv.ParseBool — fails closed
480+
{"on", false}, // not part of strconv.ParseBool — fails closed
481+
{"junk", false},
482+
}
483+
for _, tt := range tests {
484+
t.Run(tt.value, func(t *testing.T) {
485+
unsetAllEnvVars(t)
486+
t.Setenv("ODEK_TELEGRAM_ALLOW_ALL", tt.value)
487+
cfg := ConfigFromEnv(DefaultConfig())
488+
if cfg.AllowAllUsers != tt.want {
489+
t.Errorf("ODEK_TELEGRAM_ALLOW_ALL=%q → AllowAllUsers=%v, want %v",
490+
tt.value, cfg.AllowAllUsers, tt.want)
491+
}
492+
})
493+
}
494+
}
495+
496+
func TestHasAllowlist(t *testing.T) {
497+
tests := []struct {
498+
name string
499+
chats []int64
500+
users []int64
501+
want bool
502+
}{
503+
{"both empty", nil, nil, false},
504+
{"chats only", []int64{1}, nil, true},
505+
{"users only", nil, []int64{2}, true},
506+
{"both set", []int64{1}, []int64{2}, true},
507+
}
508+
for _, tt := range tests {
509+
t.Run(tt.name, func(t *testing.T) {
510+
cfg := TelegramConfig{AllowedChats: tt.chats, AllowedUsers: tt.users}
511+
if got := cfg.HasAllowlist(); got != tt.want {
512+
t.Errorf("HasAllowlist() = %v, want %v", got, tt.want)
513+
}
514+
})
515+
}
516+
}
517+
465518
func TestValidateConfig_emptyToken(t *testing.T) {
466519
cfg := DefaultConfig()
467520
err := ValidateConfig(cfg)

internal/telegram/handler.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import (
1111

1212
// HandlerConfig controls which messages the Handler processes.
1313
type HandlerConfig struct {
14-
AllowedChats []int64 // restricts by chat ID; empty + AllowAllUsers required to allow any
14+
AllowedChats []int64 // non-empty: only these chat IDs pass; empty: no chat filter
1515
BotUsername string // for @mention detection in groups (without @)
1616
MaxMsgLength int // default: 4096
17-
AllowedUsers []int64 // restricts by user ID; empty + AllowAllUsers required to allow any
17+
AllowedUsers []int64 // non-empty: only these user IDs pass; empty: no user filter
1818
// AllowAllUsers must be true to permit access when BOTH allowlists are
1919
// empty. Default false = fail-closed (deny everyone) so an unconfigured
2020
// handler never silently allows all users. See ValidateConfig.
@@ -184,6 +184,11 @@ func defaultDocumentHandler(bot *Bot) func(int64, int, string, string) (string,
184184
// HandleUpdate routes an incoming Telegram update to the appropriate handler.
185185
// Recovers from panics in handler callbacks to prevent a single bad update
186186
// from crashing the entire bot loop.
187+
//
188+
// SECURITY: every handler routed here must enforce isAllowed itself before
189+
// acting (handleMessage and handleCallback both do). When adding a new update
190+
// type, gate it the same way — callback queries once bypassed authorization
191+
// because their handler skipped this check.
187192
func (h *Handler) HandleUpdate(upd Update) {
188193
defer h.recoverFromPanic("HandleUpdate", upd.ID)
189194
switch {

0 commit comments

Comments
 (0)