Skip to content

Commit d360a82

Browse files
committed
fix: replace telegram PID-file singleton lock with flock
The PID-file lock probed liveness with signals and could kill arbitrary processes on non-Linux systems if an attacker planted a victim PID in ~/.odek/telegram.pid. Replace it with an advisory flock on ~/.odek/telegram.lock via the existing internal/flock module. - Update cmd/odek/telegram.go acquireLock/release to use flock.Lock. - Remove PID read/kill/write logic and the instanceLock struct. - Clean up legacy telegram.pid file on successful lock acquisition. - Add regression tests for lock-file creation, legacy PID cleanup, and no-kill behaviour. - Update docs/TELEGRAM.md, docs/SECURITY.md, and AGENTS.md.
1 parent 623b511 commit d360a82

5 files changed

Lines changed: 113 additions & 74 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
135135
- **Project config sensitive-field rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so `base_url`, `api_key`, `system`, and the `dangerous` section set there are ignored (with stderr warnings). These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.
136136
- **MCP subprocess environment sanitisation** (`internal/mcpclient/client.go`) — MCP server children receive only a minimal allowlist of safe environment variables plus explicit `env` overrides. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, etc.) are stripped, preventing a compromised or malicious MCP server from reading parent secrets.
137137
- **Schedule atomic-write hardening** (`internal/schedule/store.go` + `internal/fsatomic`) — schedule file writes now use `fsatomic.WriteFile`, which creates a random temp file with `O_EXCL`, fsyncs data and directory, and renames over the target. A swapped-in symlink is replaced rather than followed, closing the symlink-override attack on `schedules.json` / `schedule-state.json`.
138+
- **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.
138139
- **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.
139140
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
140141

cmd/odek/telegram.go

Lines changed: 34 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
"github.com/BackendStack21/odek"
2121
"github.com/BackendStack21/odek/internal/config"
22+
"github.com/BackendStack21/odek/internal/flock"
2223
"github.com/BackendStack21/odek/internal/llm"
2324
"github.com/BackendStack21/odek/internal/loop"
2425
"github.com/BackendStack21/odek/internal/memory"
@@ -111,9 +112,9 @@ func handleRestartCommand(chatID, userID int64, adminChats, adminUsers []int64)
111112
return "🔄 *Restarting...*\n\nThe bot will restart momentarily. This may take a few seconds.", true
112113
}
113114

114-
// instanceLockRef holds the current PID file lock, accessible from
115-
// gracefulRestart so it can release the lock before os.Exit(0).
116-
var instanceLockRef *instanceLock
115+
// instanceLockRef holds the current Telegram singleton lock release function,
116+
// accessible from gracefulRestart so it can release the lock before os.Exit(0).
117+
var instanceLockRef func()
117118

