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 @@ -169,7 +169,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
- **Telegram singleton flock lock** (`cmd/odek/telegram.go` + `internal/flock`) — the Telegram bot now uses an advisory `flock` on `~/.odek/telegram.lock` instead of a PID file probed with signals. This removes the non-Linux path where a planted PID could cause odek to kill an arbitrary process.
- **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 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.
- **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.
Expand Down
6 changes: 4 additions & 2 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,11 +511,13 @@ The batch approval gate in `internal/loop/loop.go::classifyToolCall` only unders
- Shows the full command/path text instead of truncating at 120 characters, so the user sees exactly what is being authorized.
- Refuses to grant blanket trust (`SetTrustAll`) for any iteration that still contains an unclassifiable tool; those tools must be approved individually by their own internal gates.

### 35b. Telegram class-trust guard
### 35b. Telegram class-trust guard + friction

`internal/telegram/approver.go` previously offered a "Trust Session" button for every risk class, including `destructive`, `blocked`, `unknown`, and the synthetic `tool_batch` class. One tap on Trust Session cached approval for that class, so trusting a benign `tool_batch` card also silently approved every later destructive operation in the same session.

The Telegram approver now mirrors the TTY/Web policy: the Trust Session button is hidden for `destructive`, `blocked`, `unknown`, and `tool_batch`, and a trust callback for those classes is treated as a denial. This closes the approval-fatigue path where an injected batch of mixed-risk tools is reduced to a single tap.
The Telegram approver now mirrors the TTY/Web policy: the Trust Session button is hidden for `destructive`, `blocked`, `unknown`, and `tool_batch`, and a trust callback for those classes is treated as a denial.

In addition, a friction counter tracks approvals per class. After 3 approvals of the same class within 60 seconds, the next prompt for that class hides the Trust Session shortcut and adds a warning banner, forcing a per-call approval. This breaks the reflexive tap-through pattern that sustained LLM-driven approval pressure exploits.

### 36. Browser link URL wrapping

Expand Down
76 changes: 69 additions & 7 deletions internal/telegram/approver.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,33 @@ type TelegramApprover struct {
// approver will accept. Callbacks from other users are rejected to prevent
// group-chat approval hijacking. Zero means unknown (legacy allow-all).
userID int64

// FrictionThreshold is the number of approvals of the same class within
// FrictionWindow that triggers high-friction mode. In friction mode the
// Trust Session shortcut is hidden for that class, forcing a per-call
// approval and breaking reflexive tap-through. Zero disables friction.
FrictionThreshold int
FrictionWindow time.Duration

// approvalLog records approval timestamps per class for friction detection.
approvalLog map[danger.RiskClass][]time.Time
}

// NewTelegramApprover creates a TelegramApprover for the given chat and
// originating user. Callbacks are only accepted from userID; use 0 to allow
// callbacks from any user (legacy behavior, not recommended for groups).
func NewTelegramApprover(bot *Bot, chatID, userID int64) *TelegramApprover {
return &TelegramApprover{
bot: bot,
ChatID: chatID,
userID: userID,
pending: make(map[string]*pendingRequest),
trusted: make(map[danger.RiskClass]bool),
log: NewNopLogger(),
cancel: make(chan struct{}),
bot: bot,
ChatID: chatID,
userID: userID,
pending: make(map[string]*pendingRequest),
trusted: make(map[danger.RiskClass]bool),
log: NewNopLogger(),
cancel: make(chan struct{}),
FrictionThreshold: 3,
FrictionWindow: 60 * time.Second,
approvalLog: make(map[danger.RiskClass][]time.Time),
}
}

Expand All @@ -104,6 +117,39 @@ func (a *TelegramApprover) Cancel() {
}
}

// shouldFriction reports whether the user has approved at least
// FrictionThreshold operations of cls within FrictionWindow. As a side effect
// it prunes stale entries from the per-class approval log.
func (a *TelegramApprover) shouldFriction(cls danger.RiskClass) bool {
if a.FrictionThreshold <= 0 || a.FrictionWindow <= 0 {
return false
}

a.mu.Lock()
defer a.mu.Unlock()

cutoff := time.Now().Add(-a.FrictionWindow)
log := a.approvalLog[cls]
kept := log[:0]
for _, ts := range log {
if ts.After(cutoff) {
kept = append(kept, ts)
}
}
a.approvalLog[cls] = kept
return len(kept) >= a.FrictionThreshold
}

