Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
2 changes: 1 addition & 1 deletion generator/runtimes/agy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion generator/runtimes/grok.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
2 changes: 1 addition & 1 deletion plugins/telegram-agy/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/telegram-agy/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
60 changes: 57 additions & 3 deletions plugins/telegram-agy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,7 @@ async function statusText(senderName: string): Promise<string> {
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()
Expand Down Expand Up @@ -884,11 +884,65 @@ async function listAgents(): Promise<string> {
})
}

// Run `sudo -n 5dive <args> --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 <args> --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) }
Expand Down
2 changes: 1 addition & 1 deletion plugins/telegram-codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/telegram-codex/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
60 changes: 57 additions & 3 deletions plugins/telegram-codex/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ async function statusText(senderName: string): Promise<string> {
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()
Expand Down Expand Up @@ -1030,11 +1030,65 @@ async function listAgents(): Promise<string> {
})
}

// Run `sudo -n 5dive <args> --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 <args> --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) }
Expand Down
2 changes: 1 addition & 1 deletion plugins/telegram-grok/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/telegram-grok/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading