Skip to content

Commit 636e470

Browse files
authored
L11: bind Telegram clarify callbacks to request and originating user (#83)
- cmd/odek/telegram.go: replace global per-chat clarify channel with pendingClarifyReqs keyed by random request ID; embed reqID in callback data; verify the callback comes from the originating user; reject expired/unknown requests. - internal/telegram/handler.go: pass callback user's userID into OnCallbackQuery so callers can enforce per-user binding. - cmd/odek/telegram_clarify_test.go + internal/telegram/*_test.go: regression tests and signature updates. - docs/SECURITY.md + AGENTS.md: document L11 mitigation.
1 parent bfbf8e4 commit 636e470

9 files changed

Lines changed: 245 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
181181
- **Telegram plan file size cap** (`internal/telegram/plan.go`) — plan files larger than 1 MiB are rejected by `ReadPlan` and `MostRecentPlan`, and `ListPlans` only reads the first 8 KiB for preview. This prevents a prompt-injected agent from causing an OOM via a multi-hundred-megabyte plan file.
182182
- **Telegram log file permissions** (`internal/telegram/log.go`) — Telegram log files are created with `0600`, and `os.Chmod` hardens existing files. Chat IDs and task snippets are no longer world-readable by default.
183183
- **Telegram chat-scoped sessions and plans** (`internal/telegram/session.go`, `internal/telegram/plan.go`, `cmd/odek/telegram.go`) — `/sessions`, `/resume`, `/prune`, `/plans`, `/plan_view`, `/plan_delete`, and `/plan_resume` are scoped to the requesting chat. Sessions are filtered by the `tg-<chatID>` prefix, `ResumeSession` rejects cross-chat IDs, and plans live under `~/.odek/plans/chat<chatID>/` so one chat cannot list, read, delete, or resume another chat's sessions/plans.
184+
- **Telegram clarify callback binding** (`cmd/odek/telegram.go`, `internal/telegram/handler.go`) — each `clarify` prompt now uses a random request ID in its inline-keyboard callback data (`clarify:<reqID>:yes/no`). The handler validates the request ID and binds the answer to the originating user, so a group member or stale keyboard cannot answer someone else's clarify prompt. `Handler.OnCallbackQuery` now receives the `userID` for consistent callback binding.
184185
- **odek self-invocation gate** (`internal/danger/classifier.go`) — any shell stage whose program basename is `odek` is classified as `system_write`. This prevents a prompt-injected agent from reaching the human-gated trust mutations (`odek memory promote`, `odek memory extended confirm`, `odek skill promote --force`) through the shell tool and flipping its own taint gates.
185186
- **MCP inputSchema hardening** (`cmd/odek/mcp_approval.go`) — every string in an MCP tool's `inputSchema` is recursively guard-scanned for injection patterns; schemas larger than 256 KiB are rejected; the interactive approval prompt shows a SHA-256 hash and byte size of the schema so operators can detect changes.
186187
- **MCP tool batch classification** (`internal/loop/loop.go`) — MCP tools (`<server>__<tool>`) are classified as `unknown` by `classifyToolCall`, so the batch approval gate shows them and untrusted sub-agents force them to `deny`.

cmd/odek/telegram.go

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package main
22

33
import (
44
"context"
5+
"crypto/rand"
6+
"encoding/hex"
57
"encoding/json"
68
"errors"
79
"fmt"
@@ -47,10 +49,89 @@ var chatCancels sync.Map // map[int64]context.CancelFunc
4749
// interrupted task.
4850
var chatRunInfos sync.Map // map[int64]loop.IterationInfo
4951

50-
// clarifyMsgIDs stores the message ID of the active clarify prompt
51-
// per chat, so the callback handler can edit/remove it after the user
52-
// responds. Cleared when the clarify tool completes or times out.
53-
var clarifyMsgIDs sync.Map // map[int64]int
52+
// pendingClarifyReqs stores active clarify prompts keyed by a random
53+
// request ID embedded in the callback data. Each entry records the
54+
// originating user, the response channel, and the prompt message ID so
55+
// callback queries can be validated against the user who triggered the
56+
// clarify and rejected if the prompt has expired or was answered by
57+
// someone else. Cleared when the clarify tool completes, times out, or
58+
// the user responds.
59+
type pendingClarifyReq struct {
60+
userID int64
61+
ch chan string
62+
msgID int
63+
}
64+
65+
var pendingClarifyReqs sync.Map // map[string]*pendingClarifyReq
66+
67+
// generateClarifyReqID returns a random request ID for a clarify prompt.
68+
// The ID is embedded in the callback data so each prompt is independent
69+
// and stale keyboards cannot answer later clarifies.
70+
func generateClarifyReqID() string {
71+
buf := make([]byte, 8)
72+
if _, err := rand.Read(buf); err != nil {
73+
// crypto/rand.Read only fails on catastrophic system failure. Fail
74+
// closed rather than minting a predictable ID, which would let an
75+
// attacker pre-compute callback data.
76+
panic(fmt.Sprintf("telegram: crypto/rand unavailable: %v", err))
77+
}
78+
return hex.EncodeToString(buf)
79+
}
80+
81+
// parseClarifyCallback parses callback data of the form
82+
// "clarify:<reqID>:<yes|no>". It returns the request ID, the answer, and
83+
// true if the data is a valid clarify callback.
84+
func parseClarifyCallback(data string) (reqID, answer string, ok bool) {
85+
rest, ok := strings.CutPrefix(data, "clarify:")
86+
if !ok {
87+
return "", "", false
88+
}
89+
idx := strings.LastIndex(rest, ":")
90+
if idx < 0 {
91+
return "", "", false
92+
}
93+
reqID = rest[:idx]
94+
answer = rest[idx+1:]
95+
if reqID == "" || (answer != "yes" && answer != "no") {
96+
return "", "", false
97+
}
98+
return reqID, answer, true
99+
}
100+
101+
// handleClarifyCallback routes a Telegram inline-button press for a clarify
102+
// prompt. It validates the embedded request ID, binds the answer to the
103+
// originating user, and unblocks the waiting agent goroutine. The returned
104+
// bool is true when the callback data was a clarify callback (whether valid
105+
// or expired); callers should not process it further.
106+
func handleClarifyCallback(chatID, userID int64, data string, bot *telegram.Bot) (string, bool) {
107+
reqID, answer, ok := parseClarifyCallback(data)
108+
if !ok {
109+
return "", false
110+
}
111+
v, ok := pendingClarifyReqs.Load(reqID)
112+
if !ok {
113+
return "⚠️ This clarify prompt has expired or already been answered.", true
114+
}
115+
req := v.(*pendingClarifyReq)
116+
// Bind the callback to the user who triggered the clarify.
117+
if req.userID != 0 && req.userID != userID {
118+
return "⚠️ This button was meant for another user.", true
119+
}
120+
// Accept the answer and remove the request atomically.
121+
pendingClarifyReqs.Delete(reqID)
122+
select {
123+
case req.ch <- answer:
124+
default:
125+
// Channel full or closed — clarify already resolved.
126+
}
127+
// Remove the inline keyboard and update text to show the answer.
128+
if req.msgID != 0 && bot != nil {
129+
bot.EditMessageText(chatID, req.msgID,
130+
"✅ *User answered:* "+answer,
131+
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2, ReplyMarkup: &telegram.InlineKeyboardMarkup{InlineKeyboard: [][]telegram.InlineKeyboardButton{}}})
132+
}
133+
return "", true
134+
}
54135

55136
// pendingSuggestions stores SkillSuggestion values keyed by skill name,
56137
// awaiting user approval via inline keyboard callbacks.
@@ -594,24 +675,10 @@ func telegramCmd(args []string) error {
594675
return cmd.Handler(argsStr)
595676
}
596677

597-
handler.OnCallbackQuery = func(chatID int64, data string) (string, error) {
678+
handler.OnCallbackQuery = func(chatID int64, data string, userID int64) (string, error) {
598679
// Route clarify callbacks — the user clicked Yes/No on a clarify question.
599-
if answer, ok := strings.CutPrefix(data, "clarify:"); ok {
600-
if ch, ok := sessionManager.GetClarifyChannel(chatID); ok {
601-
select {
602-
case ch <- answer:
603-
default:
604-
// Channel full or closed — clarify already resolved.
605-
}
606-
}
607-
// Remove the inline keyboard and update text to show the answer.
608-
if msgID, ok := clarifyMsgIDs.Load(chatID); ok {
609-
bot.EditMessageText(chatID, msgID.(int),
610-
"✅ *User answered:* "+answer,
611-
&telegram.SendOpts{ParseMode: telegram.ParseModeMarkdownV2, ReplyMarkup: &telegram.InlineKeyboardMarkup{InlineKeyboard: [][]telegram.InlineKeyboardButton{}}})
612-
clarifyMsgIDs.Delete(chatID)
613-
}
614-
return "", nil
680+
if resp, ok := handleClarifyCallback(chatID, userID, data, bot); ok {
681+
return resp, nil
615682
}
616683

617684
// Route skill suggestion callbacks — Save or Skip.
@@ -1514,26 +1581,29 @@ func handleChatMessage(
15141581
// keyboard message and blocks until the user responds.
15151582
agentTools := append([]odek.Tool{}, tools...)
15161583
agentTools = append(agentTools, toolpkg.NewClarifyTool(func(question string) (string, error) {
1584+
reqID := generateClarifyReqID()
15171585
ch := make(chan string, 1)
1518-
sessionManager.SetClarifyChannel(chatID, ch)
1519-
defer sessionManager.DeleteClarifyChannel(chatID)
1586+
req := &pendingClarifyReq{userID: userID, ch: ch}
1587+
pendingClarifyReqs.Store(reqID, req)
1588+
defer pendingClarifyReqs.Delete(reqID)
15201589

1521-
// Send the question with Yes/No buttons.
1590+
// Send the question with Yes/No buttons. Each prompt gets a unique
1591+
// request ID so stale keyboards cannot answer later clarifies and
1592+
// the callback can be bound to the originating user.
15221593
replyMarkup := &telegram.InlineKeyboardMarkup{
15231594
InlineKeyboard: [][]telegram.InlineKeyboardButton{
15241595
{
1525-
{Text: "Yes", CallbackData: "clarify:yes"},
1526-
{Text: "No", CallbackData: "clarify:no"},
1596+
{Text: "Yes", CallbackData: fmt.Sprintf("clarify:%s:yes", reqID)},
1597+
{Text: "No", CallbackData: fmt.Sprintf("clarify:%s:no", reqID)},
15271598
},
15281599
},
15291600
}
1530-
if msg, err := bot.SendMessage(chatID, "❓ "+question,
1531-
&telegram.SendOpts{ReplyMarkup: replyMarkup, ParseMode: "Markdown", ReplyToMessageID: messageID}); err != nil {
1601+
msg, err := bot.SendMessage(chatID, "❓ "+question,
1602+
&telegram.SendOpts{ReplyMarkup: replyMarkup, ParseMode: "Markdown", ReplyToMessageID: messageID})
1603+
if err != nil {
15321604
return "", fmt.Errorf("clarify: send message: %w", err)
1533-
} else {
1534-
clarifyMsgIDs.Store(chatID, msg.ID)
1535-
defer clarifyMsgIDs.Delete(chatID)
15361605
}
1606+
req.msgID = msg.ID
15371607

15381608
// Wait for the user to click a button (or timeout).
15391609
select {

cmd/odek/telegram_clarify_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"sync"
8+
"testing"
9+
10+
"github.com/BackendStack21/odek/internal/telegram"
11+
)
12+
13+
func TestParseClarifyCallback_Valid(t *testing.T) {
14+
reqID, answer, ok := parseClarifyCallback("clarify:abcd1234:yes")
15+
if !ok {
16+
t.Fatal("expected valid clarify callback")
17+
}
18+
if reqID != "abcd1234" {
19+
t.Errorf("reqID = %q, want abcd1234", reqID)
20+
}
21+
if answer != "yes" {
22+
t.Errorf("answer = %q, want yes", answer)
23+
}
24+
}
25+
26+
func TestParseClarifyCallback_Invalid(t *testing.T) {
27+
invalid := []string{
28+
"clarify:yes",
29+
"clarify:",
30+
"clarify:abcd1234:maybe",
31+
"skill_save:foo",
32+
"apr:123",
33+
"",
34+
}
35+
for _, data := range invalid {
36+
if _, _, ok := parseClarifyCallback(data); ok {
37+
t.Errorf("expected %q to be invalid", data)
38+
}
39+
}
40+
}
41+
42+
func TestGenerateClarifyReqID_Unique(t *testing.T) {
43+
seen := make(map[string]bool)
44+
for i := 0; i < 100; i++ {
45+
id := generateClarifyReqID()
46+
if seen[id] {
47+
t.Fatalf("duplicate request ID: %q", id)
48+
}
49+
seen[id] = true
50+
}
51+
}
52+
53+
// TestHandleClarifyCallback_BindsToOriginatingUser verifies that a clarify
54+
// callback from the correct user unblocks the channel and rejects a
55+
// callback from a different user.
56+
func TestHandleClarifyCallback_BindsToOriginatingUser(t *testing.T) {
57+
// Start a fake Telegram server so EditMessageText does not fail.
58+
var edits sync.Map
59+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
if strings.HasSuffix(r.URL.Path, "/editMessageText") {
61+
edits.Store(1, true)
62+
}
63+
w.Header().Set("Content-Type", "application/json")
64+
w.Write([]byte(`{"ok":true,"result":true}`))
65+
}))
66+
defer srv.Close()
67+
68+
bot := telegram.NewBot("test-token")
69+
bot.BaseURL = srv.URL + "/bot" + bot.Token
70+
71+
reqID := generateClarifyReqID()
72+
ch := make(chan string, 1)
73+
pendingClarifyReqs.Store(reqID, &pendingClarifyReq{userID: 42, ch: ch, msgID: 123})
74+
defer pendingClarifyReqs.Delete(reqID)
75+
76+
// Wrong user should be rejected without sending an answer.
77+
resp, ok := handleClarifyCallback(1, 99, "clarify:"+reqID+":yes", bot)
78+
if !ok {
79+
t.Fatal("expected clarify callback to be recognized")
80+
}
81+
if !strings.Contains(resp, "meant for another user") {
82+
t.Errorf("expected cross-user rejection, got: %q", resp)
83+
}
84+
select {
85+
case <-ch:
86+
t.Fatal("answer should not have been sent for wrong user")
87+
default:
88+
}
89+
90+
// Correct user should unblock the channel and update the message.
91+
resp, ok = handleClarifyCallback(1, 42, "clarify:"+reqID+":yes", bot)
92+
if !ok {
93+
t.Fatal("expected clarify callback to be recognized")
94+
}
95+
if resp != "" {
96+
t.Errorf("expected empty response for accepted callback, got: %q", resp)
97+
}
98+
select {
99+
case ans := <-ch:
100+
if ans != "yes" {
101+
t.Errorf("answer = %q, want yes", ans)
102+
}
103+
default:
104+
t.Fatal("expected answer to be delivered to channel")
105+
}
106+
if _, ok := edits.Load(1); !ok {
107+
t.Error("expected EditMessageText to be called")
108+
}
109+
}
110+
111+
// TestHandleClarifyCallback_ExpiredRequest verifies that a callback for an
112+
// unknown/expired request returns a friendly expiration message.
113+
func TestHandleClarifyCallback_ExpiredRequest(t *testing.T) {
114+
resp, ok := handleClarifyCallback(1, 42, "clarify:"+generateClarifyReqID()+":no", nil)
115+
if !ok {
116+
t.Fatal("expected clarify callback to be recognized")
117+
}
118+
if !strings.Contains(resp, "expired") {
119+
t.Errorf("expected expiration message, got: %q", resp)
120+
}
121+
}

docs/SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,12 @@ Now:
667667

668668
`persist()` now hardens any existing file with `os.Chmod(path, 0600)` and opens the file with `os.O_WRONLY|os.O_CREATE|os.O_TRUNC` and mode `0600`, matching the permission model already applied to sessions, audit logs, and Telegram logs.
669669

670+
### 39n. Telegram clarify callback binding
671+
672+
The Telegram bot's `clarify` tool sent inline keyboard buttons with literal callback data `clarify:yes` and `clarify:no`. Because the data carried no request identifier or user binding, any group member (or a stale keyboard from an earlier task) could answer a clarify prompt intended for someone else, and a later clarify could be answered by a stale button from an earlier one.
673+
674+
Each clarify prompt now generates a random request ID (embedded in callback data as `clarify:<reqID>:yes/no`). The handler validates the request ID, rejects callbacks from a different user than the one who triggered the prompt, and ignores callbacks for expired or already-answered prompts. The `Handler.OnCallbackQuery` signature now receives the originating `userID` so other callback handlers can apply the same binding.
675+
670676
### 40. `/api/resources` result limit cap
671677

672678
The `/api/resources?q=...&limit=N` autocomplete endpoint previously accepted any positive `limit` value. It is now capped to 100 results both in the HTTP handler and in `Registry.Search`. This prevents a prompt-injected or attacker-forged request from forcing an unbounded directory walk and returning a multi-megabyte JSON response.

internal/telegram/e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ func TestE2E_FullCallbackFlow(t *testing.T) {
336336
capturedChatID int64
337337
capturedData string
338338
)
339-
handler.OnCallbackQuery = func(chatID int64, callbackData string) (string, error) {
339+
handler.OnCallbackQuery = func(chatID int64, callbackData string, userID int64) (string, error) {
340340
capturedChatID = chatID
341341
capturedData = callbackData
342342
return "You selected option 1!", nil

internal/telegram/handler.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ type Handler struct {
5757
OnTextMessage func(chatID int64, messageID int, text string, forwarded bool, userID int64) (string, error)
5858

5959
// OnCallbackQuery is called when a callback query is received and
60-
// it was NOT handled by the TelegramApprover. Returns the response
61-
// text (may be empty).
62-
OnCallbackQuery func(chatID int64, callbackData string) (string, error)
60+
// it was NOT handled by the TelegramApprover. userID is the Telegram
61+
// user who pressed the button, so callers can bind callbacks to the
62+
// originating user and reject stale or cross-user button presses.
63+
// Returns the response text (may be empty).
64+
OnCallbackQuery func(chatID int64, callbackData string, userID int64) (string, error)
6365

6466
// OnCommand is called when a bot command (e.g. /start) is received.
6567
// userID is the Telegram user who sent the command.
@@ -149,8 +151,8 @@ func defaultTextHandler() func(int64, int, string, bool, int64) (string, error)
149151
}
150152

151153
// defaultCallbackHandler returns a default OnCallbackQuery callback.
152-
func defaultCallbackHandler() func(int64, string) (string, error) {
153-
return func(_ int64, _ string) (string, error) {
154+
func defaultCallbackHandler() func(int64, string, int64) (string, error) {
155+
return func(_ int64, _ string, _ int64) (string, error) {
154156
return "Not implemented yet: callback query", nil
155157
}
156158
}
@@ -409,7 +411,7 @@ func (h *Handler) handleCallback(cq *CallbackQuery) {
409411
}
410412

411413
if h.OnCallbackQuery != nil {
412-
resp, err := h.OnCallbackQuery(cq.Message.Chat.ID, cq.Data)
414+
resp, err := h.OnCallbackQuery(cq.Message.Chat.ID, cq.Data, userID)
413415
if err != nil {
414416
h.log.Error("callback query handler failed", "chat_id", cq.Message.Chat.ID, "data", cq.Data, "error", err)
415417
if h.OnError != nil {

internal/telegram/handler_error_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func TestHandleCallback_ErrorSentToUser(t *testing.T) {
6060
h := NewHandler(bot)
6161
h.Config.AllowAllUsers = true // routing test
6262

63-
h.OnCallbackQuery = func(chatID int64, data string) (string, error) {
63+
h.OnCallbackQuery = func(chatID int64, data string, userID int64) (string, error) {
6464
return "", fmt.Errorf("simulated callback failure: %s", data)
6565
}
6666

internal/telegram/handler_recover_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestHandleUpdate_RecoverFromPanicCallback(t *testing.T) {
6565
h := NewHandler(bot)
6666
h.Config.AllowAllUsers = true // routing test
6767

68-
h.OnCallbackQuery = func(chatID int64, data string) (string, error) {
68+
h.OnCallbackQuery = func(chatID int64, data string, userID int64) (string, error) {
6969
panic("simulated callback panic")
7070
}
7171

0 commit comments

Comments
 (0)