diff --git a/AGENTS.md b/AGENTS.md index 940a120..6a76d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`, `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. diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index 329fbcb..01d0576 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -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) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d2fa8dd..4bdd969 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -407,7 +407,7 @@ 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: @@ -415,7 +415,11 @@ When the agent emits `MEDIA:photo:/path`, `MEDIA:voice:/path`, `MEDIA:document:/ - `~/.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 @@ -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 | diff --git a/internal/telegram/approver.go b/internal/telegram/approver.go index 0d9ed20..5c390e2 100644 --- a/internal/telegram/approver.go +++ b/internal/telegram/approver.go @@ -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 diff --git a/internal/telegram/approver_test.go b/internal/telegram/approver_test.go index ed98994..674788d 100644 --- a/internal/telegram/approver_test.go +++ b/internal/telegram/approver_test.go @@ -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) + } +} diff --git a/internal/telegram/e2e_test.go b/internal/telegram/e2e_test.go index 0ea0546..f4be011 100644 --- a/internal/telegram/e2e_test.go +++ b/internal/telegram/e2e_test.go @@ -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 @@ -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 } diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index b6768d5..da40d12 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -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 { diff --git a/internal/telegram/handler_test.go b/internal/telegram/handler_test.go index e43b785..dc9ead3 100644 --- a/internal/telegram/handler_test.go +++ b/internal/telegram/handler_test.go @@ -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) @@ -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) @@ -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) { @@ -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) @@ -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) diff --git a/internal/telegram/media_path.go b/internal/telegram/media_path.go index a25a098..710f55a 100644 --- a/internal/telegram/media_path.go +++ b/internal/telegram/media_path.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/BackendStack21/odek/internal/danger" ) // ResolveMediaPath validates and resolves an agent-supplied media path before @@ -18,8 +20,14 @@ import ( // The input path is expanded to an absolute, cleaned path, any symlinks are // resolved, and the final resolved path must be a regular file inside one of // the allowed base directories. The final path component itself must not be a -// symlink. This prevents a prompt-injected agent from exfiltrating arbitrary -// files such as /home/user/.ssh/id_rsa via MEDIA:... or send_message(file=...). +// symlink. +// +// Additionally, paths inside well-known secret subtrees (for example ~/.ssh, +// ~/.aws, ~/.gnupg, ~/.odek trust anchors) and any file whose basename starts +// with ".env" are rejected even when the containing base directory is +// otherwise allowed. This prevents a prompt-injected agent from exfiltrating +// arbitrary files such as /home/user/.ssh/id_rsa via MEDIA:... or +// send_message(file=...), especially when the bot was launched from $HOME or /. func ResolveMediaPath(path string) (string, error) { if path == "" { return "", fmt.Errorf("media path is empty") @@ -65,6 +73,9 @@ func ResolveMediaPath(path string) (string, error) { for _, base := range allowed { if isPathInside(resolved, base) { + if err := checkMediaPathSensitivity(resolved); err != nil { + return "", err + } return resolved, nil } } @@ -72,6 +83,56 @@ func ResolveMediaPath(path string) (string, error) { return "", fmt.Errorf("media path outside allowed directories: %s", resolved) } +// checkMediaPathSensitivity rejects well-known secret subtrees and .env* files +// that must never be uploaded as Telegram media, even when they sit inside an +// otherwise allowed base directory (e.g. CWD == $HOME). +func checkMediaPathSensitivity(resolved string) error { + // Re-use the same path sensitivity model as the danger classifier. Anything + // ranked at system_write or above (~/.ssh, ~/.aws, ~/.gnupg, /etc, ~/.odek + // trust anchors, etc.) is treated as too sensitive for outbound media. + cls := danger.ClassifyPath(resolved) + if danger.Rank(cls) >= danger.Rank(danger.SystemWrite) { + return fmt.Errorf("media path: rejected sensitive path (%s): %s", cls, resolved) + } + + // Also reject .env* files anywhere in the tree — they routinely contain API + // keys and secrets and may live inside an allowed project directory. + base := strings.ToLower(filepath.Base(resolved)) + if strings.HasPrefix(base, ".env") { + return fmt.Errorf("media path: rejected .env file: %s", resolved) + } + + return nil +} + +// BroadBaseWarning returns a non-empty warning when the current working +// directory is an unusually broad base ($HOME or /). Launching the Telegram +// bot from these directories makes the CWD allowlist cover a large amount of +// sensitive filesystem territory, so callers should surface this in any +// approval prompt. +func BroadBaseWarning() string { + cwd, err := os.Getwd() + if err != nil { + return "" + } + cwd = filepath.Clean(cwd) + + if cwd == "/" { + return "The bot's working directory is /, so the media allowlist covers the whole filesystem." + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + home = filepath.Clean(home) + if cwd == home { + return "The bot's working directory is $HOME, so the media allowlist covers your entire home directory." + } + + return "" +} + // mediaBaseDirs returns the resolved, cleaned allowed base directories for // outbound media paths. Errors retrieving individual directories are ignored // where safe to do so (a directory that cannot be located cannot contain a diff --git a/internal/telegram/media_path_test.go b/internal/telegram/media_path_test.go index 8614455..e3dafba 100644 --- a/internal/telegram/media_path_test.go +++ b/internal/telegram/media_path_test.go @@ -40,6 +40,26 @@ func setupMediaPathTest(t *testing.T) (outsideDir string) { return outsideDir } +// makeTestHome creates a temporary directory that is treated as $HOME for the +// duration of the test. It is placed under the real user home directory so it +// is not classified as a system temp directory by danger.ClassifyPath, which +// would otherwise mask secret-subtree checks. +func makeTestHome(t *testing.T) string { + t.Helper() + realHome, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir: %v", err) + } + home, err := os.MkdirTemp(realHome, "odek_test_home_*") + if err != nil { + t.Fatalf("mkdir test home: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + return home +} + // TestResolveMediaPath_AllowedDirs verifies that files inside the allowed // directories (cwd, ~/.odek/media, temp dir) are accepted. func TestResolveMediaPath_AllowedDirs(t *testing.T) { @@ -315,3 +335,120 @@ func TestResolveMediaPath_RejectsSymlinkToAllowedFile(t *testing.T) { t.Errorf("expected 'symlinks are not allowed' in error, got: %v", err) } } + +// TestResolveMediaPath_RejectsSensitiveSubtrees verifies that secret subtrees +// under $HOME are rejected even when CWD == $HOME makes them technically +// inside the allowlist. +func TestResolveMediaPath_RejectsSensitiveSubtrees(t *testing.T) { + home := makeTestHome(t) + t.Chdir(home) + + cases := []string{".ssh/id_rsa", ".aws/credentials", ".gnupg/secring.gpg"} + for _, sub := range cases { + t.Run(sub, func(t *testing.T) { + f := filepath.Join(home, sub) + if err := os.MkdirAll(filepath.Dir(f), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(f, []byte("secret"), 0600); err != nil { + t.Fatalf("write: %v", err) + } + _, err := ResolveMediaPath(f) + if err == nil { + t.Fatalf("expected rejection for sensitive path %s", f) + } + if !strings.Contains(err.Error(), "rejected sensitive path") { + t.Errorf("expected 'rejected sensitive path' in error, got: %v", err) + } + }) + } +} + +// TestResolveMediaPath_RejectsEnvFiles verifies that .env* files are rejected +// even when they live inside an otherwise allowed project directory. +func TestResolveMediaPath_RejectsEnvFiles(t *testing.T) { + home := makeTestHome(t) + t.Chdir(home) + + project := filepath.Join(home, "project") + if err := os.MkdirAll(project, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + f := filepath.Join(project, ".env.local") + if err := os.WriteFile(f, []byte("SECRET=1"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + _, err := ResolveMediaPath(f) + if err == nil { + t.Fatal("expected rejection for .env file") + } + if !strings.Contains(err.Error(), "rejected .env file") { + t.Errorf("expected 'rejected .env file' in error, got: %v", err) + } +} + +// TestResolveMediaPath_RejectsOdekTrustAnchors verifies that ~/.odek trust +// anchors are rejected while ~/.odek/media remains allowed for re-upload. +func TestResolveMediaPath_RejectsOdekTrustAnchors(t *testing.T) { + home := makeTestHome(t) + t.Chdir(home) + + // Trust anchor must be rejected. + cfg := filepath.Join(home, ".odek", "config.json") + if err := os.MkdirAll(filepath.Dir(cfg), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(cfg, []byte("{}"), 0600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := ResolveMediaPath(cfg); err == nil { + t.Fatal("expected rejection for ~/.odek/config.json") + } else if !strings.Contains(err.Error(), "rejected sensitive path") { + t.Errorf("expected 'rejected sensitive path' in error, got: %v", err) + } + + // Media dir must remain allowed. + mediaDir, err := MediaDir() + if err != nil { + t.Fatalf("MediaDir: %v", err) + } + mf := filepath.Join(mediaDir, "allowed-media.txt") + if err := os.WriteFile(mf, []byte("x"), 0644); err != nil { + t.Fatalf("write media file: %v", err) + } + if _, err := ResolveMediaPath(mf); err != nil { + t.Fatalf("media dir file should be allowed: %v", err) + } +} + +// TestBroadBaseWarning verifies that a warning is produced when the bot is +// launched from $HOME or /. +func TestBroadBaseWarning(t *testing.T) { + home := makeTestHome(t) + + t.Run("home cwd warns", func(t *testing.T) { + t.Chdir(home) + if w := BroadBaseWarning(); w == "" { + t.Error("expected warning when cwd == $HOME") + } + }) + + t.Run("root cwd warns", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("root cwd test is Unix-specific") + } + t.Chdir("/") + if w := BroadBaseWarning(); w == "" { + t.Error("expected warning when cwd == /") + } + }) + + t.Run("normal cwd is silent", func(t *testing.T) { + sub := t.TempDir() + t.Chdir(sub) + if w := BroadBaseWarning(); w != "" { + t.Errorf("expected no warning, got %q", w) + } + }) +}