Skip to content

Commit 0053f3f

Browse files
authored
fix: scope ~/.odek/media to originating chat to block cross-chat re-disclosure (M-14) (#71)
1 parent 8db7647 commit 0053f3f

9 files changed

Lines changed: 264 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
170170
- **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.
171171
- **`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.
172172
- **Telegram class-trust guard + friction** (`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. After 3 approvals of the same class in 60 seconds, friction mode hides the Trust Session shortcut and adds a warning, forcing per-call approval and breaking reflexive tap-through.
173-
- **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 `/`.
173+
- **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 `/`. The shared `~/.odek/media/` directory is further scoped per chat: `ResolveMediaPathForChat` accepts a file there only when its basename contains the originating chat's `_chat<chatID>_` tag (matching downloads produced by `DownloadVoice`/`DownloadPhoto`/`DownloadDocument`) or it lives under `~/.odek/media/chat<chatID>/`, preventing one chat from re-disclosing another chat's uploaded documents or media.
174174
- **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.
175175
- **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.
176176
- **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.

cmd/odek/telegram.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,7 +1550,7 @@ func handleChatMessage(
15501550
// Wire the send_message tool so the agent can send intermediate
15511551
// messages, files, and interactive keyboards mid-task — not just
15521552
// at the final answer.
1553-
agentTools = append(agentTools, toolpkg.NewSendMessageTool(
1553+
agentTools = append(agentTools, toolpkg.NewSendMessageToolForChat(chatID,
15541554
func(text string, file string, buttons [][]map[string]string) error {
15551555
// Defense-in-depth: never send buttons that use reserved internal
15561556
// callback prefixes, even if the tool validation was bypassed.
@@ -2272,8 +2272,9 @@ func mediaTypeFromExt(path string) string {
22722272
// sendTelegramMedia sends a file as a Telegram media message with caption
22732273
// and optional inline keyboard. Detects the media type from file extension.
22742274
func sendTelegramMedia(bot *telegram.Bot, chatID int64, mediaType, path, caption string, buttons [][]map[string]string) error {
2275-
// Defense-in-depth: validate the path against the media allowlist.
2276-
resolved, err := telegram.ResolveMediaPath(path)
2275+
// Defense-in-depth: validate the path against the media allowlist, scoped to
2276+
// this chat so one chat cannot request another chat's downloaded media.
2277+
resolved, err := telegram.ResolveMediaPathForChat(path, chatID)
22772278
if err != nil {
22782279
return fmt.Errorf("telegram media: %w", err)
22792280
}

docs/SECURITY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@ The path is resolved to an absolute, cleaned form with `filepath.Abs`, symlinks
428428

429429
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 `/`.
430430

431+
The shared `~/.odek/media/` directory is additionally scoped per chat. Telegram callers use `ResolveMediaPathForChat`, which accepts a file inside `~/.odek/media/` only when its basename contains the originating chat's tag (`_chat<chatID>_`, matching the names produced by `DownloadVoice`, `DownloadPhoto`, and `DownloadDocument`) or when it lives under `~/.odek/media/chat<chatID>/`. This prevents a chat from asking the bot to re-send documents or media that were uploaded by a different chat.
432+
431433
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.
432434

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

docs/TELEGRAM.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,13 @@ The handler uses `sync.Map` for `TelegramApprover` instances, keyed by `chatID`.
210210

211211
### Outbound Media
212212

213-
The agent can send files back to the chat either by emitting a `MEDIA:` prefix in its final answer (`MEDIA:photo:/path`, `MEDIA:voice:/path`, `MEDIA:document:/path`) or by calling `send_message` with the `file` parameter. Before any upload, the path is validated by `internal/telegram.ResolveMediaPath`:
213+
The agent can send files back to the chat either by emitting a `MEDIA:` prefix in its final answer (`MEDIA:photo:/path`, `MEDIA:voice:/path`, `MEDIA:document:/path`) or by calling `send_message` with the `file` parameter. Before any upload, the path is validated by `internal/telegram.ResolveMediaPathForChat`:
214214

215215
- Allowed directories: current working directory, `~/.odek/media/`, and the system temporary directory.
216216
- The path is resolved to an absolute, cleaned form and checked against the allowlist.
217217
- Symlinks are rejected: on Unix the final component is opened with `O_NOFOLLOW` and verified with `fstat` to close a TOCTOU race; on other platforms it is checked with `os.Lstat`. The resolved path must not escape the allowlist.
218218
- Files outside the allowlist (e.g. `/home/user/.ssh/id_rsa`) are refused, closing prompt-injection-driven exfiltration.
219+
- Files inside the shared `~/.odek/media/` directory are additionally scoped to the originating chat. A file is accepted only when its basename contains the chat's `_chat<chatID>_` tag (matching files downloaded by the bot) or when it lives under `~/.odek/media/chat<chatID>/`. This prevents one chat from asking the bot to re-send documents or media uploaded by another chat.
219220

220221
## Slash Commands (`commands.go`)
221222

internal/telegram/handler.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,9 @@ func (h *Handler) sendMedia(chatID int64, text string, replyToMessageID int) {
493493
return
494494
}
495495

496-
// Validate and resolve the media path against the allowlist.
497-
resolved, err := ResolveMediaPath(filePath)
496+
// Validate and resolve the media path against the allowlist, scoped to this
497+
// chat so one chat cannot request another chat's downloaded media.
498+
resolved, err := ResolveMediaPathForChat(filePath, chatID)
498499
if err != nil {
499500
h.log.Error("media file rejected", "chat_id", chatID, "path", filePath, "error", err)
500501
if h.OnError != nil {

internal/telegram/media_path.go

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,33 @@ import (
2828
// otherwise allowed. This prevents a prompt-injected agent from exfiltrating
2929
// arbitrary files such as /home/user/.ssh/id_rsa via MEDIA:... or
3030
// send_message(file=...), especially when the bot was launched from $HOME or /.
31+
//
32+
// For Telegram callers that know the originating chat, use
33+
// ResolveMediaPathForChat instead. It scopes the ~/.odek/media directory to
34+
// files whose names contain the chat-specific "_chat<chatID>_" tag, preventing
35+
// one chat from requesting media downloaded for another chat.
3136
func ResolveMediaPath(path string) (string, error) {
37+
return resolveMediaPath(path, 0)
38+
}
39+
40+
// ResolveMediaPathForChat is like ResolveMediaPath but additionally binds files
41+
// inside ~/.odek/media to the supplied chat ID. Files downloaded from Telegram
42+
// are named with a "_chat<chatID>_" prefix (e.g. "doc_chat12345_report.pdf"),
43+
// so a path inside ~/.odek/media is accepted only when its basename contains
44+
// that prefix or it lives under ~/.odek/media/chat<chatID>/. This prevents a
45+
// chat from re-disclosing documents or media that originated in another chat.
46+
//
47+
// A chatID of 0 disables the chat-scoped check and behaves like
48+
// ResolveMediaPath. Real Telegram chat IDs are never zero, so callers should
49+
// pass the actual chat ID whenever it is available.
50+
func ResolveMediaPathForChat(path string, chatID int64) (string, error) {
51+
return resolveMediaPath(path, chatID)
52+
}
53+
54+
// resolveMediaPath implements the shared validation for ResolveMediaPath and
55+
// ResolveMediaPathForChat. When chatID != 0, paths inside the odek media
56+
// directory must be scoped to that chat.
57+
func resolveMediaPath(path string, chatID int64) (string, error) {
3258
if path == "" {
3359
return "", fmt.Errorf("media path is empty")
3460
}
@@ -66,7 +92,7 @@ func ResolveMediaPath(path string) (string, error) {
6692
}
6793
resolved = filepath.Clean(resolved)
6894

69-
allowed, err := mediaBaseDirs()
95+
allowed, mediaDir, err := mediaBaseDirs()
7096
if err != nil {
7197
return "", fmt.Errorf("media path: allowed dirs: %w", err)
7298
}
@@ -76,6 +102,12 @@ func ResolveMediaPath(path string) (string, error) {
76102
if err := checkMediaPathSensitivity(resolved); err != nil {
77103
return "", err
78104
}
105+
// Enforce per-chat isolation inside the shared media directory.
106+
if chatID != 0 && mediaDir != "" && isPathInside(resolved, mediaDir) {
107+
if err := checkChatMediaScope(resolved, mediaDir, chatID); err != nil {
108+
return "", err
109+
}
110+
}
79111
return resolved, nil
80112
}
81113
}
@@ -133,20 +165,43 @@ func BroadBaseWarning() string {
133165
return ""
134166
}
135167

168+
// checkChatMediaScope rejects media files inside ~/.odek/media that do not
169+
// belong to the requesting chat. Telegram downloads are tagged with
170+
// "_chat<chatID>_" in the basename; future per-chat subdirectories are also
171+
// allowed under ~/.odek/media/chat<chatID>/.
172+
func checkChatMediaScope(resolved, mediaDir string, chatID int64) error {
173+
// Allow files under a per-chat subdirectory.
174+
chatSubdir := filepath.Join(mediaDir, fmt.Sprintf("chat%d", chatID))
175+
if isPathInside(resolved, chatSubdir) {
176+
return nil
177+
}
178+
179+
// Allow files whose basename contains the chat tag (e.g. doc_chat12345_x.pdf).
180+
base := filepath.Base(resolved)
181+
tag := fmt.Sprintf("_chat%d_", chatID)
182+
if strings.Contains(base, tag) {
183+
return nil
184+
}
185+
186+
return fmt.Errorf("media path: file belongs to a different chat: %s", resolved)
187+
}
188+
136189
// mediaBaseDirs returns the resolved, cleaned allowed base directories for
137-
// outbound media paths. Errors retrieving individual directories are ignored
190+
// outbound media paths and the resolved odek media directory (empty string if
191+
// it cannot be located). Errors retrieving individual directories are ignored
138192
// where safe to do so (a directory that cannot be located cannot contain a
139193
// valid media file), but the current working directory and temp directory are
140194
// always included.
141-
func mediaBaseDirs() ([]string, error) {
195+
func mediaBaseDirs() (dirs []string, mediaDir string, err error) {
142196
cwd, err := os.Getwd()
143197
if err != nil {
144-
return nil, fmt.Errorf("getwd: %w", err)
198+
return nil, "", fmt.Errorf("getwd: %w", err)
145199
}
146200

147-
dirs := []string{cwd}
201+
dirs = []string{cwd}
148202

149-
if mediaDir, err := MediaDir(); err == nil {
203+
if md, err := MediaDir(); err == nil {
204+
mediaDir = md
150205
dirs = append(dirs, mediaDir)
151206
}
152207

@@ -160,7 +215,14 @@ func mediaBaseDirs() ([]string, error) {
160215
}
161216
resolved = append(resolved, d)
162217
}
163-
return resolved, nil
218+
// Return the resolved media dir, not the unresolved one.
219+
if mediaDir != "" {
220+
mediaDir = filepath.Clean(mediaDir)
221+
if real, err := filepath.EvalSymlinks(mediaDir); err == nil {
222+
mediaDir = filepath.Clean(real)
223+
}
224+
}
225+
return resolved, mediaDir, nil
164226
}
165227

166228
// isPathInside reports whether child is equal to or inside parent, using

internal/telegram/media_path_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package telegram
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"runtime"
@@ -422,6 +423,118 @@ func TestResolveMediaPath_RejectsOdekTrustAnchors(t *testing.T) {
422423
}
423424
}
424425

426+
// TestResolveMediaPathForChat_AllowsOwnChat verifies that files tagged for the
427+
// requesting chat inside ~/.odek/media are accepted.
428+
func TestResolveMediaPathForChat_AllowsOwnChat(t *testing.T) {
429+
setupMediaPathTest(t)
430+
431+
mediaDir, err := MediaDir()
432+
if err != nil {
433+
t.Fatalf("MediaDir: %v", err)
434+
}
435+
436+
chatID := int64(12345)
437+
cases := []string{
438+
fmt.Sprintf("doc_chat%d_report.pdf", chatID),
439+
fmt.Sprintf("photo_chat%d_abc.jpg", chatID),
440+
fmt.Sprintf("voice_chat%d_def.ogg", chatID),
441+
}
442+
443+
for _, name := range cases {
444+
t.Run(name, func(t *testing.T) {
445+
f := filepath.Join(mediaDir, name)
446+
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
447+
t.Fatalf("write media file: %v", err)
448+
}
449+
if _, err := ResolveMediaPathForChat(f, chatID); err != nil {
450+
t.Fatalf(" ResolveMediaPathForChat(%q, %d) error: %v", f, chatID, err)
451+
}
452+
})
453+
}
454+
}
455+
456+
// TestResolveMediaPathForChat_RejectsOtherChat verifies that files tagged for a
457+
// different chat inside ~/.odek/media are rejected, preventing cross-chat
458+
// re-disclosure of downloaded documents or media.
459+
func TestResolveMediaPathForChat_RejectsOtherChat(t *testing.T) {
460+
setupMediaPathTest(t)
461+
462+
mediaDir, err := MediaDir()
463+
if err != nil {
464+
t.Fatalf("MediaDir: %v", err)
465+
}
466+
467+
ownerChat := int64(12345)
468+
attackerChat := int64(99999)
469+
470+
cases := []string{
471+
fmt.Sprintf("doc_chat%d_report.pdf", ownerChat),
472+
fmt.Sprintf("photo_chat%d_abc.jpg", ownerChat),
473+
fmt.Sprintf("voice_chat%d_def.ogg", ownerChat),
474+
}
475+
476+
for _, name := range cases {
477+
t.Run(name, func(t *testing.T) {
478+
f := filepath.Join(mediaDir, name)
479+
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
480+
t.Fatalf("write media file: %v", err)
481+
}
482+
_, err := ResolveMediaPathForChat(f, attackerChat)
483+
if err == nil {
484+
t.Fatalf("expected rejection for other chat's file: %s", f)
485+
}
486+
if !strings.Contains(err.Error(), "different chat") {
487+
t.Errorf("expected 'different chat' in error, got: %v", err)
488+
}
489+
})
490+
}
491+
}
492+
493+
// TestResolveMediaPathForChat_AllowsChatSubdir verifies that files under a
494+
// per-chat subdirectory inside ~/.odek/media are accepted.
495+
func TestResolveMediaPathForChat_AllowsChatSubdir(t *testing.T) {
496+
setupMediaPathTest(t)
497+
498+
mediaDir, err := MediaDir()
499+
if err != nil {
500+
t.Fatalf("MediaDir: %v", err)
501+
}
502+
503+
chatID := int64(12345)
504+
subdir := filepath.Join(mediaDir, fmt.Sprintf("chat%d", chatID))
505+
if err := os.MkdirAll(subdir, 0755); err != nil {
506+
t.Fatalf("mkdir chat subdir: %v", err)
507+
}
508+
509+
f := filepath.Join(subdir, "shared-notes.pdf")
510+
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
511+
t.Fatalf("write media file: %v", err)
512+
}
513+
if _, err := ResolveMediaPathForChat(f, chatID); err != nil {
514+
t.Fatalf("ResolveMediaPathForChat(%q, %d) error: %v", f, chatID, err)
515+
}
516+
}
517+
518+
// TestResolveMediaPathForChat_BackwardCompatibility verifies that the
519+
// unscoped ResolveMediaPath still accepts any file in ~/.odek/media, preserving
520+
// behavior for callers that do not know the originating chat.
521+
func TestResolveMediaPathForChat_BackwardCompatibility(t *testing.T) {
522+
setupMediaPathTest(t)
523+
524+
mediaDir, err := MediaDir()
525+
if err != nil {
526+
t.Fatalf("MediaDir: %v", err)
527+
}
528+
529+
f := filepath.Join(mediaDir, "doc_chat12345_report.pdf")
530+
if err := os.WriteFile(f, []byte("x"), 0644); err != nil {
531+
t.Fatalf("write media file: %v", err)
532+
}
533+
if _, err := ResolveMediaPath(f); err != nil {
534+
t.Fatalf("ResolveMediaPath(%q) should still allow any media file: %v", f, err)
535+
}
536+
}
537+
425538
// TestBroadBaseWarning verifies that a warning is produced when the bot is
426539
// launched from $HOME or /.
427540
func TestBroadBaseWarning(t *testing.T) {

0 commit comments

Comments
 (0)