Skip to content

Commit 5fa47f7

Browse files
authored
M1: add approval-friction counters to TelegramApprover
- Added FrictionThreshold/FrictionWindow and a per-class approval log to TelegramApprover. - After the threshold approvals within the window, PromptCommand hides the Trust Session shortcut and surfaces a warning, forcing per-call approval. - Added regression tests for friction disabling trust and for the normal below-threshold path. - Updated AGENTS.md and docs/SECURITY.md to describe the friction guard.
1 parent cb190ff commit 5fa47f7

4 files changed

Lines changed: 180 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
169169
- **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.
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.
172-
- **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.
172+
- **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.
173173
- **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 `/`.
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.

docs/SECURITY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -511,11 +511,13 @@ The batch approval gate in `internal/loop/loop.go::classifyToolCall` only unders
511511
- Shows the full command/path text instead of truncating at 120 characters, so the user sees exactly what is being authorized.
512512
- 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.
513513

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

516516
`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.
517517

518-
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.
518+
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.
519+
520+
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.
519521

520522
### 36. Browser link URL wrapping
521523

internal/telegram/approver.go

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,20 +67,33 @@ type TelegramApprover struct {
6767
// approver will accept. Callbacks from other users are rejected to prevent
6868
// group-chat approval hijacking. Zero means unknown (legacy allow-all).
6969
userID int64
70+
71+
// FrictionThreshold is the number of approvals of the same class within
72+
// FrictionWindow that triggers high-friction mode. In friction mode the
73+
// Trust Session shortcut is hidden for that class, forcing a per-call
74+
// approval and breaking reflexive tap-through. Zero disables friction.
75+
FrictionThreshold int
76+
FrictionWindow time.Duration
77+
78+
// approvalLog records approval timestamps per class for friction detection.
79+
approvalLog map[danger.RiskClass][]time.Time
7080
}
7181

7282
// NewTelegramApprover creates a TelegramApprover for the given chat and
7383
// originating user. Callbacks are only accepted from userID; use 0 to allow
7484
// callbacks from any user (legacy behavior, not recommended for groups).
7585
func NewTelegramApprover(bot *Bot, chatID, userID int64) *TelegramApprover {
7686
return &TelegramApprover{
77-
bot: bot,
78-
ChatID: chatID,
79-
userID: userID,
80-
pending: make(map[string]*pendingRequest),
81-
trusted: make(map[danger.RiskClass]bool),
82-
log: NewNopLogger(),
83-
cancel: make(chan struct{}),
87+
bot: bot,
88+
ChatID: chatID,
89+
userID: userID,
90+
pending: make(map[string]*pendingRequest),
91+
trusted: make(map[danger.RiskClass]bool),
92+
log: NewNopLogger(),
93+
cancel: make(chan struct{}),
94+
FrictionThreshold: 3,
95+
FrictionWindow: 60 * time.Second,
96+
approvalLog: make(map[danger.RiskClass][]time.Time),
8497
}
8598
}
8699

@@ -104,6 +117,39 @@ func (a *TelegramApprover) Cancel() {
104117
}
105118
}
106119

120+
// shouldFriction reports whether the user has approved at least
121+
// FrictionThreshold operations of cls within FrictionWindow. As a side effect
122+
// it prunes stale entries from the per-class approval log.
123+
func (a *TelegramApprover) shouldFriction(cls danger.RiskClass) bool {
124+
if a.FrictionThreshold <= 0 || a.FrictionWindow <= 0 {
125+
return false
126+
}
127+
128+
a.mu.Lock()
129+
defer a.mu.Unlock()
130+
131+
cutoff := time.Now().Add(-a.FrictionWindow)
132+
log := a.approvalLog[cls]
133+
kept := log[:0]
134+
for _, ts := range log {
135+
if ts.After(cutoff) {
136+
kept = append(kept, ts)
137+
}
138+
}
139+
a.approvalLog[cls] = kept
140+
return len(kept) >= a.FrictionThreshold
141+
}
142+
143+
// recordApproval logs an approval timestamp for the given class.
144+
func (a *TelegramApprover) recordApproval(cls danger.RiskClass) {
145+
a.mu.Lock()
146+
defer a.mu.Unlock()
147+
if a.approvalLog == nil {
148+
a.approvalLog = make(map[danger.RiskClass][]time.Time)
149+
}
150+
a.approvalLog[cls] = append(a.approvalLog[cls], time.Now())
151+
}
152+
107153
// PromptCommand sends an approval request with inline keyboard and waits
108154
// for the user to respond. Returns nil on approve/trust, error on deny/timeout.
109155
// allowTrustForClass mirrors the TTY/Web approver policy: the highest-impact
@@ -126,6 +172,21 @@ func (a *TelegramApprover) PromptCommand(cls danger.RiskClass, cmd, description
126172
id := a.newID()
127173
allowTrust := allowTrustForClass(cls)
128174

175+
// Approval-fatigue mitigation: after FrictionThreshold approvals of the
176+
// same class within FrictionWindow, force a per-call approval by hiding
177+
// the Trust Session shortcut and surfacing a warning. This breaks the
178+
// reflexive tap-through pattern that an injected batch of benign-looking
179+
// operations is designed to exploit.
180+
if a.shouldFriction(cls) {
181+
allowTrust = false
182+
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)
183+
if description != "" {
184+
description = warning + "\n" + description
185+
} else {
186+
description = warning
187+
}
188+
}
189+
129190
// Build the approval message — the full command is always shown so the
130191
// user can make an informed decision.
131192
text := buildApprovalText(cls, cmd, description)
@@ -195,6 +256,7 @@ func (a *TelegramApprover) PromptCommand(cls danger.RiskClass, cmd, description
195256

196257
switch action {
197258
case "approve":
259+
a.recordApproval(cls)
198260
return nil
199261
case "trust":
200262
if !allowTrust {

internal/telegram/approver_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,112 @@ func TestTelegramApprover_TrustDeniedForToolBatch(t *testing.T) {
603603
}
604604
}
605605

606+
// TestTelegramApprover_FrictionDisablesTrust verifies that after enough
607+
// approvals of a class within the friction window the Trust Session shortcut
608+
// is hidden and a warning is shown.
609+
func TestTelegramApprover_FrictionDisablesTrust(t *testing.T) {
610+
rec := new(requestRecorder)
611+
ts := testServer(t, rec)
612+
defer ts.Close()
613+
bot := testBot(t, ts)
614+
615+
a := NewTelegramApprover(bot, 1, 0)
616+
a.FrictionThreshold = 2
617+
a.FrictionWindow = 5 * time.Minute
618+
619+
// Record two prior system_write approvals to trigger friction.
620+
a.recordApproval(danger.SystemWrite)
621+
a.recordApproval(danger.SystemWrite)
622+
623+
done := make(chan error, 1)
624+
go func() { done <- a.PromptCommand(danger.SystemWrite, "echo third", "test friction") }()
625+
626+
var body string
627+
deadline := time.Now().Add(2 * time.Second)
628+
for time.Now().Before(deadline) {
629+
rec.mu.Lock()
630+
if len(rec.requests) > 0 {
631+
body = rec.requests[len(rec.requests)-1].Body
632+
}
633+
rec.mu.Unlock()
634+
if body != "" {
635+
break
636+
}
637+
time.Sleep(10 * time.Millisecond)
638+
}
639+
if body == "" {
640+
t.Fatal("prompt request was not sent")
641+
}
642+
if !strings.Contains(body, "Trust Session is disabled") {
643+
t.Errorf("friction prompt should contain a warning, got body:\n%s", body)
644+
}
645+
if strings.Contains(body, "🔒 Trust Session") {
646+
t.Errorf("friction prompt should not offer Trust Session, got body:\n%s", body)
647+
}
648+
649+
id := extractCallbackID(body, cbPrefixApprove)
650+
if id == "" {
651+
t.Fatal("could not extract approve callback id")
652+
}
653+
a.HandleCallback(cbPrefixApprove+id, 0)
654+
if err := <-done; err != nil {
655+
t.Fatalf("approve should succeed: %v", err)
656+
}
657+
}
658+
659+
// TestTelegramApprover_NoFrictionBelowThreshold verifies that the Trust
660+
// Session shortcut is still offered when the approval count is below the
661+
// friction threshold.
662+
func TestTelegramApprover_NoFrictionBelowThreshold(t *testing.T) {
663+
rec := new(requestRecorder)
664+
ts := testServer(t, rec)
665+
defer ts.Close()
666+
bot := testBot(t, ts)
667+
668+
a := NewTelegramApprover(bot, 1, 0)
669+
a.FrictionThreshold = 3
670+
a.FrictionWindow = 5 * time.Minute
671+
672+
// Record only two approvals — below the threshold.
673+
a.recordApproval(danger.SystemWrite)
674+
a.recordApproval(danger.SystemWrite)
675+
676+
done := make(chan error, 1)
677+
go func() { done <- a.PromptCommand(danger.SystemWrite, "echo third", "test no friction") }()
678+
679+
var body string
680+
deadline := time.Now().Add(2 * time.Second)
681+
for time.Now().Before(deadline) {
682+
rec.mu.Lock()
683+
if len(rec.requests) > 0 {
684+
body = rec.requests[len(rec.requests)-1].Body
685+
}
686+
rec.mu.Unlock()
687+
if body != "" {
688+
break
689+
}
690+
time.Sleep(10 * time.Millisecond)
691+
}
692+
if body == "" {
693+
t.Fatal("prompt request was not sent")
694+
}
695+
if !strings.Contains(body, "🔒 Trust Session") {
696+
t.Errorf("prompt below threshold should still offer Trust Session, got body:\n%s", body)
697+
}
698+
if strings.Contains(body, "Trust Session is disabled") {
699+
t.Errorf("prompt below threshold should not show friction warning, got body:\n%s", body)
700+
}
701+
702+
id := extractCallbackID(body, cbPrefixDeny)
703+
if id == "" {
704+
t.Fatal("could not extract deny callback id")
705+
}
706+
a.HandleCallback(cbPrefixDeny+id, 0)
707+
if err := <-done; err == nil {
708+
t.Fatal("deny should return an error")
709+
}
710+
}
711+
606712
// extractCallbackID pulls the callback payload suffix for the given prefix
607713
// from a Telegram sendMessage request body.
608714
func extractCallbackID(body, prefix string) string {

0 commit comments

Comments
 (0)