// recordApproval logs an approval timestamp for the given class.
func (a *TelegramApprover) recordApproval(cls danger.RiskClass) {
a.mu.Lock()
defer a.mu.Unlock()
if a.approvalLog == nil {
a.approvalLog = make(map[danger.RiskClass][]time.Time)
}
a.approvalLog[cls] = append(a.approvalLog[cls], time.Now())
}

// PromptCommand sends an approval request with inline keyboard and waits
// for the user to respond. Returns nil on approve/trust, error on deny/timeout.
// allowTrustForClass mirrors the TTY/Web approver policy: the highest-impact
Expand All @@ -126,6 +172,21 @@ func (a *TelegramApprover) PromptCommand(cls danger.RiskClass, cmd, description
id := a.newID()
allowTrust := allowTrustForClass(cls)

// Approval-fatigue mitigation: after FrictionThreshold approvals of the
// same class within FrictionWindow, force a per-call approval by hiding
// the Trust Session shortcut and surfacing a warning. This breaks the
// reflexive tap-through pattern that an injected batch of benign-looking
// operations is designed to exploit.
if a.shouldFriction(cls) {
allowTrust = false
warning := fmt.Sprintf("⚠️ You have approved %d %s operations in the last %s. Trust Session is disabled for this request.", a.FrictionThreshold, cls, a.FrictionWindow)
if description != "" {
description = warning + "\n" + description
} else {
description = warning
}
}

// Build the approval message — the full command is always shown so the
// user can make an informed decision.
text := buildApprovalText(cls, cmd, description)
Expand Down Expand Up @@ -195,6 +256,7 @@ func (a *TelegramApprover) PromptCommand(cls danger.RiskClass, cmd, description

switch action {
case "approve":
a.recordApproval(cls)
return nil
case "trust":
if !allowTrust {
Expand Down
106 changes: 106 additions & 0 deletions internal/telegram/approver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,112 @@ func TestTelegramApprover_TrustDeniedForToolBatch(t *testing.T) {
}
}

// TestTelegramApprover_FrictionDisablesTrust verifies that after enough
// approvals of a class within the friction window the Trust Session shortcut
// is hidden and a warning is shown.
func TestTelegramApprover_FrictionDisablesTrust(t *testing.T) {
rec := new(requestRecorder)
ts := testServer(t, rec)
defer ts.Close()
bot := testBot(t, ts)

a := NewTelegramApprover(bot, 1, 0)
a.FrictionThreshold = 2
a.FrictionWindow = 5 * time.Minute

// Record two prior system_write approvals to trigger friction.
a.recordApproval(danger.SystemWrite)
a.recordApproval(danger.SystemWrite)

done := make(chan error, 1)
go func() { done <- a.PromptCommand(danger.SystemWrite, "echo third", "test friction") }()

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, "Trust Session is disabled") {
t.Errorf("friction prompt should contain a warning, got body:\n%s", body)
}
if strings.Contains(body, "🔒 Trust Session") {
t.Errorf("friction prompt should not offer Trust Session, 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)
}
}

// TestTelegramApprover_NoFrictionBelowThreshold verifies that the Trust
// Session shortcut is still offered when the approval count is below the
// friction threshold.
func TestTelegramApprover_NoFrictionBelowThreshold(t *testing.T) {
rec := new(requestRecorder)
ts := testServer(t, rec)
defer ts.Close()
bot := testBot(t, ts)

a := NewTelegramApprover(bot, 1, 0)
a.FrictionThreshold = 3
a.FrictionWindow = 5 * time.Minute

// Record only two approvals — below the threshold.
a.recordApproval(danger.SystemWrite)
a.recordApproval(danger.SystemWrite)

done := make(chan error, 1)
go func() { done <- a.PromptCommand(danger.SystemWrite, "echo third", "test no friction") }()

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, "🔒 Trust Session") {
t.Errorf("prompt below threshold should still offer Trust Session, got body:\n%s", body)
}
if strings.Contains(body, "Trust Session is disabled") {
t.Errorf("prompt below threshold should not show friction warning, got body:\n%s", body)
}

id := extractCallbackID(body, cbPrefixDeny)
if id == "" {
t.Fatal("could not extract deny callback id")
}
a.HandleCallback(cbPrefixDeny+id, 0)
if err := <-done; err == nil {
t.Fatal("deny should return an error")
}
}

// extractCallbackID pulls the callback payload suffix for the given prefix
// from a Telegram sendMessage request body.
func extractCallbackID(body, prefix string) string {
Expand Down
Loading