diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 8024dba..aeb8e7c 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -60,6 +60,13 @@ jobs: # silence, not a green. - name: browser plugin harness run: bash tests/browser_plugin_unit.sh + # DIVE-4397: the unprivileged-first 5dive reader, and the arms that assert + # all five telegram forks still route through it. `bun test` does not reach + # it — the module is driven with an injected exec function from bash, and + # the fork arms read the shipped server.ts as text. A harness that sits in + # the repo unrun is silence, not a green (same reason as the step above). + - name: telegram 5dive-reader harness + run: bash tests/telegram_cliexec_unit.sh - name: entry points parse run: | rc=0 diff --git a/CHANGES.md b/CHANGES.md index fcc4da1..f82f9fc 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,48 @@ ## Unreleased +### Fixed — the telegram plugins polled 5dive through `sudo` every 60s on scoped seats, and sudo mailed root every time: 83,898 messages / 66 MB on a customer's disk (DIVE-4397), telegram 0.5.52 · grok/agy 0.5.20 · codex 0.5.18 · opencode 0.5.11 · pi 0.1.11 + +Reported from OUTSIDE the company, twice, by an agent on a box that is not ours (`5dive-teal-fox-cx43`): +first on 2026-08-05 against telegram 0.5.36, again on 2026-09-13 against 0.5.51. It survived 15 +releases and 39 days. Their numbers, on their disk: `/var/mail/claude` at 66 MB / 83,898 messages, +oldest 2026-08-05, 640 of them in one day, 12 scoped seats on the box and every one a source. + +**Mechanism.** Every 5dive read in the plugin spawned `sudo -n 5dive …` unconditionally. A standard +(non-admin) agent's sudoers grant is scoped to `_deliver`/`_capture`/`_audit_append`, so that call is +denied — and sudo mails root about a denial. `reconcileNeedsBanner` runs on a 60-second timer +(`task coordinator`, then `task inbox`), so each scoped seat generated one root mail a minute, +forever, with no backoff and nothing to stop it. The reader's own `catch` swallowed the rejection, so +no component on our side ever reported a thing — it took an outside reader with shell access to see +it. A swallowed catch on a 60s timer is the whole reason this was invisible for 39 days. + +**What changed.** + +1. **Unprivileged first.** `task coordinator`, `task inbox`, `task ls`, `task show`, `heartbeat ls`, + `org tree`, `agent list`, `agent info`, `usage`, `models` and `--version` are READS and need no + root. The bare binary now runs as the seat's own uid first; on a scoped seat that path succeeds + and sudo is never spawned, so no mail is generated at all. (`refreshModelAliases` had hand-rolled + exactly this under DIVE-1883; that strategy is now the reader's, and that site is ordinary again.) + A `{ok:false}` envelope from the unprivileged attempt is the one answer that still escalates. +2. **Sudo is a fallback, and a denial is sticky.** After the first `not allowed to execute` / + `not in the sudoers file` / `a password is required`, no further sudo is spawned for the life of + the process. Even where the unprivileged path also fails, an unbounded mail stream becomes at most + ONE message per process start. A non-zero exit from 5dive *itself* is a product error, not a + refusal, and deliberately does not latch — otherwise an admin seat would silently lose root. +3. **It says so out loud.** The denial prints one line naming the command, and a run of five + consecutive read failures prints one line an hour (reset on any success). Silence is what cost 39 + days here. +4. **All six plugins, not one.** `telegram-{grok,codex,agy,pi,opencode}` each carry the same 60s + banner timer over their own `run5dive`, and ship to the same customers. Fixing only `telegram` + would have left the mail stream running in five of the six. + +**Not fixed by granting sudo,** as the reporter asked and they are right: widening a seat's grant to +silence a poll is an access change made to quiet a log, and the access would outlive the need. Sudo +is still handed the bare word `5dive` and not an absolute path — sudoers rules on shipped boxes match +the command as written today, and "tidying" it to a path would turn every working grant into a denial. + +**Existing boxes do not self-heal the mail already written.** The plugin stops adding to it on its +next install; the 66 MB already on that customer's disk is theirs to truncate. + ### Fixed — the dashboard's `browser ls` refused on every box, so the Connect-a-site tile could never list sites (DIVE-4348), browser 1.1.1 Two defects in `bin/browser`, both found on the first real box (exact-swallow, 2026-09-12): diff --git a/generator/runtimes/agy.json b/generator/runtimes/agy.json index 48968bd..37d2ffd 100644 --- a/generator/runtimes/agy.json +++ b/generator/runtimes/agy.json @@ -12,7 +12,7 @@ "timeoutKey": "MCP tool timeout", "pkgScope": "@5dive/telegram-agy-mcp" }, - "version": "0.5.19", + "version": "0.5.20", "manifest": "root", "manifestKeyword": "gemini", "blocks": [ diff --git a/generator/runtimes/grok.json b/generator/runtimes/grok.json index 7a4b4c7..8ecfee8 100644 --- a/generator/runtimes/grok.json +++ b/generator/runtimes/grok.json @@ -12,7 +12,7 @@ "timeoutKey": "tool_timeout_sec", "pkgScope": "@5dive/telegram-grok-mcp" }, - "version": "0.5.19", + "version": "0.5.20", "manifest": "claude-plugin", "blocks": [] } diff --git a/plugins/telegram-agy/package.json b/plugins/telegram-agy/package.json index f1db0e6..02919e5 100644 --- a/plugins/telegram-agy/package.json +++ b/plugins/telegram-agy/package.json @@ -1,6 +1,6 @@ { "name": "@5dive/telegram-agy-mcp", - "version": "0.5.19", + "version": "0.5.20", "license": "Apache-2.0", "type": "module", "bin": "./server.ts", diff --git a/plugins/telegram-agy/plugin.json b/plugins/telegram-agy/plugin.json index 1e7447f..d73f04c 100644 --- a/plugins/telegram-agy/plugin.json +++ b/plugins/telegram-agy/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram-agy", "description": "Telegram channel for Google's Antigravity CLI — stdio MCP bridge with access control, poll-based inbound (wait_for_message), and bundled lifecycle hooks (turn-complete ping, silence watchdog, error relay). Sibling to the telegram (Claude Code) and telegram-codex plugins, maintained by 5dive.", - "version": "0.5.19", + "version": "0.5.20", "author": { "name": "5dive", "email": "support@5dive.com" diff --git a/plugins/telegram-agy/server.ts b/plugins/telegram-agy/server.ts index 0506fbd..16e6c08 100644 --- a/plugins/telegram-agy/server.ts +++ b/plugins/telegram-agy/server.ts @@ -816,7 +816,7 @@ async function statusText(senderName: string): Promise { lines.push(`${CLI_LABEL.toLowerCase()}: ${/^\d/.test(v0) ? 'v' + v0 : v0}`) } lines.push(`plugin: v${PLUGIN_VERSION}`) - const fiveVer = await execText('sudo', ['-n', '5dive', '--version']) + const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`) lines.push(`account: ${info?.authProfile || 'default'}`) const wd = agentWorkdir() @@ -884,11 +884,65 @@ async function listAgents(): Promise { }) } -// Run `sudo -n 5dive --json` and return the parsed {ok,data,error} +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and +// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant +// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call +// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box +// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail +// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats +// and every one a source. The reader's own catch swallowed the rejection, so +// nothing on our side ever said a word for 39 days. +// +// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an +// access change made to quiet a log, and it would outlive the need. Instead: +// try the bare binary as this seat's own uid first (`task coordinator`, +// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and +// need no root), and once sudo has refused us once, never spawn it again for the +// life of this process — that turns an unbounded mail stream into at most ONE +// message per process start even where the unprivileged path also fails. +// +// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on +// shipped boxes match the command as written today, and an absolute path would +// turn a working grant into a denial on every one of them. +let SUDO_DENIED_5DIVE = false +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i +function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void { + const cp = require('child_process') + const opts = { timeout, maxBuffer: 16 * 1024 * 1024 } + const okUnprivileged = (out: string): boolean => { + // `{ok:false}` is the one answer worth escalating to root for; anything else + // (including a shape with no `ok` at all) stands as this seat's answer. + try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false } + } + cp.execFile('5dive', args, opts, (err: any, stdout: string) => { + if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout) + if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '') + cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => { + if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) { + if (!SUDO_DENIED_5DIVE) { + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' + + 'unprivileged binary for the rest of this process and not spawning sudo again. Every ' + + 'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' + + 'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0], + ) + } + SUDO_DENIED_5DIVE = true + } + cb(e2, out2 ?? '') + }) + }) +} + +// Run `5dive --json` (unprivileged first, sudo only as a fallback — see +// exec5dive above) and return the parsed {ok,data,error} // envelope. Rejects on spawn/exec failure so callers can show a clean error. function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> { return new Promise((resolve, reject) => { - require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }, + exec5dive(args, timeout, (err: any, stdout: string) => { if (err && !stdout) return reject(err) try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) } diff --git a/plugins/telegram-codex/.codex-plugin/plugin.json b/plugins/telegram-codex/.codex-plugin/plugin.json index 2de76ca..8d51d04 100644 --- a/plugins/telegram-codex/.codex-plugin/plugin.json +++ b/plugins/telegram-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "telegram-codex", - "version": "0.5.11", + "version": "0.5.12", "description": "Telegram bridge for OpenAI Codex CLI: DM or group-chat your Codex session, get turn-complete pings, and approve risky commands from your phone.", "author": { "name": "5dive", diff --git a/plugins/telegram-codex/package.json b/plugins/telegram-codex/package.json index 94d6a6e..e9e7fab 100644 --- a/plugins/telegram-codex/package.json +++ b/plugins/telegram-codex/package.json @@ -1,6 +1,6 @@ { "name": "@5dive/telegram-codex-mcp", - "version": "0.5.17", + "version": "0.5.18", "description": "Telegram bridge for OpenAI Codex CLI, delivered as a stdio MCP server: wait for messages, reply, edit, react, and download attachments from a Telegram bot.", "license": "Apache-2.0", "type": "module", diff --git a/plugins/telegram-codex/server.ts b/plugins/telegram-codex/server.ts index dbda817..0c28b01 100644 --- a/plugins/telegram-codex/server.ts +++ b/plugins/telegram-codex/server.ts @@ -961,7 +961,7 @@ async function statusText(senderName: string): Promise { lines.push(`${CLI_LABEL.toLowerCase()}: ${/^\d/.test(v0) ? 'v' + v0 : v0}`) } lines.push(`plugin: v${PLUGIN_VERSION}`) - const fiveVer = await execText('sudo', ['-n', '5dive', '--version']) + const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`) lines.push(`account: ${info?.authProfile || 'default'}`) const wd = agentWorkdir() @@ -1030,11 +1030,65 @@ async function listAgents(): Promise { }) } -// Run `sudo -n 5dive --json` and return the parsed {ok,data,error} +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and +// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant +// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call +// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box +// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail +// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats +// and every one a source. The reader's own catch swallowed the rejection, so +// nothing on our side ever said a word for 39 days. +// +// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an +// access change made to quiet a log, and it would outlive the need. Instead: +// try the bare binary as this seat's own uid first (`task coordinator`, +// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and +// need no root), and once sudo has refused us once, never spawn it again for the +// life of this process — that turns an unbounded mail stream into at most ONE +// message per process start even where the unprivileged path also fails. +// +// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on +// shipped boxes match the command as written today, and an absolute path would +// turn a working grant into a denial on every one of them. +let SUDO_DENIED_5DIVE = false +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i +function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void { + const cp = require('child_process') + const opts = { timeout, maxBuffer: 16 * 1024 * 1024 } + const okUnprivileged = (out: string): boolean => { + // `{ok:false}` is the one answer worth escalating to root for; anything else + // (including a shape with no `ok` at all) stands as this seat's answer. + try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false } + } + cp.execFile('5dive', args, opts, (err: any, stdout: string) => { + if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout) + if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '') + cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => { + if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) { + if (!SUDO_DENIED_5DIVE) { + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' + + 'unprivileged binary for the rest of this process and not spawning sudo again. Every ' + + 'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' + + 'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0], + ) + } + SUDO_DENIED_5DIVE = true + } + cb(e2, out2 ?? '') + }) + }) +} + +// Run `5dive --json` (unprivileged first, sudo only as a fallback — see +// exec5dive above) and return the parsed {ok,data,error} // envelope. Rejects on spawn/exec failure so callers can show a clean error. function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> { return new Promise((resolve, reject) => { - require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }, + exec5dive(args, timeout, (err: any, stdout: string) => { if (err && !stdout) return reject(err) try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) } diff --git a/plugins/telegram-grok/.claude-plugin/plugin.json b/plugins/telegram-grok/.claude-plugin/plugin.json index 38cb89e..8594dbf 100644 --- a/plugins/telegram-grok/.claude-plugin/plugin.json +++ b/plugins/telegram-grok/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram-grok", "description": "Telegram channel for xAI's Grok CLI — stdio MCP bridge with access control, poll-based inbound (wait_for_message), and bundled lifecycle hooks (turn-complete ping, silence watchdog, error relay). Sibling to the telegram (Claude Code) and telegram-codex plugins, maintained by 5dive.", - "version": "0.5.19", + "version": "0.5.20", "author": { "name": "5dive", "email": "support@5dive.com" diff --git a/plugins/telegram-grok/package.json b/plugins/telegram-grok/package.json index 2961763..1a3d201 100644 --- a/plugins/telegram-grok/package.json +++ b/plugins/telegram-grok/package.json @@ -1,6 +1,6 @@ { "name": "@5dive/telegram-grok-mcp", - "version": "0.5.19", + "version": "0.5.20", "license": "Apache-2.0", "type": "module", "bin": "./server.ts", diff --git a/plugins/telegram-grok/server.ts b/plugins/telegram-grok/server.ts index 87613b2..4446b9f 100755 --- a/plugins/telegram-grok/server.ts +++ b/plugins/telegram-grok/server.ts @@ -828,7 +828,7 @@ async function statusText(senderName: string): Promise { lines.push(`${CLI_LABEL.toLowerCase()}: ${/^\d/.test(v0) ? 'v' + v0 : v0}`) } lines.push(`plugin: v${PLUGIN_VERSION}`) - const fiveVer = await execText('sudo', ['-n', '5dive', '--version']) + const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`) lines.push(`account: ${info?.authProfile || 'default'}`) const wd = agentWorkdir() @@ -896,11 +896,65 @@ async function listAgents(): Promise { }) } -// Run `sudo -n 5dive --json` and return the parsed {ok,data,error} +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and +// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant +// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call +// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box +// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail +// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats +// and every one a source. The reader's own catch swallowed the rejection, so +// nothing on our side ever said a word for 39 days. +// +// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an +// access change made to quiet a log, and it would outlive the need. Instead: +// try the bare binary as this seat's own uid first (`task coordinator`, +// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and +// need no root), and once sudo has refused us once, never spawn it again for the +// life of this process — that turns an unbounded mail stream into at most ONE +// message per process start even where the unprivileged path also fails. +// +// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on +// shipped boxes match the command as written today, and an absolute path would +// turn a working grant into a denial on every one of them. +let SUDO_DENIED_5DIVE = false +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i +function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void { + const cp = require('child_process') + const opts = { timeout, maxBuffer: 16 * 1024 * 1024 } + const okUnprivileged = (out: string): boolean => { + // `{ok:false}` is the one answer worth escalating to root for; anything else + // (including a shape with no `ok` at all) stands as this seat's answer. + try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false } + } + cp.execFile('5dive', args, opts, (err: any, stdout: string) => { + if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout) + if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '') + cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => { + if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) { + if (!SUDO_DENIED_5DIVE) { + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' + + 'unprivileged binary for the rest of this process and not spawning sudo again. Every ' + + 'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' + + 'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0], + ) + } + SUDO_DENIED_5DIVE = true + } + cb(e2, out2 ?? '') + }) + }) +} + +// Run `5dive --json` (unprivileged first, sudo only as a fallback — see +// exec5dive above) and return the parsed {ok,data,error} // envelope. Rejects on spawn/exec failure so callers can show a clean error. function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> { return new Promise((resolve, reject) => { - require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }, + exec5dive(args, timeout, (err: any, stdout: string) => { if (err && !stdout) return reject(err) try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) } diff --git a/plugins/telegram-opencode/.claude-plugin/plugin.json b/plugins/telegram-opencode/.claude-plugin/plugin.json index 245e386..87efef0 100644 --- a/plugins/telegram-opencode/.claude-plugin/plugin.json +++ b/plugins/telegram-opencode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram-opencode", "description": "Telegram channel for the opencode CLI — a long-running relay over opencode's headless HTTP server (opencode serve) and /event SSE stream, with access control and pairing. Unlike the codex/grok/agy MCP forks it needs no wait_for_message loop, re-arm watchdog, or file-IPC permission bridge (server.heartbeat/session.idle/permission.asked are first-class API). Maintained by 5dive.", - "version": "0.5.10", + "version": "0.5.11", "author": { "name": "5dive", "email": "support@5dive.com" }, "homepage": "https://github.com/5dive-ai/5dive-plugins/tree/main/plugins/telegram-opencode", "keywords": ["telegram", "messaging", "channel", "opencode", "relay", "sse"] diff --git a/plugins/telegram-opencode/package.json b/plugins/telegram-opencode/package.json index 98b61c6..5030733 100644 --- a/plugins/telegram-opencode/package.json +++ b/plugins/telegram-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@5dive/telegram-opencode-bridge", - "version": "0.5.9", + "version": "0.5.10", "license": "Apache-2.0", "type": "module", "bin": "./server.ts", diff --git a/plugins/telegram-opencode/server.ts b/plugins/telegram-opencode/server.ts index ec750c1..fe0ed83 100644 --- a/plugins/telegram-opencode/server.ts +++ b/plugins/telegram-opencode/server.ts @@ -755,9 +755,64 @@ function agentName(): string { return 'unknown' } +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and +// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant +// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call +// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box +// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail +// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats +// and every one a source. The reader's own catch swallowed the rejection, so +// nothing on our side ever said a word for 39 days. +// +// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an +// access change made to quiet a log, and it would outlive the need. Instead: +// try the bare binary as this seat's own uid first (`task coordinator`, +// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and +// need no root), and once sudo has refused us once, never spawn it again for the +// life of this process — that turns an unbounded mail stream into at most ONE +// message per process start even where the unprivileged path also fails. +// +// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on +// shipped boxes match the command as written today, and an absolute path would +// turn a working grant into a denial on every one of them. +let SUDO_DENIED_5DIVE = false +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i +function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void { + const cp = require('child_process') + const opts = { timeout, maxBuffer: 16 * 1024 * 1024 } + const okUnprivileged = (out: string): boolean => { + // `{ok:false}` is the one answer worth escalating to root for; anything else + // (including a shape with no `ok` at all) stands as this seat's answer. + try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false } + } + cp.execFile('5dive', args, opts, (err: any, stdout: string) => { + if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout) + if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '') + cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => { + if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) { + if (!SUDO_DENIED_5DIVE) { + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' + + 'unprivileged binary for the rest of this process and not spawning sudo again. Every ' + + 'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' + + 'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0], + ) + } + SUDO_DENIED_5DIVE = true + } + cb(e2, out2 ?? '') + }) + }) +} + +// Run `5dive --json` (unprivileged first, sudo only as a fallback — see +// exec5dive above) and return the parsed {ok,data,error} envelope. function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> { return new Promise((resolve, reject) => { - require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }, + exec5dive(args, timeout, (err: any, stdout: string) => { if (err && !stdout) return reject(err) try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) } @@ -800,7 +855,7 @@ async function statusText(senderName: string): Promise { lines.push(`opencode: ${/^\d/.test(v0) ? 'v' + v0 : v0}`) } lines.push(`plugin: v${PLUGIN_VERSION}`) - const fiveVer = await execText('sudo', ['-n', '5dive', '--version']) + const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`) lines.push(`account: ${info?.authProfile || 'default'}`) return lines.join('\n') diff --git a/plugins/telegram-pi/.claude-plugin/plugin.json b/plugins/telegram-pi/.claude-plugin/plugin.json index cd9b57d..9be3fce 100644 --- a/plugins/telegram-pi/.claude-plugin/plugin.json +++ b/plugins/telegram-pi/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram-pi", "description": "Telegram channel for the pi CLI (earendil-works/pi-coding-agent) — a long-running relay that HOSTS pi in-process via its SDK (createAgentSession), with access control, pairing, streaming edit-in-place, and a sandboxed-by-default permission gate. pi ships no permission system of its own, so every mutating tool (bash/write/edit) is gated behind a Telegram once/always/reject tap via pi's extension tool_call block-hook. A fourth run-model distinct from claude (--channels), codex/grok/agy (MCP+config), and opencode (HTTP relay). Maintained by 5dive.", - "version": "0.1.10", + "version": "0.1.11", "author": { "name": "5dive", "email": "support@5dive.com" }, "homepage": "https://github.com/5dive-ai/5dive-plugins/tree/main/plugins/telegram-pi", "keywords": ["telegram", "messaging", "channel", "pi", "pi-coding-agent", "relay", "sdk", "sandbox"] diff --git a/plugins/telegram-pi/package.json b/plugins/telegram-pi/package.json index 78c5e34..2183ea7 100644 --- a/plugins/telegram-pi/package.json +++ b/plugins/telegram-pi/package.json @@ -1,6 +1,6 @@ { "name": "@5dive/telegram-pi-bridge", - "version": "0.1.9", + "version": "0.1.10", "license": "Apache-2.0", "type": "module", "bin": "./server.ts", diff --git a/plugins/telegram-pi/server.ts b/plugins/telegram-pi/server.ts index 155e1f2..1c4664e 100644 --- a/plugins/telegram-pi/server.ts +++ b/plugins/telegram-pi/server.ts @@ -753,9 +753,64 @@ function agentName(): string { return 'unknown' } +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// Every 5dive read below used to spawn `sudo -n 5dive …` unconditionally, and +// `reconcileNeedsBanner` runs one on a 60s timer. On a seat whose sudoers grant +// is SCOPED (the standard agent: _deliver/_capture/_audit_append only) that call +// is denied, and sudo MAILS ROOT about each denial. Measured on a customer box +// reported from outside the company twice (`5dive-teal-fox-cx43`): /var/mail +// at 66 MB / 83,898 messages, oldest 2026-08-05, 640 in one day, 12 scoped seats +// and every one a source. The reader's own catch swallowed the rejection, so +// nothing on our side ever said a word for 39 days. +// +// THE FIX IS NOT MORE SUDO — widening a seat's grant to silence a poll is an +// access change made to quiet a log, and it would outlive the need. Instead: +// try the bare binary as this seat's own uid first (`task coordinator`, +// `task inbox`, `task ls`, `task show`, `org tree`, `agent info` are READS and +// need no root), and once sudo has refused us once, never spawn it again for the +// life of this process — that turns an unbounded mail stream into at most ONE +// message per process start even where the unprivileged path also fails. +// +// `5dive` is handed to sudo as the bare word, deliberately: sudoers rules on +// shipped boxes match the command as written today, and an absolute path would +// turn a working grant into a denial on every one of them. +let SUDO_DENIED_5DIVE = false +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|no tty present|a terminal is required)/i +function exec5dive(args: string[], timeout: number, cb: (err: any, stdout: string) => void): void { + const cp = require('child_process') + const opts = { timeout, maxBuffer: 16 * 1024 * 1024 } + const okUnprivileged = (out: string): boolean => { + // `{ok:false}` is the one answer worth escalating to root for; anything else + // (including a shape with no `ok` at all) stands as this seat's answer. + try { const j = JSON.parse(out); return !(j && typeof j === 'object' && j.ok === false) } catch { return false } + } + cp.execFile('5dive', args, opts, (err: any, stdout: string) => { + if (!err && okUnprivileged(stdout ?? '')) return cb(null, stdout) + if (SUDO_DENIED_5DIVE) return cb(err ?? new Error('5dive: unprivileged read returned no usable output'), stdout ?? '') + cp.execFile('sudo', ['-n', '5dive', ...args], opts, (e2: any, out2: string) => { + if (e2 && SUDO_DENIAL_RE.test(`${String(e2?.stderr ?? '')}\n${String(e2?.message ?? '')}`)) { + if (!SUDO_DENIED_5DIVE) { + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — using the ' + + 'unprivileged binary for the rest of this process and not spawning sudo again. Every ' + + 'further attempt would only mail root (DIVE-4397). Do NOT widen this seat\'s sudoers ' + + 'grant to silence it. sudo said: ' + String(e2?.stderr ?? '').trim().split('\n')[0], + ) + } + SUDO_DENIED_5DIVE = true + } + cb(e2, out2 ?? '') + }) + }) +} + +// Run `5dive --json` (unprivileged first, sudo only as a fallback — see +// exec5dive above) and return the parsed {ok,data,error} envelope. function run5dive(args: string[], timeout = 8000): Promise<{ ok: boolean; data?: any; error?: { message?: string } }> { return new Promise((resolve, reject) => { - require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }, + exec5dive(args, timeout, (err: any, stdout: string) => { if (err && !stdout) return reject(err) try { resolve(JSON.parse(stdout)) } catch (e) { reject(e) } @@ -797,7 +852,7 @@ async function statusText(senderName: string): Promise { lines.push(`pi: ${/^\d/.test(v0) ? 'v' + v0 : v0}`) } lines.push(`plugin: v${PLUGIN_VERSION}`) - const fiveVer = await execText('sudo', ['-n', '5dive', '--version']) + const fiveVer = await execText('5dive', ['--version']) // DIVE-4397: a version read needs no root if (fiveVer) lines.push(`5dive: ${fmtVer(fiveVer)}`) lines.push(`account: ${info?.authProfile || 'default'}`) return lines.join('\n') diff --git a/plugins/telegram/.claude-plugin/plugin.json b/plugins/telegram/.claude-plugin/plugin.json index f7baefe..89821b0 100644 --- a/plugins/telegram/.claude-plugin/plugin.json +++ b/plugins/telegram/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram", "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control, bundled lifecycle hooks (AskUserQuestion intercept, Stop-reply safety net), and a notify-user comms-playbook skill. Fork of Anthropic's telegram plugin, maintained by 5dive.", - "version": "0.5.51", + "version": "0.5.52", "author": { "name": "5dive", "email": "support@5dive.com" diff --git a/plugins/telegram/cliexec.ts b/plugins/telegram/cliexec.ts new file mode 100644 index 0000000..31484d9 --- /dev/null +++ b/plugins/telegram/cliexec.ts @@ -0,0 +1,171 @@ +// DIVE-4397 — SUDO IS THE FALLBACK, NEVER THE FIRST TRY. +// +// WHAT THIS FILE IS FOR. Every 5dive read in server.ts used to spawn +// `sudo -n 5dive …` unconditionally. On a seat whose sudoers grant is SCOPED +// (the standard agent: _deliver/_capture/_audit_append only) that call is +// denied — and a denial is not free. sudo MAILS ROOT about it. The needs-banner +// reconciler runs on a 60s timer, so each scoped seat generated one root mail a +// minute, forever, and the `catch` in the reader swallowed the rejection so +// nothing on our side ever said a word. +// +// Measured on a customer box (`5dive-teal-fox-cx43`, reported from outside the +// company twice): /var/mail/claude at 66 MB / 83,898 messages, oldest +// 2026-08-05, 640 in one day, 12 scoped seats and every one a source. Live from +// telegram plugin 0.5.36 through 0.5.51 — 15 releases, 39 days. +// +// THE FIX IS NOT MORE SUDO. Widening a seat's grant to silence a poll is an +// access change made to quiet a log, and the access would outlive the need. +// Instead: +// +// 1. UNPRIVILEGED FIRST. `task coordinator`, `task inbox`, `task ls`, +// `task show`, `heartbeat ls`, `org tree`, `agent list`, `models` are +// READS. They need no root. Run the bare binary as the seat's own uid; on +// a scoped seat that path succeeds and sudo is never spawned, so no mail +// is ever generated. (`refreshModelAliases` already did exactly this +// one-off under DIVE-1883 — this generalises it to every read.) +// 2. SUDO ONLY AS FALLBACK, AND ONLY UNTIL IT IS DENIED ONCE. A denial is +// sticky for the life of the process: after the first `not allowed to +// execute` / `not in the sudoers file` / `a password is required`, this +// runner never spawns sudo again. That turns an unbounded mail stream into +// at most ONE message per process start even on a host where the +// unprivileged path also fails. +// 3. SAY IT OUT LOUD. The latch and the failure breaker both surface one +// rate-limited line. A swallowed catch on a 60s timer is precisely how +// this stayed invisible for 39 days; nothing here may be silent again. +// +// Kept in its own module because server.ts long-polls Telegram on import and so +// cannot be imported by a unit test (same reason commands.ts holds the model +// alias merge). Everything below takes its exec function and its clock as +// parameters, so the suite drives the real strategy with a fake sudo. + +export type ExecResult = { stdout: string; stderr: string } +export type ExecFn = (file: string, args: string[], opts?: unknown) => Promise + +// sudo's own refusals, as printed to stderr by `sudo -n`. These are the ones +// that mail root; a non-zero exit from 5dive ITSELF is a product error and must +// NOT latch (the grant is fine, the command failed). +const SUDO_DENIAL_RE = + /(is not allowed to execute|not in the sudoers file|a password is required|sudo: no password was provided|no tty present|a terminal is required|sorry, try again)/i + +export function isSudoDenial(e: unknown): boolean { + const err = e as { stderr?: unknown; message?: unknown } + return SUDO_DENIAL_RE.test(`${String(err?.stderr ?? '')}\n${String(err?.message ?? '')}`) +} + +// Salvage stdout off a rejected exec (DIVE-125: the CLI can print a complete +// JSON envelope and still exit non-zero). +export function stdoutOf(e: unknown): string { + return String((e as { stdout?: unknown } | undefined)?.stdout ?? '') +} + +export type RunOutcome = { + ok: boolean + stdout: string + /** which attempt produced `stdout` — 'none' when neither ran or both threw */ + via: 'plain' | 'sudo' | 'none' + /** whether sudo was spawned at all on this call */ + sudoSpawned: boolean + error?: unknown +} + +export type FiveRunner = { + run(args: string[], opts?: unknown, accept?: (stdout: string) => boolean): Promise + /** true once sudo has refused us; no further sudo spawn will happen */ + sudoDenied(): boolean +} + +export type FiveRunnerOpts = { + execFile: ExecFn + sudoBin: string + /** absolute path to the bare binary, used for the unprivileged attempt */ + fiveBin: string + /** + * The word handed to sudo. Deliberately the bare name '5dive' and NOT + * `fiveBin`: existing sudoers rules on shipped boxes match the command as + * written today, and rewriting it to an absolute path would turn a working + * grant into a denial on every one of them. + */ + sudoArg?: string + onSudoDenied?: (args: string[], stderr: string) => void +} + +export function createFiveRunner(o: FiveRunnerOpts): FiveRunner { + const sudoArg = o.sudoArg ?? '5dive' + let denied = false + return { + sudoDenied: () => denied, + async run(args, opts, accept) { + let plainErr: unknown + try { + const { stdout } = await o.execFile(o.fiveBin, args, opts) + if (!accept || accept(stdout)) return { ok: true, stdout, via: 'plain', sudoSpawned: false } + // Ran fine but the CLI said it could not do it as this uid (ok:false). + // That is the one case worth escalating — fall through to sudo. + plainErr = new Error('unprivileged 5dive returned a non-ok envelope') + ;(plainErr as { stdout?: string }).stdout = stdout + } catch (e) { + plainErr = e + } + if (denied) { + // Sudo has already refused us once. Spawning it again buys nothing and + // costs one more root mail, which is the entire defect. + return { ok: false, stdout: stdoutOf(plainErr), via: 'none', sudoSpawned: false, error: plainErr } + } + try { + const { stdout } = await o.execFile(o.sudoBin, ['-n', sudoArg, ...args], opts) + if (!accept || accept(stdout)) return { ok: true, stdout, via: 'sudo', sudoSpawned: true } + return { ok: false, stdout, via: 'sudo', sudoSpawned: true, error: plainErr } + } catch (e) { + if (isSudoDenial(e)) { + denied = true + o.onSudoDenied?.(args, String((e as { stderr?: unknown })?.stderr ?? '')) + } + const salvaged = stdoutOf(e) || stdoutOf(plainErr) + return { ok: false, stdout: salvaged, via: salvaged ? 'sudo' : 'none', sudoSpawned: true, error: e } + } + }, + } +} + +// A run of consecutive failures on a background timer is the shape that hid +// DIVE-4397 for 39 days. Count them, and after `threshold` in a row say so — +// once per `intervalMs`, reset the moment anything succeeds, so a fresh outage +// is loud on its first streak instead of waiting out a window from the last. +export type FailureBreaker = { + ok(): void + fail(detail: string): void + streak(): number +} + +export function createFailureBreaker(o: { + threshold: number + intervalMs: number + now: () => number + log: (msg: string) => void + label: string +}): FailureBreaker { + let streak = 0 + // null, not 0: an injected or monotonic clock can legitimately read 0, and a + // 0-sentinel would then treat "already logged" as "never logged" and print on + // every single tick — which is the same unbounded-output shape this row exists + // to remove, just in our log instead of their mail. + let loggedAt: number | null = null + return { + streak: () => streak, + ok() { + streak = 0 + loggedAt = null + }, + fail(detail: string) { + streak++ + if (streak < o.threshold) return + const now = o.now() + if (loggedAt !== null && now - loggedAt < o.intervalMs) return + loggedAt = now + o.log( + `[${o.label}] ${streak} consecutive 5dive read failures — the surface fed by these reads ` + + `is stale or blank and nothing else reports it. Last: ${detail}`, + ) + }, + } +} diff --git a/plugins/telegram/server.ts b/plugins/telegram/server.ts index 705128b..a5dcbe0 100644 --- a/plugins/telegram/server.ts +++ b/plugins/telegram/server.ts @@ -33,6 +33,7 @@ import { TNA_RE, resolveTnaAnswer, OPT_RE, optionChoices, parseOptions, tapEvide import { appendFileSync as tapAppendFileSync, mkdirSync as tapMkdirSync, statSync as tapStatSync, renameSync as tapRenameSync } from 'fs' import { parseGateReply, resolveGateReply, gateAlertIdent } from './gatereply' import { renderRoster, renderLog, renderLineage, renderVerify, COUNCIL_BUTTONS, parseVetoTap, parseCvoteTap } from './council' +import { createFiveRunner, createFailureBreaker, type FiveRunner } from './cliexec.ts' import { planAutoAttach, autoAttachFooter, AUTO_PHOTO_EXTS, type AutoAttachPlan } from './autoattach' import { resolveQuestionTap } from './hooks/lib/question-bridge' import { sweepStaleRelayIn } from './hooks/lib/relay-quarantine' @@ -2050,16 +2051,85 @@ type FiveDiveAgentEntry = { // tight one falls on every user whose box is slower than the author's. const CLI_READ_MS = 8000 +// DIVE-4397: every read below goes through ONE runner that tries the bare +// binary as this seat's own uid before it will spawn sudo, and that stops +// spawning sudo entirely once sudo has refused us once. See cliexec.ts for the +// customer-box measurement that forced it (83,898 root mails, 66 MB, 39 days). +// +// Built lazily because SUDO/FIVEDIVE are declared further down this file and a +// module-scope construction here would read them in their temporal dead zone. +let FIVE_RUNNER: FiveRunner | null = null +function fiveRunner(): FiveRunner { + if (FIVE_RUNNER) return FIVE_RUNNER + FIVE_RUNNER = createFiveRunner({ + execFile: execFileP as any, + sudoBin: SUDO, + fiveBin: FIVEDIVE, + onSudoDenied: (args, stderr) => { + // Said once, loudly, and then never again for this process — because the + // saying is what was missing, and the repeating is what filled the disk. + console.error( + '[5dive] sudo refused this seat (`sudo -n 5dive ' + args.join(' ') + '`) — ' + + 'falling back to the unprivileged binary for the rest of this process and ' + + 'not spawning sudo again. Every further sudo attempt would only mail root ' + + '(DIVE-4397). Do NOT widen this seat\'s sudoers grant to silence it. ' + + 'sudo said: ' + stderr.trim().split('\n')[0], + ) + }, + }) + return FIVE_RUNNER +} + +// A parsed 5dive envelope is only worth keeping from the unprivileged attempt +// if the CLI did not itself say no. `{ok:false}` is the one answer that earns a +// sudo escalation; anything else (including a shape with no `ok` at all) stands. +function acceptFiveJson(stdout: string): boolean { + try { + const j = JSON.parse(stdout) + return !(j && typeof j === 'object' && (j as { ok?: unknown }).ok === false) + } catch { + return false + } +} + +// DIVE-4397: one surfaced line after a run of failures. The defect this row +// exists for was invisible because a 60s timer swallowed its own rejection; a +// silent reader is not allowed here again. +const cliReadBreaker = createFailureBreaker({ + threshold: 5, + intervalMs: 3_600_000, + now: () => Date.now(), + log: (m) => console.error(m), + label: '5dive-read', +}) + async function read5diveJson(args: string[], timeout: number = CLI_READ_MS): Promise { + const r = await fiveRunner().run(args, { timeout, maxBuffer: JSON_MAXBUFFER }, acceptFiveJson) + // DIVE-125 salvage: the CLI can print a complete envelope and still exit + // non-zero, so parse whatever stdout we ended up holding either way. try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', ...args], { timeout, maxBuffer: JSON_MAXBUFFER }) - return JSON.parse(stdout) - } catch (e) { - const out = String((e as { stdout?: unknown })?.stdout ?? '') - try { return JSON.parse(out) } catch { return null } + const j = JSON.parse(r.stdout) + cliReadBreaker.ok() + return j + } catch { + const e = r.error as { message?: unknown } | undefined + cliReadBreaker.fail(`5dive ${args.join(' ')} — ${String(e?.message ?? 'unparseable output')}`) + return null } } +// Read-only surfaces that parse stdout themselves. Same unprivileged-first +// strategy; throws on failure so each caller keeps its own user-facing message. +async function read5diveStdout(args: string[], opts?: { timeout?: number; maxBuffer?: number }): Promise { + const r = await fiveRunner().run(args, opts, acceptFiveJson) + if (!r.ok && !r.stdout) { + cliReadBreaker.fail(`5dive ${args.join(' ')}`) + throw r.error instanceof Error ? r.error : new Error(String(r.error ?? '5dive call failed')) + } + cliReadBreaker.ok() + return r.stdout +} + // DIVE-1883: pull the alias -> model-id map from the CLI's single source of // truth (`5dive models --json` -> src/lib/models.sh) and merge it into // MODEL_ALIASES in place. Every read of MODEL_ALIASES happens inside a handler, @@ -2069,23 +2139,13 @@ async function read5diveJson(args: string[], timeout: number = CLI_READ_MS): Pro // defaults in commands.ts stand. The merge itself lives in commands.ts so it is // unit-testable without importing this module (which long-polls on import). async function refreshModelAliases(): Promise { - // Try unprivileged FIRST: `models` reads no state and needs no root, and a - // standard (non-admin) agent's sudoers grant is scoped to _deliver/_capture/ - // _audit_append — `sudo -n 5dive models` would be denied there, silently - // stranding those agents on the baked defaults. Fall back to the sudo path - // for hosts where the bare binary isn't on PATH for this uid. - let data: unknown = null - try { - const { stdout } = await execFileP(FIVEDIVE, ['models', '--json'], { timeout: CLI_READ_MS }) - const j = JSON.parse(stdout) - if (j?.ok) data = j.data - } catch { /* fall through to the sudo path */ } - if (data == null) { - const j = await read5diveJson(['models', '--json']) - if (!j?.ok) return - data = j.data - } - applyModelAliases(data) + // Unprivileged-first used to be hand-rolled here (DIVE-1883) because a + // standard agent's scoped sudoers grant denies `sudo -n 5dive models` and + // stranded those agents on the baked defaults. DIVE-4397 moved that strategy + // into read5diveJson itself, so this site is now ordinary. + const j = await read5diveJson(['models', '--json']) + if (!j?.ok) return + applyModelAliases(j.data) } async function read5diveAgentList(): Promise { @@ -3557,7 +3617,7 @@ const commandHandlers: Record = { return } try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'org', 'tree', '--json']) + const stdout = await read5diveStdout(['org', 'tree', '--json']) const j = JSON.parse(stdout) if (!j.ok || !Array.isArray(j.data?.tree)) { await ctx.reply(`5dive returned unexpected output.`) @@ -3880,7 +3940,7 @@ function taskRow(t: any, needTag = false): string { async function buildTaskList(): Promise { let j: any try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'task', 'ls', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) + const stdout = await read5diveStdout(['task', 'ls', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) j = JSON.parse(stdout) } catch (err) { return `Failed to list tasks: ${err instanceof Error ? err.message : String(err)}` @@ -3979,7 +4039,7 @@ function inboxCard(t: any): string { async function buildInboxList(): Promise { let j: any try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'task', 'inbox', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) + const stdout = await read5diveStdout(['task', 'inbox', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) j = JSON.parse(stdout) } catch (err) { return `Failed to load inbox: ${err instanceof Error ? err.message : String(err)}` @@ -4060,7 +4120,7 @@ async function buildActionableInbox( } let j: any try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'task', 'inbox', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) + const stdout = await read5diveStdout(['task', 'inbox', '--json'], { timeout: 8000, maxBuffer: JSON_MAXBUFFER }) j = JSON.parse(stdout) } catch (err) { return [{ text: `Failed to load inbox: ${err instanceof Error ? err.message : String(err)}` }] @@ -4150,7 +4210,7 @@ async function buildActionableInbox( async function buildHeartbeatList(): Promise { let j: any try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'heartbeat', 'ls', '--json'], { timeout: 8000 }) + const stdout = await read5diveStdout(['heartbeat', 'ls', '--json'], { timeout: 8000 }) j = JSON.parse(stdout) } catch (err) { return `Failed to list heartbeats: ${err instanceof Error ? err.message : String(err)}` @@ -4174,7 +4234,7 @@ async function buildHeartbeatList(): Promise { async function buildTaskDetail(id: number): Promise<{ text: string; keyboard?: InlineKeyboard }> { let j: any try { - const { stdout } = await execFileP(SUDO, ['-n', '5dive', 'task', 'show', String(id), '--json'], { timeout: 8000 }) + const stdout = await read5diveStdout(['task', 'show', String(id), '--json'], { timeout: 8000 }) j = JSON.parse(stdout) } catch (err) { return { text: `Failed to load task: ${err instanceof Error ? err.message : String(err)}` } diff --git a/tests/telegram_cliexec_unit.sh b/tests/telegram_cliexec_unit.sh new file mode 100755 index 0000000..e7e674a --- /dev/null +++ b/tests/telegram_cliexec_unit.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# DIVE-4397 — the telegram plugin's 5dive reads must not spawn sudo on a scoped seat. +# +# WHAT THIS SUITE IS ARRANGED AROUND. The defect was not "a call failed" — the +# call failing was harmless. The defect was that a DENIED call was repeated once +# a minute forever, and sudo mails root on each one: 83,898 messages / 66 MB on +# a customer's disk across 15 releases. So a test that only proved "the reader +# still returns JSON" would grade none of it. Every arm below is a MUTANT of the +# specific behaviour that generated that mail: +# +# T1 unprivileged-first -> a seat where the bare binary works must spawn sudo +# ZERO times. The mutant is sudo-first (today's ship). +# T3 the sticky latch -> after ONE denial, 20 further reads spawn sudo zero +# more times. This is the arm that is literally the +# 83,898. The mutant is a runner that retries. +# T5 latch only on sudo -> a non-zero exit from 5dive ITSELF must NOT latch. +# refusals The mutant is an over-eager latch, which would +# silently strip root from admin seats that need it. +# T7 the sudoers word -> sudo is still handed the bare word `5dive`. The +# mutant is the "tidier" absolute path, which turns +# every existing NOPASSWD rule on a shipped box into +# a denial — i.e. it would CAUSE this bug at scale. +# +# Driven against the real plugins/telegram/cliexec.ts through bun, with exec and +# clock injected. No sudo, no 5dive and no Telegram are touched. +set -uo pipefail +trap 'rc=$?; rm -rf "${TMP:-}"; echo "HARNESS-RC=$rc"' EXIT +cd "$(dirname "$0")/.." +ROOT="$PWD" +printf 'grading tree: %s @ %s\n' "$PWD" "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" >&2 + +PASS=0; FAIL=0 +t() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); printf 'FAIL: %s\n expected: %s\n got: %s\n' "$1" "$2" "$3"; fi; } +tc() { if [[ "$3" == *"$2"* ]]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); printf 'FAIL: %s\n expected to contain: %s\n got: %s\n' "$1" "$2" "$3"; fi; } +tn() { if [[ "$3" != *"$2"* ]]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); printf 'FAIL: %s\n expected NOT to contain: %s\n got: %s\n' "$1" "$2" "$3"; fi; } + +TMP="$(mktemp -d)" + +# ---------------------------------------------------------------- driver ---- +cat > "$TMP/drive.ts" <<'TSEOF' +import { createFiveRunner, createFailureBreaker, isSudoDenial } from '__CLIEXEC__' + +const out: string[] = [] +const say = (k: string, v: unknown) => out.push(`${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) + +type Call = { file: string; args: string[] } +function mk(handler: (c: Call) => Promise<{ stdout: string; stderr: string }>) { + const calls: Call[] = [] + const denials: string[] = [] + const runner = createFiveRunner({ + execFile: async (file, args) => { calls.push({ file, args }); return handler({ file, args }) }, + sudoBin: '/usr/bin/sudo', + fiveBin: '/usr/local/bin/5dive', + onSudoDenied: (args, stderr) => denials.push(`${args.join(' ')}|${stderr}`), + }) + return { runner, calls, denials, sudoCalls: () => calls.filter((c) => c.file === '/usr/bin/sudo') } +} +const fail = (stderr: string, stdout = '') => { const e: any = new Error('Command failed'); e.stderr = stderr; e.stdout = stdout; throw e } +const DENIAL = 'sudo: a password is required' +const NOTALLOWED = "Sorry, user agent-anton is not allowed to execute '/usr/local/bin/5dive task inbox --json' as root on box-cx43." +const accept = (s: string) => { try { const j = JSON.parse(s); return !(j && j.ok === false) } catch { return false } } + +// T1 — scoped seat, bare binary works: sudo must never be spawned. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? { stdout: '{"ok":true,"data":{"inbox":[]}}', stderr: '' } : fail(NOTALLOWED))) + const r = await h.runner.run(['task', 'inbox', '--json'], {}, accept) + say('T1_ok', r.ok); say('T1_via', r.via); say('T1_sudo_spawns', h.sudoCalls().length) + say('T1_plain_file', h.calls[0]!.file); say('T1_plain_args', h.calls[0]!.args.join(' ')) +} + +// T2 — bare binary missing for this uid: sudo is the fallback and still works. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('ENOENT') : { stdout: '{"ok":true,"data":1}', stderr: '' })) + const r = await h.runner.run(['agent', 'list', '--json'], {}, accept) + say('T2_ok', r.ok); say('T2_via', r.via); say('T2_sudo_spawns', h.sudoCalls().length); say('T2_denied', h.runner.sudoDenied()) +} + +// T3 — THE ROW. One denial, then 20 more timer ticks: sudo spawns exactly once. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('ENOENT') : fail(NOTALLOWED))) + for (let i = 0; i < 21; i++) await h.runner.run(['task', 'coordinator', '--json'], {}, accept) + say('T3_sudo_spawns', h.sudoCalls().length) + say('T3_plain_spawns', h.calls.filter((c) => c.file === '/usr/local/bin/5dive').length) + say('T3_denied', h.runner.sudoDenied()) + say('T3_denial_notices', h.denials.length) + say('T3_notice', h.denials[0] ?? '') +} + +// T3b — the same, for the `a password is required` shape. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('ENOENT') : fail(DENIAL))) + for (let i = 0; i < 10; i++) await h.runner.run(['task', 'inbox', '--json'], {}, accept) + say('T3b_sudo_spawns', h.sudoCalls().length) +} + +// T4 — bare binary runs but the CLI says ok:false: escalate to sudo, take its answer. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? { stdout: '{"ok":false,"error":"permission"}', stderr: '' } : { stdout: '{"ok":true,"data":"root"}', stderr: '' })) + const r = await h.runner.run(['usage', '--json'], {}, accept) + say('T4_via', r.via); say('T4_stdout', r.stdout); say('T4_sudo_spawns', h.sudoCalls().length) +} + +// T5 — 5dive itself exits non-zero under sudo. The GRANT is fine: must not latch. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('ENOENT') : fail('5dive: no such task'))) + await h.runner.run(['task', 'show', '9', '--json'], {}, accept) + await h.runner.run(['task', 'show', '9', '--json'], {}, accept) + say('T5_denied', h.runner.sudoDenied()); say('T5_sudo_spawns', h.sudoCalls().length) +} + +// T6 — DIVE-125 salvage: a complete envelope on a non-zero exit is still returned. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('boom') : fail('warn', '{"ok":true,"data":"salvaged"}'))) + const r = await h.runner.run(['digest', 'status', '--json'], {}, accept) + say('T6_stdout', r.stdout) +} + +// T7 — sudo is handed the BARE WORD 5dive, not the absolute path: existing +// sudoers rules on shipped boxes match the command as written today. +{ + const h = mk(async (c) => (c.file === '/usr/local/bin/5dive' ? fail('ENOENT') : { stdout: '{"ok":true}', stderr: '' })) + await h.runner.run(['org', 'tree', '--json'], {}, accept) + say('T7_sudo_args', h.sudoCalls()[0]!.args.join(' ')) +} + +// T8 — denial classifier: sudo refusals latch, product errors do not. +say('T8_notallowed', isSudoDenial({ stderr: NOTALLOWED })) +say('T8_password', isSudoDenial({ stderr: DENIAL })) +say('T8_notsudoers', isSudoDenial({ stderr: 'agent-x is not in the sudoers file. This incident will be reported.' })) +say('T8_product', isSudoDenial({ stderr: '5dive: unknown subcommand' })) +say('T8_enoent', isSudoDenial({ message: "spawn 5dive ENOENT" })) + +// T9 — the failure breaker: silent under threshold, one line at it, hourly, resets. +{ + const lines: string[] = [] + let now = 0 + const b = createFailureBreaker({ threshold: 5, intervalMs: 3_600_000, now: () => now, log: (m) => lines.push(m), label: '5dive-read' }) + for (let i = 0; i < 4; i++) b.fail('x') + say('T9_under', lines.length) + b.fail('task inbox --json') + say('T9_at', lines.length); say('T9_line', lines[0] ?? '') + for (let i = 0; i < 50; i++) b.fail('x') + say('T9_ratelimited', lines.length) + now = 3_600_001 + b.fail('x') + say('T9_after_hour', lines.length) + b.ok() + say('T9_streak_reset', b.streak()) + for (let i = 0; i < 5; i++) b.fail('y') + say('T9_fresh_outage_loud', lines.length) +} + +console.log(out.join('\n')) +TSEOF +sed -i "s#__CLIEXEC__#$ROOT/plugins/telegram/cliexec.ts#" "$TMP/drive.ts" + +O="$(cd "$ROOT" && bun "$TMP/drive.ts" 2>&1)" +g() { printf '%s\n' "$O" | grep -m1 "^$1=" | cut -d= -f2-; } + +t 'T1 unprivileged-first succeeds' 'true' "$(g T1_ok)" +t 'T1 answer came from the bare binary' 'plain' "$(g T1_via)" +t 'T1 SUDO IS NEVER SPAWNED on a working seat' '0' "$(g T1_sudo_spawns)" +t 'T1 first spawn is the bare binary' '/usr/local/bin/5dive' "$(g T1_plain_file)" +t 'T1 args reach it unchanged' 'task inbox --json' "$(g T1_plain_args)" + +t 'T2 falls back to sudo when bare binary fails' 'true' "$(g T2_ok)" +t 'T2 answer came from sudo' 'sudo' "$(g T2_via)" +t 'T2 sudo spawned once' '1' "$(g T2_sudo_spawns)" +t 'T2 a WORKING sudo does not latch' 'false' "$(g T2_denied)" + +t 'T3 21 ticks after a denial spawn sudo ONCE' '1' "$(g T3_sudo_spawns)" +t 'T3 the unprivileged attempt still runs each tick' '21' "$(g T3_plain_spawns)" +t 'T3 latch is set' 'true' "$(g T3_denied)" +t 'T3 the denial is surfaced exactly once' '1' "$(g T3_denial_notices)" +tc 'T3 the notice names the command' 'task coordinator --json' "$(g T3_notice)" +t 'T3b password-required shape latches too' '1' "$(g T3b_sudo_spawns)" + +t 'T4 ok:false escalates to sudo' 'sudo' "$(g T4_via)" +tc 'T4 sudo answer is the one returned' '"data":"root"' "$(g T4_stdout)" +t 'T4 sudo spawned once' '1' "$(g T4_sudo_spawns)" + +t 'T5 a 5dive product error does NOT latch' 'false' "$(g T5_denied)" +t 'T5 so sudo is still tried on the next call' '2' "$(g T5_sudo_spawns)" + +tc 'T6 DIVE-125 salvage survives' 'salvaged' "$(g T6_stdout)" + +t 'T7 sudo still gets the bare word 5dive' '-n 5dive org tree --json' "$(g T7_sudo_args)" +tn 'T7 sudo does NOT get an absolute path' '/usr/local/bin/5dive' "$(g T7_sudo_args)" + +t 'T8 not-allowed is a denial' 'true' "$(g T8_notallowed)" +t 'T8 password-required is a denial' 'true' "$(g T8_password)" +t 'T8 not-in-sudoers is a denial' 'true' "$(g T8_notsudoers)" +t 'T8 a 5dive usage error is NOT a denial' 'false' "$(g T8_product)" +t 'T8 ENOENT is NOT a denial' 'false' "$(g T8_enoent)" + +t 'T9 silent under threshold' '0' "$(g T9_under)" +t 'T9 one line at threshold' '1' "$(g T9_at)" +tc 'T9 the line names the last command' 'task inbox --json' "$(g T9_line)" +t 'T9 rate-limited to one an hour' '1' "$(g T9_ratelimited)" +t 'T9 speaks again after the hour' '2' "$(g T9_after_hour)" +t 'T9 success resets the streak' '0' "$(g T9_streak_reset)" +t 'T9 a fresh outage is loud on its first streak' '3' "$(g T9_fresh_outage_loud)" + +# --- T10: the product itself, not just the module --------------------------- +S="$ROOT/plugins/telegram/server.ts" +tn 'T10 no unconditional sudo on the 60s inbox read' "execFileP(SUDO, ['-n', '5dive', 'task', 'inbox'" "$(cat "$S")" +tn 'T10 no unconditional sudo on the task-ls read' "execFileP(SUDO, ['-n', '5dive', 'task', 'ls'" "$(cat "$S")" +tn 'T10 no unconditional sudo on the heartbeat read' "execFileP(SUDO, ['-n', '5dive', 'heartbeat', 'ls'" "$(cat "$S")" +tn 'T10 no unconditional sudo on the org-tree read' "execFileP(SUDO, ['-n', '5dive', 'org', 'tree'" "$(cat "$S")" +tc 'T10 the shared reader routes through the runner' 'fiveRunner().run(args' "$(cat "$S")" + +# --- T11: the FIVE FORKS carry the same 60s banner timer and the same reader --- +# telegram-{grok,codex,agy,pi,opencode} each poll `task coordinator` / `task +# inbox` every 60s through their own run5dive. They ship to the same customers, +# so a fix that stopped at plugins/telegram would have left the mail stream +# running in five of the six shipped telegram plugins. +for FORK in telegram-grok telegram-codex telegram-agy telegram-pi telegram-opencode; do + F="$ROOT/plugins/$FORK/server.ts" + SRC="$(cat "$F")" + # the exact shipped shape: run5dive's ONE unconditional sudo spawn. The + # replacement helper legitimately still contains a `sudo` spawn (the fallback), + # so the arm must name the unconditional call, not the word sudo. + tn "T11 $FORK: run5dive no longer spawns sudo unconditionally" \ + "require('child_process').execFile('sudo', ['-n', '5dive', ...args], { timeout }," "$SRC" + tc "T11 $FORK: its reads go through the unprivileged-first helper" \ + 'exec5dive(args, timeout,' "$SRC" + tc "T11 $FORK: it tries the bare binary first" "cp.execFile('5dive', args, opts" "$SRC" + tc "T11 $FORK: sudo is still handed the bare word" "['-n', '5dive', ...args]" "$SRC" + tc "T11 $FORK: the denial is sticky for the process" 'SUDO_DENIED_5DIVE = true' "$SRC" + tc "T11 $FORK: and it is said out loud once" '[5dive] sudo refused this seat' "$SRC" + tn "T11 $FORK: the /status version read needs no root" \ + "execText('sudo', ['-n', '5dive', '--version'])" "$SRC" + # the reason this is worth a test and not a comment: every fork still has a + # 60s timer on that reader, which is the thing that multiplied one denial into + # 83,898 of them. + tc "T11 $FORK: still on the 60s banner timer (so the fix must hold)" \ + 'reconcileNeedsBanner(), 60_000' "$SRC" +done + +printf '\nPASS=%d FAIL=%d\n' "$PASS" "$FAIL" +[[ $FAIL -eq 0 ]]