-
Notifications
You must be signed in to change notification settings - Fork 2
feat(mcp): enforce the room-responder lock in room_post/alert_recipient (#42) #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| // | ||
| // Enforcement half of the single room-responder lock (#40 wrote it, #42 | ||
| // enforces it): scripts/session-bootstrap.sh gives PASSIVE sessions | ||
| // instructions not to answer the room, but instructions are model-followed. | ||
| // This module lets the MCP room-post path REFUSE the post when another live | ||
| // session holds the machine's responder lock, so a session that ignores its | ||
| // PASSIVE context still cannot speak as the agent through IAK tooling. | ||
| // (Raw curl against the GroupMind API is out of scope here — that needs | ||
| // server-side per-session keys; see issue #42.) | ||
| // | ||
| // Semantics mirror the bash hook exactly: | ||
| // - lock file: $IAK_RESPONDER_LOCK, else <notification file>.responder.lock; | ||
| // the literal value "off" disables enforcement. | ||
| // - owner identity: pid + process start time (a recycled pid is stale). | ||
| // - fail OPEN on mechanics (missing/corrupt lock, ps failures): a machine | ||
| // with a broken lock keeps its room voice; the hook is the same way. | ||
| // - "we hold the lock": the lock's pid is this process or one of its | ||
| // ancestors — the MCP server is spawned by the session process, so the | ||
| // owning session appears in our parent chain. | ||
|
|
||
| import { readFileSync } from 'node:fs'; | ||
| import { execFileSync } from 'node:child_process'; | ||
|
|
||
| export function lockPathFor(config, env = process.env) { | ||
| const explicit = env.IAK_RESPONDER_LOCK; | ||
| if (explicit) return explicit; // may be the literal 'off' | ||
| const newFile = | ||
| env.IAK_NEW_FILE || | ||
| config?.poller?.notification_file || | ||
| '/tmp/iak-new-messages.txt'; | ||
| return `${newFile}.responder.lock`; | ||
| } | ||
|
|
||
| export function readResponderLock(lockPath) { | ||
| try { | ||
| const raw = readFileSync(lockPath, 'utf8'); | ||
| const get = (k) => { | ||
| const m = raw.match(new RegExp(`^${k}=(.*)$`, 'm')); | ||
| return m ? m[1].trim() : ''; | ||
| }; | ||
| const pid = parseInt(get('pid'), 10); | ||
| return { | ||
| pid: Number.isInteger(pid) && pid > 0 ? pid : null, | ||
| sid: get('sid'), | ||
| pstart: get('pstart'), | ||
| }; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function procStart(pid, exec) { | ||
| try { | ||
| return exec('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).trim(); | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } | ||
|
|
||
| // Returns the parent pid, 0 when the chain legitimately ended (ps ran and | ||
| // the pid is gone), or null when the LOOKUP ITSELF failed (spawn-level | ||
| // error) — the caller must treat null as unknown, never as "not an | ||
| // ancestor", or a transient ps failure would refuse a legitimate owner | ||
| // (codex review on #45). | ||
| function parentOf(pid, exec) { | ||
| try { | ||
| const out = exec('ps', ['-p', String(pid), '-o', 'ppid='], { encoding: 'utf8' }).trim(); | ||
| const parent = parseInt(out, 10); | ||
| return Number.isInteger(parent) && parent > 0 ? parent : 0; | ||
| } catch (e) { | ||
| // execFileSync sets .status when the command RAN and exited nonzero — | ||
| // for ps that means "no such pid": the chain ended. No .status means | ||
| // ps never ran (ENOENT, EAGAIN, …): mechanics failure -> unknown. | ||
| if (e && typeof e.status === 'number') return 0; | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // true / false / null(unknown — caller fails open). | ||
| function isSelfOrAncestor(targetPid, fromPid, exec) { | ||
| let pid = fromPid; | ||
| // The owning session process is normally our direct parent; walk a few | ||
| // levels to cover wrapper processes between the session and this server. | ||
| for (let hops = 0; hops < 10 && pid && pid > 1; hops++) { | ||
| if (pid === targetPid) return true; | ||
| pid = parentOf(pid, exec); | ||
| if (pid === null) return null; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Decide whether THIS process may post to the room as the agent. | ||
| * Returns {allowed, reason}. Never throws. | ||
| * | ||
| * Injectables (tests): env, selfPid (start of the ancestor walk), exec. | ||
| */ | ||
| export function assertRoomVoice({ | ||
| config, | ||
| env = process.env, | ||
| selfPid = process.pid, | ||
| exec = execFileSync, | ||
| } = {}) { | ||
| try { | ||
| const lockPath = lockPathFor(config, env); | ||
| if (lockPath === 'off') return { allowed: true, reason: 'responder lock disabled' }; | ||
|
|
||
| const lock = readResponderLock(lockPath); | ||
| if (!lock || !lock.pid) return { allowed: true, reason: 'no responder lock' }; | ||
|
|
||
| const start = procStart(lock.pid, exec); | ||
| if (!start) return { allowed: true, reason: 'lock owner process is gone (stale lock)' }; | ||
| if (lock.pstart && start !== lock.pstart) { | ||
| return { allowed: true, reason: 'lock owner pid was recycled (stale lock)' }; | ||
| } | ||
|
|
||
| const ancestry = isSelfOrAncestor(lock.pid, selfPid, exec); | ||
| if (ancestry === null) { | ||
| return { allowed: true, reason: 'ancestor lookup failed mid-walk (fail open)' }; | ||
| } | ||
| if (ancestry) { | ||
| return { allowed: true, reason: 'this session holds the responder lock' }; | ||
| } | ||
|
|
||
| return { | ||
| allowed: false, | ||
| reason: | ||
| `another live session (pid ${lock.pid}${lock.sid ? `, session ${lock.sid}` : ''}) ` + | ||
| `holds the room-responder lock (${lockPath}). This session is PASSIVE and must not ` + | ||
| `post to the room — duplicate agent voices confuse the team. Takeover only on the ` + | ||
| `user's explicit request: delete the lock file, then re-run the standard bootstrap.`, | ||
| }; | ||
| } catch { | ||
| // Mechanics failure: fail open, same philosophy as the bash hook. | ||
| return { allowed: true, reason: 'lock check failed (fail open)' }; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| import { describe, it, afterEach } from 'node:test'; | ||
| import { strict as assert } from 'node:assert'; | ||
| import { mkdtempSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { assertRoomVoice, lockPathFor, readResponderLock } from '../src/responder-lock.mjs'; | ||
|
|
||
| const tempPaths = []; | ||
| function tempDir() { | ||
| const dir = realpathSync(mkdtempSync(join(tmpdir(), 'iak-voice-'))); | ||
| tempPaths.push(dir); | ||
| return dir; | ||
| } | ||
| afterEach(() => { | ||
| while (tempPaths.length > 0) rmSync(tempPaths.pop(), { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| /** | ||
| * Fake process table for the injectable exec: {pid: {lstart, ppid}}. | ||
| * Throws (like execFileSync) for unknown pids. | ||
| */ | ||
| function fakeExec(table) { | ||
| return (cmd, args) => { | ||
| const pid = parseInt(args[1], 10); | ||
| const col = args[3]; | ||
| const row = table[pid]; | ||
| if (!row) { | ||
| // Mimic execFileSync when ps RAN and found no such pid: nonzero exit. | ||
| const e = new Error(`no such process ${pid}`); | ||
| e.status = 1; | ||
| throw e; | ||
| } | ||
| if (row === 'SPAWN_FAILURE') { | ||
| // Mimic a spawn-level failure: ps never ran, no .status on the error. | ||
| throw new Error('spawn ps EAGAIN'); | ||
| } | ||
| if (col === 'lstart=') return `${row.lstart}\n`; | ||
| if (col === 'ppid=') return `${row.ppid}\n`; | ||
| throw new Error(`unexpected ps column ${col}`); | ||
| }; | ||
| } | ||
|
|
||
| function writeLock(dir, { pid, sid = 'sess-owner', pstart = '' }) { | ||
| const path = join(dir, 'new.txt.responder.lock'); | ||
| writeFileSync(path, `pid=${pid}\nsid=${sid}\n${pstart ? `pstart=${pstart}\n` : ''}`); | ||
| return path; | ||
| } | ||
|
|
||
| const START = 'Thu Jul 16 11:08:28 2026'; | ||
|
|
||
| describe('lockPathFor', () => { | ||
| it('derives from the notification file and honors env overrides', () => { | ||
| assert.equal(lockPathFor({}, {}), '/tmp/iak-new-messages.txt.responder.lock'); | ||
| assert.equal(lockPathFor({ poller: { notification_file: '/x/n.txt' } }, {}), '/x/n.txt.responder.lock'); | ||
| assert.equal(lockPathFor({}, { IAK_NEW_FILE: '/y/n.txt' }), '/y/n.txt.responder.lock'); | ||
| assert.equal(lockPathFor({}, { IAK_RESPONDER_LOCK: 'off' }), 'off'); | ||
| assert.equal(lockPathFor({}, { IAK_RESPONDER_LOCK: '/z/lock' }), '/z/lock'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('readResponderLock', () => { | ||
| it('parses the hook lock format and tolerates junk', () => { | ||
| const dir = tempDir(); | ||
| const path = writeLock(dir, { pid: 42, sid: 's1', pstart: START }); | ||
| assert.deepEqual(readResponderLock(path), { pid: 42, sid: 's1', pstart: START }); | ||
| assert.equal(readResponderLock(join(dir, 'missing')), null); | ||
| const junk = join(dir, 'junk'); | ||
| writeFileSync(junk, 'pid=not-a-number\n%%%\n'); | ||
| assert.equal(readResponderLock(junk).pid, null); | ||
| }); | ||
| }); | ||
|
|
||
| describe('assertRoomVoice', () => { | ||
| it('allows when the lock is disabled, absent, or unreadable', () => { | ||
| const dir = tempDir(); | ||
| assert.equal(assertRoomVoice({ env: { IAK_RESPONDER_LOCK: 'off' } }).allowed, true); | ||
| assert.equal(assertRoomVoice({ | ||
| env: { IAK_RESPONDER_LOCK: join(dir, 'nope') }, exec: fakeExec({}), | ||
| }).allowed, true); | ||
| }); | ||
|
|
||
| it('allows when the owner process is dead (stale lock)', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 999, pstart: START }); | ||
| const verdict = assertRoomVoice({ | ||
| env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec({ 10: { lstart: 'x', ppid: 1 } }), | ||
| }); | ||
| assert.equal(verdict.allowed, true); | ||
| assert.match(verdict.reason, /stale/); | ||
| }); | ||
|
|
||
| it('allows when the owner pid was recycled (pstart mismatch)', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 999, pstart: 'Thu Jan 1 00:00:00 1970' }); | ||
| const verdict = assertRoomVoice({ | ||
| env: { IAK_RESPONDER_LOCK: lock }, | ||
| selfPid: 10, | ||
| exec: fakeExec({ 999: { lstart: START, ppid: 1 }, 10: { lstart: 'x', ppid: 1 } }), | ||
| }); | ||
| assert.equal(verdict.allowed, true); | ||
| assert.match(verdict.reason, /recycled/); | ||
| }); | ||
|
|
||
| it('allows the session that holds the lock (owner in the ancestor chain)', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500, pstart: START }); | ||
| // self 10 -> parent 20 -> parent 500 (the owning session process) | ||
| const table = { | ||
| 10: { lstart: 'a', ppid: 20 }, | ||
| 20: { lstart: 'b', ppid: 500 }, | ||
| 500: { lstart: START, ppid: 1 }, | ||
| }; | ||
| const verdict = assertRoomVoice({ env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec(table) }); | ||
| assert.equal(verdict.allowed, true); | ||
| assert.match(verdict.reason, /holds the responder lock/); | ||
| }); | ||
|
|
||
| it('REFUSES a passive session while the owner is alive elsewhere', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500, sid: 'sess-owner', pstart: START }); | ||
| // self 10 -> 20 -> 1: owner 500 alive but NOT in our chain. | ||
| const table = { | ||
| 10: { lstart: 'a', ppid: 20 }, | ||
| 20: { lstart: 'b', ppid: 1 }, | ||
| 500: { lstart: START, ppid: 1 }, | ||
| }; | ||
| const verdict = assertRoomVoice({ env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec(table) }); | ||
| assert.equal(verdict.allowed, false); | ||
| assert.match(verdict.reason, /pid 500/); | ||
| assert.match(verdict.reason, /sess-owner/); | ||
| assert.match(verdict.reason, /PASSIVE/); | ||
| }); | ||
|
|
||
| it('legacy lock without pstart still refuses on a live owner pid', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500 }); | ||
| const table = { 10: { lstart: 'a', ppid: 1 }, 500: { lstart: START, ppid: 1 } }; | ||
| const verdict = assertRoomVoice({ env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec(table) }); | ||
| assert.equal(verdict.allowed, false); | ||
| }); | ||
|
|
||
| it('fails OPEN when an intermediate ancestor lookup fails at spawn level (codex regression)', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500, pstart: START }); | ||
| // Owner 500 is alive and IS our grandparent, but the walk's lookup of | ||
| // pid 20's parent fails at spawn level — must fail open, not refuse. | ||
| const table = { | ||
| 10: { lstart: 'a', ppid: 20 }, | ||
| 20: 'SPAWN_FAILURE', | ||
| 500: { lstart: START, ppid: 1 }, | ||
| }; | ||
| const verdict = assertRoomVoice({ env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec(table) }); | ||
| assert.equal(verdict.allowed, true); | ||
| assert.match(verdict.reason, /fail open/); | ||
| }); | ||
|
|
||
| it('a walk ancestor that exited (ps ran, pid gone) ends the chain and refuses', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500, pstart: START }); | ||
| // self 10 -> parent 77 which is GONE (ps exits 1): chain ends without | ||
| // reaching the live owner 500 -> refused, not crashed. | ||
| const table = { | ||
| 10: { lstart: 'a', ppid: 77 }, | ||
| 500: { lstart: START, ppid: 1 }, | ||
| }; | ||
| const verdict = assertRoomVoice({ env: { IAK_RESPONDER_LOCK: lock }, selfPid: 10, exec: fakeExec(table) }); | ||
| assert.equal(verdict.allowed, false); | ||
| }); | ||
|
|
||
| it('fails open when ps itself explodes', () => { | ||
| const dir = tempDir(); | ||
| const lock = writeLock(dir, { pid: 500, pstart: START }); | ||
| const verdict = assertRoomVoice({ | ||
| env: { IAK_RESPONDER_LOCK: lock }, | ||
| selfPid: 10, | ||
| exec: () => { throw new Error('ps unavailable'); }, | ||
| }); | ||
| assert.equal(verdict.allowed, true); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Preserve fail-open semantics when ancestor lookup fails.
parentOf()collapses anexec('ps', ...)error into0, which is indistinguishable from reaching the process root. IfprocStart()succeeds butps -o ppid=fails for the current process or an intermediate wrapper,isSelfOrAncestor()returns false and this legitimate lock owner is refused, contrary to the documented “ps failures fail open” contract. Return a distinct unknown/error state and haveassertRoomVoice()allow on that state, with a test where only the ancestor lookup throws.