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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
- **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.
- **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.
- **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.
- **Skill/episode untrusted wrapper** (`internal/loop/loop.go` + `odek.go`) — skill context and retrieved session-episode context are passed through the caller-provided untrusted wrapper (the same nonce'd `<untrusted_content_*>` boundary used for tool output) before being injected into the model's system context. This prevents a compromised or tainted skill/episode from being treated as trusted system instructions.
- **`env` / `printenv` environment-dump gate** (`internal/danger/classifier.go`) — bare `env` and `printenv` invocations are classified as `system_write` because they can leak process-environment secrets that the redaction scanner does not recognise. `env VAR=value <cmd>` still classifies `<cmd>` normally.
Expand Down
80 changes: 70 additions & 10 deletions cmd/odek/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,9 +385,9 @@ func telegramCmd(args []string) error {
return formatStats(cs), nil
}

// Handle /sessions — list recent sessions from the store.
// Handle /sessions — list recent sessions belonging to this chat.
if cmdName == "sessions" {
infos, err := sessionManager.ListSessions(10)
infos, err := sessionManager.ListSessions(chatID, 10)
if err != nil {
return fmt.Sprintf("❌ Failed to list sessions: %v", err), nil
}
Expand All @@ -411,7 +411,7 @@ func telegramCmd(args []string) error {
return b.String(), nil
}

// Handle /resume <id> — switch to a different session.
// Handle /resume <id> — switch to a different session owned by this chat.
if cmdName == "resume" {
sessionID := strings.TrimSpace(argsStr)
if sessionID == "" {
Expand All @@ -431,7 +431,7 @@ func telegramCmd(args []string) error {
), nil
}

// Handle /prune [days] — clean up old sessions and plans.
// Handle /prune [days] — clean up old sessions and plans for this chat.
if cmdName == "prune" {
days := 30
if strings.TrimSpace(argsStr) != "" {
Expand All @@ -441,11 +441,11 @@ func telegramCmd(args []string) error {
return "❗ Usage: `/prune [days]`\n\nExample: `/prune 7` to remove sessions and plans older than 7 days.", nil
}
}
sessionsRemoved, err := sessionManager.PruneSessions(days)
sessionsRemoved, err := sessionManager.PruneSessions(chatID, days)
if err != nil {
return fmt.Sprintf("❌ Failed to prune sessions: %v", err), nil
}
plansRemoved, err := sessionManager.PrunePlans(days)
plansRemoved, err := sessionManager.PrunePlans(chatID, days)
if err != nil {
return fmt.Sprintf("❌ Failed to prune plans: %v", err), nil
}
Expand All @@ -471,26 +471,78 @@ func telegramCmd(args []string) error {
return "❗ Usage: `/plan <description>`\n\nExample: `/plan Add user authentication with OAuth2`", nil
}
slug := telegram.Slugify(description)
planFile := fmt.Sprintf("~/.odek/plans/chat%d/%s.md", chatID, slug)
prompt := fmt.Sprintf(
"Create a detailed implementation plan for: %s\n\n"+
"Save the plan as a markdown file to `~/.odek/plans/%s.md`. "+
"Save the plan as a markdown file to `%s`. "+
"The plan should include:\n"+
"- Overview and goals\n"+
"- Architecture / design\n"+
"- Implementation steps (bite-sized tasks)\n"+
"- File paths and key code locations\n"+
"- Testing strategy\n\n"+
"Use your write_file tool to save the plan.",
description, slug,
description, planFile,
)
go handleChatMessage(chatID, messageID, userID, prompt, bot, handler, sessionManager,
resolved, systemMessage, handlerLog)
return fmt.Sprintf("📝 *Planning* `%s`…\n\n_Generating plan for: %s_", slug, description), nil
}

// Handle /plan-resume — inject most recent plan into session context.
// Handle /plans — list saved plans for this chat.
if cmdName == "plans" {
infos, err := telegram.ListPlans(chatID, 20)
if err != nil {
return fmt.Sprintf("❌ Failed to list plans: %v", err), nil
}
if len(infos) == 0 {
return "📋 *Plans* — No plans found.\n\nCreate one with `/plan <description>`", nil
}
var b strings.Builder
b.WriteString("📋 *Plans*\n\n")
for _, p := range infos {
ago := time.Since(p.ModTime).Round(time.Minute)
fmt.Fprintf(&b, "`%s` — %s ago\n", p.Slug, ago)
if p.Preview != "" {
fmt.Fprintf(&b, " _%s_\n", truncateStr(p.Preview, 60))
}
}
b.WriteString("\nUse `/plan_view <slug>` to read a plan.")
return b.String(), nil
}

// Handle /plan_view <slug> — read a plan for this chat.
if cmdName == "plan_view" {
slug := strings.TrimSpace(argsStr)
if slug == "" {
return "❗ Usage: `/plan_view <slug>`\n\nUse `/plans` to see available plans.", nil
}
matched, content, err := telegram.ReadPlan(chatID, slug)
if err != nil {
return fmt.Sprintf("❌ %v", err), nil
}
if len(content) > 3900 {
content = content[:3900] + "\n\n… _(truncated — plan too long for Telegram)_"
}
return fmt.Sprintf("📄 *Plan: `%s`*\n\n%s", matched, content), nil
}

// Handle /plan_delete <slug> — delete a plan for this chat.
if cmdName == "plan_delete" {
slug := strings.TrimSpace(argsStr)
if slug == "" {
return "❗ Usage: `/plan_delete <slug>`\n\nUse `/plans` to see available plans.", nil
}
matched, err := telegram.DeletePlan(chatID, slug)
if err != nil {
return fmt.Sprintf("❌ %v", err), nil
}
return fmt.Sprintf("🗑️ *Plan deleted*: `%s`", matched), nil
}

// Handle /plan-resume — inject most recent plan for this chat into session context.
if cmdName == "plan_resume" {
slug, content, err := telegram.MostRecentPlan()
slug, content, err := telegram.MostRecentPlan(chatID)
if err != nil {
return fmt.Sprintf("❌ %v", err), nil
}
Expand Down Expand Up @@ -2280,6 +2332,14 @@ func buttonsToMarkup(buttons [][]map[string]string) *telegram.InlineKeyboardMark
return markup
}

// truncateStr shortens s to maxLen, appending "…" if trimmed.
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "…"
}

// deleteToolTraceMessages deletes all individual tool trace messages for a chat.
// Used to clean up after the agent finishes (success or error).
func deleteToolTraceMessages(bot *telegram.Bot, chatID int64, msgIDs *sync.Map) {
Expand Down
8 changes: 0 additions & 8 deletions cmd/odek/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -744,14 +744,6 @@ func narratorToolCallMessage(name, args string) string {
}
}

// truncateStr truncates a string for display in test logs.
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

// ── Tool Latency ────────────────────────────────────────────────────

// TestToolLatencyTracking verifies that recordToolStart and popToolLatency
Expand Down
12 changes: 12 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,18 @@ Plan files live in `~/.odek/plans/` and are loaded by `/plan_view` and injected

Telegram log files were created with world-readable `0644` permissions, exposing chat IDs and task snippets to other local users. `NewFileLogger` now creates log files with `0600` and `os.Chmod`'s existing files to the same mode.

### 39c. Telegram chat-scoped sessions and plans

The Telegram bot's `/sessions`, `/resume`, `/prune`, `/plans`, `/plan_view`, `/plan_delete`, and `/plan_resume` commands previously operated on the global `~/.odek/sessions` and `~/.odek/plans` stores that are shared with the CLI. Any allowed chat could list the operator's CLI sessions (including task snippets that often contain secrets), resume one so its history entered the attacker's chat context, prune the operator's history, or read/delete plans created by other chats.

These commands are now scoped to the requesting chat:

- Each Telegram chat owns sessions with IDs of the form `tg-<chatID>` (and timestamped archives `tg-<chatID>-<YYYYMMDD>-<HHMMSS>`). `ListSessions`, `ResumeSession`, and `PruneSessions` only consider sessions whose ID starts with the caller's `tg-<chatID>` prefix.
- `ResumeSession` explicitly rejects a direct ID that belongs to a different chat.
- Plans are stored under `~/.odek/plans/chat<chatID>/`. `ListPlans`, `ReadPlan`, `DeletePlan`, and `MostRecentPlan` only look inside the caller's per-chat directory. Chat ID `0` is reserved as a global/admin scope mapping to the root `~/.odek/plans/` directory.

This removes the cross-chat session/plan disclosure path while keeping the CLI and admin flows functional.

### 40. `/api/resources` result limit cap

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.
Expand Down
25 changes: 13 additions & 12 deletions docs/TELEGRAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,12 @@ The agent can send files back to the chat either by emitting a `MEDIA:` prefix i
| `/mode` | Show current agent modes (interaction_mode, sandbox, skills) |
| `/restart` | Gracefully restart the bot process. Restricted to operator chats/users and rate-limited to once per 60 seconds. |
| `/plan <description>` | Create a new plan from a natural language description |
| `/plans` | List all saved plans |
| `/plan-view <slug>` | View a specific plan's content |
| `/plan-delete <slug>` | Delete a saved plan |
| `/sessions` | List recent conversation sessions |
| `/resume <session_id>` | Resume a previous session by ID |
| `/prune [days]` | Clean up old sessions (default: 30 days) |
| `/plans` | List saved plans for this chat |
| `/plan-view <slug>` | View a specific plan's content for this chat |
| `/plan-delete <slug>` | Delete a saved plan for this chat |
| `/sessions` | List recent conversation sessions for this chat |
| `/resume <session_id>` | Resume a previous session owned by this chat |
| `/prune [days]` | Clean up old sessions and plans for this chat (default: 30 days) |
| `/schedules` | List scheduled tasks (id, on/off, cron, next fire, last status) |
| `/schedule <subcommand>` | Manage scheduled tasks — `add`, `rm`, `enable`, `disable`, `run`, `next`, `view`. Mutating commands are restricted to configured operator chats/users. See [Managing schedules from Telegram](SCHEDULES.md#managing-from-telegram) |

Expand All @@ -262,6 +262,7 @@ The `SessionManager` manages per-chat Telegram agent conversations, backed by th
5. **Session recall** — the user message is saved to the session store *before* the agent loop runs, enabling `session_search` to find the current conversation's data during the same turn
6. Active sessions survive bot restarts — on reconnect, the session is loaded from disk
7. **/now archives** — using `/new` archives the current session with a timestamped ID (`tg-<chatID>-<YYYYMMDD>-<HHMMSS>`) before starting fresh. Archived sessions remain on disk and are visible via `odek session list`.
8. **Chat-scoped listing/resume/prune** — `/sessions`, `/resume`, and `/prune` only operate on sessions belonging to the requesting chat. A chat cannot list, resume, or delete another chat's sessions.

### Key Methods

Expand All @@ -278,7 +279,7 @@ The `clarifyChannels` sync.Map provides per-chat channels for the agent to ask t

## Plan Management (`plan.go`)

Plans are stored as markdown files in `~/.odek/plans/<slug>.md`. Each plan is created from a natural language description and persisted for later review.
Plans are stored as markdown files in `~/.odek/plans/chat<chatID>/<slug>.md`, isolating each Telegram chat's plans. Each plan is created from a natural language description and persisted for later review. The `/plan` command instructs the agent to save plans into the caller's per-chat directory, and `/plans`, `/plan_view`, `/plan_delete`, and `/plan_resume` only see plans belonging to that chat.

### Size Cap

Expand All @@ -292,11 +293,11 @@ To prevent a prompt-injected agent from causing an out-of-memory condition, plan
| Function | Purpose |
|---|---|
| `Slugify(description)` | Convert description to filesystem-safe slug (max 60 chars) |
| `ListPlans(limit)` | List plans sorted by modification time (newest first) |
| `ReadPlan(slug)` | Load plan content by exact slug or prefix match |
| `DeletePlan(slug)` | Delete plan by exact slug or prefix match |
| `MostRecentPlan()` | Return the most recently modified plan's content |
| `EnsurePlansDir()` | Create plans directory if it doesn't exist |
| `ListPlans(chatID, limit)` | List plans for a chat sorted by modification time (newest first) |
| `ReadPlan(chatID, slug)` | Load a chat's plan content by exact slug or prefix match |
| `DeletePlan(chatID, slug)` | Delete a chat's plan by exact slug or prefix match |
| `MostRecentPlan(chatID)` | Return the most recently modified plan's content for a chat |
| `EnsurePlansDir(chatID)` | Create per-chat plans directory if it doesn't exist |

### Slug Generation

Expand Down
132 changes: 132 additions & 0 deletions internal/telegram/chat_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package telegram

import (
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/BackendStack21/odek/internal/llm"
"github.com/BackendStack21/odek/internal/session"
)

// Regression tests for M-2: Telegram session/plan commands must be scoped
// to the requesting chat. Cross-chat access must be rejected.

func TestResumeSession_CrossChatRejected(t *testing.T) {
sm, _ := setupTestSessionManager(t)

const ownerChat int64 = 999
const attackerChat int64 = 100

if err := sm.Save(ownerChat, []llm.Message{{Role: "user", Content: "secret"}}); err != nil {
t.Fatalf("Save failed: %v", err)
}

_, err := sm.ResumeSession(attackerChat, "tg-999")
if err == nil {
t.Fatal("ResumeSession should reject a session belonging to a different chat")
}
if !strings.Contains(err.Error(), "different chat") {
t.Errorf("error = %q, want 'different chat'", err)
}
}

func TestListSessions_ChatScoped(t *testing.T) {
sm, _ := setupTestSessionManager(t)

for _, chatID := range []int64{111, 222, 333} {
if err := sm.Save(chatID, []llm.Message{{Role: "user", Content: "msg"}}); err != nil {
t.Fatalf("Save(%d) failed: %v", chatID, err)
}
}

infos, err := sm.ListSessions(222, 0)
if err != nil {
t.Fatalf("ListSessions failed: %v", err)
}
if len(infos) != 1 {
t.Errorf("ListSessions returned %d, want 1", len(infos))
}
if len(infos) == 1 && infos[0].ID != "tg-222" {
t.Errorf("session ID = %q, want tg-222", infos[0].ID)
}
}

func TestPruneSessions_ChatScoped(t *testing.T) {
sm, st := setupTestSessionManager(t)

makeOldSession := func(chatID int64, id string) {
sess := &session.Session{
ID: id,
CreatedAt: time.Now().Add(-60 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-60 * 24 * time.Hour),
Task: id,
Messages: nil,
}
if err := st.Save(sess); err != nil {
t.Fatalf("store.Save(%q) failed: %v", id, err)
}
}

makeOldSession(1, "tg-1-old")
makeOldSession(2, "tg-2-old")

removed, err := sm.PruneSessions(1, 30)
if err != nil {
t.Fatalf("PruneSessions failed: %v", err)
}
if removed != 1 {
t.Errorf("PruneSessions removed %d, want 1", removed)
}

// Chat 2's session must remain untouched.
_, err = st.Load("tg-2-old")
if err != nil {
t.Errorf("chat 2 session should not have been pruned: %v", err)
}
}

func TestReadPlan_ChatScoped(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

ownerDir := filepath.Join(tmp, ".odek", "plans", "chat111")
if err := os.MkdirAll(ownerDir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
os.WriteFile(filepath.Join(ownerDir, "secret.md"), []byte("secret plan"), 0644)

_, _, err := ReadPlan(222, "secret")
if err == nil {
t.Fatal("ReadPlan should reject a plan belonging to a different chat")
}
if !strings.Contains(err.Error(), "no plan") && !strings.Contains(err.Error(), "no plans found") {
t.Errorf("error = %q, want plan-not-found", err)
}
}

func TestDeletePlan_ChatScoped(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

ownerDir := filepath.Join(tmp, ".odek", "plans", "chat111")
if err := os.MkdirAll(ownerDir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
os.WriteFile(filepath.Join(ownerDir, "secret.md"), []byte("secret plan"), 0644)

_, err := DeletePlan(222, "secret")
if err == nil {
t.Fatal("DeletePlan should reject a plan belonging to a different chat")
}
if !strings.Contains(err.Error(), "no plan") && !strings.Contains(err.Error(), "no plans found") {
t.Errorf("error = %q, want plan-not-found", err)
}

// Ensure the owner's file is still there.
if _, err := os.Stat(filepath.Join(ownerDir, "secret.md")); os.IsNotExist(err) {
t.Error("owner's plan file was deleted by cross-chat call")
}
}
Loading
Loading