Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
- **Telegram photo caption wrapping** (`cmd/odek/telegram.go`) — photo captions cross the Telegram trust boundary, so they are wrapped as untrusted content both when passed to the local vision model and when injected into the main agent's user message.
- **`send_message` callback prefix restriction** (`internal/tool/send_message.go` + `cmd/odek/telegram.go`) — the `send_message` tool rejects any button whose `callback_data` starts with a reserved internal prefix (`apr:`, `den:`, `trs:`, `clarify:`, `skill_save:`, `skill_skip:`); only user-facing `cb:` callbacks are allowed. The Telegram sender closure validates again as defense-in-depth, preventing a forged approval or skill button.
- **Telegram class-trust guard** (`internal/telegram/approver.go`) — the "Trust Session" button is hidden for `destructive`, `blocked`, `unknown`, and the synthetic `tool_batch` class, and a trust callback for those classes is treated as a denial. This matches the TTY/Web approver policy and prevents one batch approval from blanket-authorizing later destructive operations.
- **Telegram outbound media path allowlist** (`internal/telegram/media_path.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). On Unix the final component is opened with `O_NOFOLLOW` and `fstat`'d to avoid a symlink TOCTOU race; `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist, preventing prompt-injection-driven exfiltration of arbitrary files.
- **Telegram outbound media hardening** (`internal/telegram/media_path.go` + `internal/telegram/approver.go` + `internal/telegram/handler.go` + `internal/tool/send_message.go` + `cmd/odek/telegram.go`) — paths supplied to `MEDIA:...` prefixes or `send_message(file=...)` are resolved to an absolute path and verified against an allowlist (cwd, `~/.odek/media/`, system temp dir). On Unix the final component is opened with `O_NOFOLLOW` and `fstat`'d to avoid a symlink TOCTOU race; `filepath.EvalSymlinks` ensures the resolved path does not escape the allowlist. Additionally, well-known secret subtrees (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.odek` trust anchors, etc.) and any file whose basename starts with `.env` are rejected outright, and every outbound media upload now requires explicit user approval via `TelegramApprover.PromptMedia`, with an extra warning when the bot was launched from `$HOME` or `/`.
- **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.
- **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.
- **Session ID entropy + session-scoped auth tokens** (`internal/session/session.go`, `cmd/odek/serve.go`) — session IDs now carry 128 bits of randomness (16 bytes / 32 hex chars); each session stores a 256-bit `AuthToken` required by `GET/DELETE/POST /api/sessions/<id>`, `POST /api/cancel`, and WebSocket session-resume messages via `X-Session-Token` header, `session_token` cookie, or `auth_token` WS field. Per-IP rate limiting (60/min) on session lookups adds a brute-force backstop.
Expand Down
5 changes: 5 additions & 0 deletions cmd/odek/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,11 @@ func handleChatMessage(
safeText := telegram.EscapeMarkdown(text)

if file != "" {
// Outbound media can exfiltrate arbitrary files; require explicit
// user approval before resolving or uploading the path.
if err := approver.PromptMedia(file); err != nil {
return fmt.Errorf("send_message: media upload denied: %w", err)
}
// Detect media type from extension.
mediaType := mediaTypeFromExt(file)
return sendTelegramMedia(bot, chatID, mediaType, file, safeText, buttons)
Expand Down
10 changes: 7 additions & 3 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,15 +407,19 @@ The Telegram bot previously used a PID file at `~/.odek/telegram.pid` to enforce

The `send_message` tool lets the agent send inline keyboard buttons. Each button's `callback_data` is validated by the tool and again by the Telegram sender closure: any value that starts with a reserved internal prefix (`apr:`, `den:`, `trs:`, `clarify:`, `skill_save:`, `skill_skip:`) is rejected. Only user-facing `cb:` callbacks are allowed. This prevents a compromised or prompt-injected agent from presenting a button that, when clicked, would forge an approval decision or trigger a skill action.

### 23. Telegram outbound media path allowlist
### 23. Telegram outbound media hardening

When the agent emits `MEDIA:photo:/path`, `MEDIA:voice:/path`, `MEDIA:document:/path`, or `send_message` with a `file`, the path is validated by `internal/telegram.ResolveMediaPath` before upload. Only paths inside an allowed base directory are permitted:

- the current working directory,
- `~/.odek/media/`, and
- the system temporary directory.

The path is resolved to an absolute, cleaned form with `filepath.Abs`, symlinks are resolved with `filepath.EvalSymlinks`, and the final component is verified with an atomic `O_NOFOLLOW` open + `fstat` (Unix). If the final component is a symlink, or if the resolved path escapes the allowlist, the upload is rejected. This closes the arbitrary-file-read/exfiltration vector where a prompt-injected agent asks the bot to send files such as `/home/user/.ssh/id_rsa`.
The path is resolved to an absolute, cleaned form with `filepath.Abs`, symlinks are resolved with `filepath.EvalSymlinks`, and the final component is verified with an atomic `O_NOFOLLOW` open + `fstat` (Unix). If the final component is a symlink, or if the resolved path escapes the allowlist, the upload is rejected.

On top of the allowlist, `ResolveMediaPath` now rejects well-known secret subtrees (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.odek` trust anchors, etc.) and any file whose basename starts with `.env`, so project API keys and host secrets cannot be uploaded even when the bot is launched from a broad base such as `$HOME` or `/`.

Finally, every outbound media upload requires explicit user approval via `TelegramApprover.PromptMedia` (`internal/telegram/approver.go`). The approval card shows the full file path and the `network_egress` risk class, and adds an explicit warning when the current working directory is `$HOME` or `/`. If no approver is registered (e.g. a standalone `Handler` outside the bot runtime), the upload is denied outright.

### 24. Session ID entropy + session-scoped auth tokens

Expand Down Expand Up @@ -663,7 +667,7 @@ A prompt-injected agent could overwrite `schedules.json` to install persistent c
| Local process brute-forces session IDs to read transcripts | 128-bit IDs + session-scoped auth tokens + per-IP rate limiting |
| Telegram bot scanned by random user | Allowlist enforced before any tool call |
| Agent sends fake approval/skill button via `send_message` | Reserved internal callback prefixes rejected; only `cb:` allowed |
| Agent exfiltrates arbitrary file via Telegram media | Outbound paths restricted to cwd, `~/.odek/media/`, and temp dir; symlinks rejected |
| Agent exfiltrates arbitrary file via Telegram media | Outbound paths restricted to cwd, `~/.odek/media/`, and temp dir; secret subtrees and `.env*` files rejected; explicit user approval required for every upload |
| Auto-saved skill auto-activates on next session | Provenance gate pins NeedsReview skills to Lazy |
| Memory replays a previously-injected episode forever | Tainted episodes filtered from `Search` |
| User reflex-approves a destructive class after many benign ones | Friction mode requires typed `approve` + 1.5 s pause |
Expand Down
11 changes: 11 additions & 0 deletions internal/telegram/approver.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ func (a *TelegramApprover) PromptOperation(op danger.ToolOperation) error {
return a.PromptCommand(op.Risk, desc, "")
}

// PromptMedia asks the user to approve an outbound Telegram media upload.
// The file path is shown in full and, when the bot was launched from a broad
// base such as $HOME or /, an explicit warning is added to the prompt.
func (a *TelegramApprover) PromptMedia(path string) error {
desc := "Outbound Telegram media upload"
if w := BroadBaseWarning(); w != "" {
desc += "\n⚠️ " + w
}
return a.PromptCommand(danger.NetworkEgress, path, desc)
}

// HandleCallback processes a callback query from an inline keyboard approval.
// It parses the callback data, looks up the pending request, and unblocks
// the waiting goroutine. Callbacks are only accepted from the originating
Expand Down
136 changes: 136 additions & 0 deletions internal/telegram/approver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -616,3 +616,139 @@ func extractCallbackID(body, prefix string) string {
}
return rest
}

// TestPromptMedia_Approves verifies that PromptMedia sends an approval prompt
// for an outbound media upload and returns nil when the user approves.
func TestPromptMedia_Approves(t *testing.T) {
rec := new(requestRecorder)
ts := testServer(t, rec)
defer ts.Close()
bot := testBot(t, ts)

a := NewTelegramApprover(bot, 1, 0)
done := make(chan error, 1)
go func() { done <- a.PromptMedia("/tmp/photo.jpg") }()

// Wait for the prompt to be registered.
var body string
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rec.mu.Lock()
if len(rec.requests) > 0 {
body = rec.requests[len(rec.requests)-1].Body
}
rec.mu.Unlock()
if body != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if body == "" {
t.Fatal("prompt request was not sent")
}
if !strings.Contains(body, "/tmp/photo.jpg") {
t.Errorf("approval prompt must show the media path, got body:\n%s", body)
}
if !strings.Contains(body, "network_egress") {
t.Errorf("approval prompt must show the network_egress risk class, got body:\n%s", body)
}

id := extractCallbackID(body, cbPrefixApprove)
if id == "" {
t.Fatal("could not extract approve callback id")
}
a.HandleCallback(cbPrefixApprove+id, 0)
if err := <-done; err != nil {
t.Fatalf("approve should succeed: %v", err)
}
}

// TestPromptMedia_Deny verifies that PromptMedia returns an error when the
// user denies the upload.
func TestPromptMedia_Deny(t *testing.T) {
rec := new(requestRecorder)
ts := testServer(t, rec)
defer ts.Close()
bot := testBot(t, ts)

a := NewTelegramApprover(bot, 1, 0)
done := make(chan error, 1)
go func() { done <- a.PromptMedia("/tmp/photo.jpg") }()

var body string
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rec.mu.Lock()
if len(rec.requests) > 0 {
body = rec.requests[len(rec.requests)-1].Body
}
rec.mu.Unlock()
if body != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if body == "" {
t.Fatal("prompt request was not sent")
}

id := extractCallbackID(body, cbPrefixDeny)
if id == "" {
t.Fatal("could not extract deny callback id")
}
a.HandleCallback(cbPrefixDeny+id, 0)
err := <-done
if err == nil {
t.Fatal("deny should return an error")
}
if !strings.Contains(err.Error(), "denied") {
t.Errorf("expected 'denied' in error, got: %v", err)
}
}

// TestPromptMedia_BroadBaseWarning verifies that the approval prompt includes
// a warning when the bot is launched from $HOME.
func TestPromptMedia_BroadBaseWarning(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Chdir(home)

rec := new(requestRecorder)
ts := testServer(t, rec)
defer ts.Close()
bot := testBot(t, ts)

a := NewTelegramApprover(bot, 1, 0)
done := make(chan error, 1)
go func() { done <- a.PromptMedia("/home/user/project/plot.png") }()

var body string
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
rec.mu.Lock()
if len(rec.requests) > 0 {
body = rec.requests[len(rec.requests)-1].Body
}
rec.mu.Unlock()
if body != "" {
break
}
time.Sleep(10 * time.Millisecond)
}
if body == "" {
t.Fatal("prompt request was not sent")
}
if !strings.Contains(body, "$HOME") {
t.Errorf("approval prompt must warn when cwd == $HOME, got body:\n%s", body)
}

id := extractCallbackID(body, cbPrefixApprove)
if id == "" {
t.Fatal("could not extract approve callback id")
}
a.HandleCallback(cbPrefixApprove+id, 0)
if err := <-done; err != nil {
t.Fatalf("approve should succeed: %v", err)
}
}
10 changes: 10 additions & 0 deletions internal/telegram/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,11 @@ func TestE2E_MediaFlow(t *testing.T) {
handler := NewHandler(bot)
handler.Config.AllowAllUsers = true // routing test

// Auto-approve outbound media so the test verifies the upload path.
approver := NewTelegramApprover(bot, 777, 0)
approver.SetTrustAll(true)
handler.SetApprover(777, approver)

// OnTextMessage returns a MEDIA:photo response.
handler.OnTextMessage = func(chatID int64, messageID int, text string, _ bool, _ int64) (string, error) {
return "MEDIA:photo:" + tmpPath, nil
Expand Down Expand Up @@ -722,6 +727,11 @@ func TestE2E_VoiceMediaFlow(t *testing.T) {
handler := NewHandler(bot)
handler.Config.AllowAllUsers = true // routing test

// Auto-approve outbound media so the test verifies the upload path.
approver := NewTelegramApprover(bot, 888, 0)
approver.SetTrustAll(true)
handler.SetApprover(888, approver)

handler.OnTextMessage = func(chatID int64, messageID int, text string, _ bool, _ int64) (string, error) {
return "MEDIA:voice:" + tmpPath, nil
}
Expand Down
20 changes: 20 additions & 0 deletions internal/telegram/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,26 @@ func (h *Handler) sendMedia(chatID int64, text string, replyToMessageID int) {
mediaType := parts[0]
filePath := parts[1]

// Outbound media can exfiltrate arbitrary files, so it requires an
// explicit user approval before the path is resolved or uploaded. The
// Telegram bot registers a per-chat approver in production; without one
// we fail closed.
a := h.GetApprover(chatID)
if a == nil {
h.log.Error("media upload rejected: no approver configured", "chat_id", chatID, "path", filePath)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: media upload rejected: no approver configured"))
}
return
}
if err := a.PromptMedia(filePath); err != nil {
h.log.Error("media upload denied", "chat_id", chatID, "path", filePath, "error", err)
if h.OnError != nil {
h.OnError(chatID, fmt.Errorf("telegram: media upload denied: %s: %w", filePath, err))
}
return
}

// Validate and resolve the media path against the allowlist.
resolved, err := ResolveMediaPath(filePath)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/telegram/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,9 @@ func TestSendResponse_MediaPhoto(t *testing.T) {
defer ts.Close()
bot := testBot(t, ts)
h := NewHandler(bot)
approver := NewTelegramApprover(bot, 123, 0)
approver.SetTrustAll(true)
h.SetApprover(123, approver)

h.SendResponse(123, "MEDIA:photo:"+tmpPath, 0)

Expand Down Expand Up @@ -1163,6 +1166,9 @@ func TestSendResponse_MediaVoice(t *testing.T) {
defer ts.Close()
bot := testBot(t, ts)
h := NewHandler(bot)
approver := NewTelegramApprover(bot, 456, 0)
approver.SetTrustAll(true)
h.SetApprover(456, approver)

h.SendResponse(456, "MEDIA:voice:"+tmpPath, 0)

Expand All @@ -1185,6 +1191,9 @@ func TestSendResponse_MediaFileNotFound(t *testing.T) {
defer ts.Close()
bot := testBot(t, ts)
h := NewHandler(bot)
approver := NewTelegramApprover(bot, 123, 0)
approver.SetTrustAll(true)
h.SetApprover(123, approver)

errCalled := false
h.OnError = func(_ int64, err error) {
Expand Down Expand Up @@ -1218,6 +1227,9 @@ func TestSendResponse_MediaUnknownType(t *testing.T) {
defer ts.Close()
bot := testBot(t, ts)
h := NewHandler(bot)
approver := NewTelegramApprover(bot, 123, 0)
approver.SetTrustAll(true)
h.SetApprover(123, approver)

h.SendResponse(123, "MEDIA:video:"+tmpPath, 0)

Expand Down Expand Up @@ -1248,6 +1260,9 @@ func TestSendResponse_MediaDocument(t *testing.T) {
defer ts.Close()
bot := testBot(t, ts)
h := NewHandler(bot)
approver := NewTelegramApprover(bot, 456, 0)
approver.SetTrustAll(true)
h.SetApprover(456, approver)

h.SendResponse(456, "MEDIA:document:"+tmpPath, 0)

Expand Down
Loading
Loading