118119
// getChatMutex returns the per-chat mutex for the given chat ID.
119120
func getChatMutex(chatID int64) *sync.Mutex {
@@ -142,15 +143,15 @@ func resetChatForNew(chatID int64, sessionManager *telegram.SessionManager, hand
142143

143144
// telegramCmd is the entry point for "odek telegram".
144145
func telegramCmd(args []string) error {
145-
// 0. Acquire singleton lock — kill any stale previous instance.
146-
lock, err := acquireLock()
146+
// 0. Acquire singleton lock — wait for any previous instance to exit.
147+
lockRelease, err := acquireLock()
147148
if err != nil {
148149
return fmt.Errorf("telegram: %w", err)
149150
}
150-
instanceLockRef = lock
151+
instanceLockRef = lockRelease
151152
defer func() {
152153
instanceLockRef = nil
153-
lock.release()
154+
lockRelease()
154155
}()
155156

156157
// 1. Load config from all sources (file → env).
@@ -1010,9 +1011,9 @@ func gracefulRestart(bot *telegram.Bot) {
10101011
//
10111012
// Since the child is an independent process already running via
10121013
// os.StartProcess, the cleanest path is to exit right here.
1013-
// Release the PID file lock before exit so the child gets a clean slate.
1014+
// Release the singleton lock before exit so the child gets a clean slate.
10141015
if instanceLockRef != nil {
1015-
instanceLockRef.release()
1016+
instanceLockRef()
10161017
}
10171018
// Close the embedded scheduler's MCP connections before exiting — os.Exit
10181019
// skips deferred cleanup, so without this the MCP child processes (e.g.
@@ -1983,89 +1984,53 @@ func truncateToolArgs(data string, maxLen int) string {
19831984
// ── Singleton Lock ─────────────────────────────────────────────────────
19841985
//
19851986
// Prevents two bot instances from polling Telegram simultaneously (which
1986-
// causes 409 Conflict errors). Uses a PID file at ~/.odek/telegram.pid.
1987+
// causes 409 Conflict errors). Uses an advisory file lock on
1988+
// ~/.odek/telegram.lock via the internal/flock module.
1989+
//
1990+
// Why not a PID file? A PID file is probed with signals and, on macOS and
1991+
// other non-Linux POSIX systems, can easily be made to kill an unrelated
1992+
// process whose PID was planted by an attacker. flock is advisory, portable,
1993+
// and the OS automatically releases the lock when the holding process exits.
19871994
//
19881995
// LIFECYCLE
19891996
//
19901997
// Normal startup (no previous instance):
19911998
//
1992-
// acquireLock() → PID file doesn't exist → writes own PID → OK
1999+
// acquireLock() → flock succeeds → OK
19932000
//
19942001
// Competing startup (existing instance still alive):
19952002
//
1996-
// acquireLock() → reads PID file → kills old process (SIGTERM→5s→SIGKILL)
1997-
// → old process dies → writes own PID → OK
2003+
// acquireLock() → blocks on flock until the old process exits → OK
19982004
//
19992005
// Restart (child starts after parent's os.Exit(0)):
20002006
//
2001-
// acquireLock() → reads PID file → finds old (dead) PID
2002-
// → syscall.Kill(pid, 0) fails (process gone)
2003-
// → writes own PID → OK
2004-
//
2005-
// Note: During restart, the parent's deferred lock.release() never runs
2006-
// (os.Exit(0) skips defers). The stale PID file is harmless — the child's
2007-
// acquireLock simply finds a dead PID and overwrites it.
2008-
2009-
type instanceLock struct {
2010-
pidFile string
2011-
}
2007+
// acquireLock() → old process released the lock via os.Exit → OK
20122008

2013-
// acquireLock reads any existing PID file, kills the old process if still
2014-
// alive, then writes the current PID. Returns the lock for deferred release.
2015-
func acquireLock() (*instanceLock, error) {
2009+
// acquireLock acquires an exclusive advisory lock on ~/.odek/telegram.lock
2010+
// using the internal/flock module. The returned release function must be
2011+
// called on shutdown. Any legacy telegram.pid file is removed on success.
2012+
func acquireLock() (func(), error) {
20162013
home, err := os.UserHomeDir()
20172014
if err != nil {
20182015
return nil, fmt.Errorf("home dir: %w", err)
20192016
}
2020-
pidFile := filepath.Join(home, ".odek", "telegram.pid")
2017+
lockFile := filepath.Join(home, ".odek", "telegram.lock")
20212018

20222019
// Ensure parent dir exists.
2023-
if err := os.MkdirAll(filepath.Dir(pidFile), 0755); err != nil {
2024-
return nil, fmt.Errorf("mkdir pid: %w", err)
2025-
}
2026-
2027-
// Read stale PID and kill it if still alive.
2028-
if data, err := os.ReadFile(pidFile); err == nil {
2029-
oldPID := strings.TrimSpace(string(data))
2030-
if pid, _ := strconv.Atoi(oldPID); pid > 1 {
2031-
// Primary liveness check: cross-platform (signal 0 = probe only).
2032-
if err := syscall.Kill(pid, 0); err == nil {
2033-
// Process is alive. On Linux, verify it's an odek telegram
2034-
// process before killing — skip identity check elsewhere since
2035-
// /proc is Linux-only and the PID file is odek-specific anyway.
2036-
shouldKill := true
2037-
if cmdline, err := os.ReadFile(filepath.Join("/proc", oldPID, "cmdline")); err == nil {
2038-
shouldKill = strings.Contains(string(cmdline), "odek") &&
2039-
strings.Contains(string(cmdline), "telegram")
2040-
}
2041-
if shouldKill {
2042-
fmt.Fprintf(os.Stderr, "odek telegram: killing stale instance (PID %d)\n", pid)
2043-
syscall.Kill(pid, syscall.SIGTERM)
2044-
// Wait up to 5s for graceful shutdown.
2045-
for i := 0; i < 50; i++ {
2046-
time.Sleep(100 * time.Millisecond)
2047-
if err := syscall.Kill(pid, 0); err != nil {
2048-
break // process gone
2049-
}
2050-
}
2051-
// Force kill if still alive.
2052-
syscall.Kill(pid, syscall.SIGKILL)
2053-
}
2054-
}
2055-
}
2020+
if err := os.MkdirAll(filepath.Dir(lockFile), 0755); err != nil {
2021+
return nil, fmt.Errorf("mkdir lock: %w", err)
20562022
}
20572023

2058-
// Write our PID.
2059-
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644); err != nil {
2060-
return nil, fmt.Errorf("write pid: %w", err)
2024+
release, err := flock.Lock(lockFile)
2025+
if err != nil {
2026+
return nil, fmt.Errorf("acquire singleton lock: %w", err)
20612027
}
20622028

2063-
return &instanceLock{pidFile: pidFile}, nil
2064-
}
2029+
// Clean up the legacy PID file if present; it is no longer used.
2030+
pidFile := filepath.Join(home, ".odek", "telegram.pid")
2031+
os.Remove(pidFile)
20652032

2066-
// release removes the PID file on clean shutdown.
2067-
func (l *instanceLock) release() {
2068-
os.Remove(l.pidFile)
2033+
return release, nil
20692034
}
20702035

20712036
// ── send_message helpers ──────────────────────────────────────────────

cmd/odek/telegram_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,3 +836,71 @@ func TestHandleRestartCommand_AuthorizationAndCooldown(t *testing.T) {
836836
t.Fatalf("cooldown should block restart, got reply=%q triggered=%v", reply, triggered)
837837
}
838838
}
839+
840+
// ── singleton lock tests ────────────────────────────────────────────────
841+
842+
func TestAcquireLock_CreatesLockFile(t *testing.T) {
843+
dir := t.TempDir()
844+
t.Setenv("HOME", dir)
845+
846+
release, err := acquireLock()
847+
if err != nil {
848+
t.Fatalf("acquireLock: %v", err)
849+
}
850+
defer release()
851+
852+
lockFile := filepath.Join(dir, ".odek", "telegram.lock")
853+
info, err := os.Stat(lockFile)
854+
if err != nil {
855+
t.Fatalf("stat lock file: %v", err)
856+
}
857+
if perm := info.Mode().Perm(); perm != 0600 {
858+
t.Errorf("lock file mode = %04o, want 0600", perm)
859+
}
860+
}
861+
862+
func TestAcquireLock_RemovesLegacyPIDFile(t *testing.T) {
863+
dir := t.TempDir()
864+
t.Setenv("HOME", dir)
865+
866+
pidFile := filepath.Join(dir, ".odek", "telegram.pid")
867+
if err := os.MkdirAll(filepath.Dir(pidFile), 0755); err != nil {
868+
t.Fatal(err)
869+
}
870+
if err := os.WriteFile(pidFile, []byte("12345\n"), 0644); err != nil {
871+
t.Fatal(err)
872+
}
873+
874+
release, err := acquireLock()
875+
if err != nil {
876+
t.Fatalf("acquireLock: %v", err)
877+
}
878+
defer release()
879+
880+
if _, err := os.Stat(pidFile); !os.IsNotExist(err) {
881+
t.Errorf("legacy PID file was not removed")
882+
}
883+
}
884+
885+
func TestAcquireLock_DoesNotKillLegacyPID(t *testing.T) {
886+
dir := t.TempDir()
887+
t.Setenv("HOME", dir)
888+
889+
pidFile := filepath.Join(dir, ".odek", "telegram.pid")
890+
if err := os.MkdirAll(filepath.Dir(pidFile), 0755); err != nil {
891+
t.Fatal(err)
892+
}
893+
// Old PID-file logic would have killed this process. The flock-based lock
894+
// must not act on the PID file contents at all.
895+
if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644); err != nil {
896+
t.Fatal(err)
897+
}
898+
899+
release, err := acquireLock()
900+
if err != nil {
901+
t.Fatalf("acquireLock: %v", err)
902+
}
903+
defer release()
904+
905+
// If we reach here, the current process is still alive.
906+
}

docs/SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,10 @@ MCP server subprocesses no longer inherit the full odek process environment. The
316316

317317
Schedule persistence (`schedules.json` and `schedule-state.json`) now writes through `internal/fsatomic.WriteFile`. It creates a uniquely-named temp file with `O_EXCL` (so a pre-created symlink cannot be opened), fsyncs the data and parent directory, and atomically renames over the target. This means a swapped-in symlink is replaced rather than followed, closing the symlink-override attack where an attacker points `schedules.json.tmp` or `schedule-state.json.tmp` at sensitive files.
318318

319+
### 21. Telegram singleton lock uses flock instead of PID file
320+
321+
The Telegram bot previously used a PID file at `~/.odek/telegram.pid` to enforce a single polling instance. On Linux it verified `/proc/<pid>/cmdline`, but on macOS and other POSIX systems it would kill whatever process the planted PID belonged to. The implementation now uses an advisory `flock` on `~/.odek/telegram.lock` via `internal/flock`. A second instance simply blocks until the first releases the lock, and the OS releases the lock automatically if the holder crashes, eliminating the arbitrary-process-kill vector.
322+
319323
### YOLO mode
320324

321325
```json

docs/TELEGRAM.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ The package defines Telegram API types used throughout:
415415

416416
### Singleton Lock
417417

418-
The bot writes its PID to `~/.odek/telegram.pid` on startup. If a stale PID file exists from a previous instance, the new process kills it (SIGTERM → 5s grace → SIGKILL) before taking over. This prevents 409 Conflict errors from dual polling.
418+
The bot acquires an advisory file lock on `~/.odek/telegram.lock` on startup. If another instance is already running, the new process blocks on the lock until the old process exits, then takes over automatically. This prevents 409 Conflict errors from dual polling without trusting or killing PID values, which could otherwise be planted to target unrelated processes.
419419

420420
### Graceful Restart
421421

@@ -445,9 +445,9 @@ During restart:
445445

446446
3. **New messages are rejected** — any message arriving while restart is in progress gets "⏳ Bot is restarting — please try again in a few seconds." The message is not lost (it remains in the Telegram server).
447447

448-
4. **Bounded drain** — the process waits up to 15 seconds for all agent goroutines to finish. If a task is stuck (e.g., a long HTTP call that ignores context), the child process takes over and the parent is killed by the singleton lock.
448+
4. **Bounded drain** — the process waits up to 15 seconds for all agent goroutines to finish. If a task is stuck (e.g., a long HTTP call that ignores context), the child process takes over after the parent releases the singleton lock.
449449

450-
5. **PID file cleanup** — before `os.Exit(0)`, the PID file lock is explicitly released so the child process starts with no stale lock file.
450+
5. **Lock release** — before `os.Exit(0)`, the singleton lock is explicitly released so the child process can acquire it immediately.
451451

452452
6. **Post-restart notification** — when the new instance starts, it reads the restart marker file and sends "🔄 Bot restarted" to each chat that was active during the restart.
453453

@@ -458,12 +458,13 @@ The actual process handoff uses the same spawn+exit mechanism:
458458
```
459459
SIGHUP → gracefulRestart() → writeRestartMarker() → spawnChild() → os.Exit(0)
460460
461-
child acquireLock() kills parent
461+
parent releases singleton lock
462+
child acquireLock() succeeds
462463
child gets fresh HTTP/2 connections
463464
child starts polling Telegram
464465
```
465466

466-
The child process inherits environment variables and command-line arguments. `acquireLock` ensures the old process is dead before the new one starts polling. The restart marker at `~/.odek/restart.json` carries the list of chat IDs that had active agent runs.
467+
The child process inherits environment variables and command-line arguments. `acquireLock` waits for the parent to release the lock, then the child starts polling. The restart marker at `~/.odek/restart.json` carries the list of chat IDs that had active agent runs.
467468

468469
This avoids binary overwrite races, stale HTTP/2 connections, and session context loops that plagued `syscall.Exec`. The restart marker (`~/.odek/restart.json`) enables the new instance to notify users that a restart occurred.
469470

0 commit comments

Comments
 (0)