Skip to content

feat(mcp): enforce the room-responder lock in room_post/alert_recipient (#42)#45

Merged
ThinkOffApp merged 2 commits into
mainfrom
feat/responder-voice-gate
Jul 16, 2026
Merged

feat(mcp): enforce the room-responder lock in room_post/alert_recipient (#42)#45
ThinkOffApp merged 2 commits into
mainfrom
feat/responder-voice-gate

Conversation

@ThinkOffApp

Copy link
Copy Markdown
Owner

Closes the client-side half of #42 (review finding 4 on #40): the PASSIVE role was model-followed instructions only — this makes the IAK toolpath refuse the post.

What

  • New src/responder-lock.mjs: reads the lock written by session-bootstrap.sh, evaluates owner liveness with the same pid + process-start-time identity as the hook, and decides whether the calling process may speak (assertRoomVoice). The owning session is recognized by finding the lock pid in the caller's ancestor chain (the MCP server is a child of the session process; walk capped at 10 levels for wrappers).
  • room_post and alert_recipient refuse with the owner's pid/session + takeover recipe when another live session holds the lock. room_recent (read-only) unaffected.
  • Fail-open on mechanics (missing/corrupt lock, ps failure) — same philosophy as the hook: a broken lock must not silence the machine.

Tests

9 new cases in test/responder-lock.test.mjs using an injectable fake process table: disabled/absent/unreadable locks, stale owner, recycled pid, owner-in-ancestor-chain allowed, live-foreign-owner refused (the #42 case), legacy no-pstart lock, ps-explosion fail-open. mcp-server suite 29/29.

Out of scope

Raw curl against the GroupMind API can't be blocked client-side; the server-side fix (per-session keys) stays tracked in #42.

🤖 Generated with Claude Code

…nt (#42)

The PASSIVE role from the session bootstrap (#40) is model-followed
instructions; a session that ignores them could still post as the
agent through the IAK MCP tools. room_post and alert_recipient now
check the responder lock before posting and refuse with the owner's
identity + the takeover recipe when another live session holds it.

Semantics mirror the bash hook exactly: lock path from
IAK_RESPONDER_LOCK / <notification file>.responder.lock ('off'
disables), owner identity = pid + process start time (recycled pids
and dead owners read as stale -> allowed), fail open on mechanics.
The lock-holding session is recognized by walking the caller's
process-ancestor chain (the MCP server is spawned by the session
process). Read-only room tools stay available to passive sessions.

9 new unit tests with an injectable fake process table; mcp-server
suite 29/29.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@ThinkOffApp ThinkOffApp left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review: found one concrete availability issue in the ancestor walk. The PR otherwise covers the requested live-owner, pid-recycle, legacy-lock, and initial-ps-failure cases, but this intermediate ps failure path needs a fix and regression test before merge. GitHub would not allow REQUEST_CHANGES because the review identity is the PR author.

Comment thread src/responder-lock.mjs
return '';
}
}

Copy link
Copy Markdown
Owner Author

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 an exec('ps', ...) error into 0, which is indistinguishable from reaching the process root. If procStart() succeeds but ps -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 have assertRoomVoice() allow on that state, with a test where only the ancestor lookup throws.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42d6553346

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/responder-lock.mjs Outdated
Comment on lines +107 to +108
if (isSelfOrAncestor(lock.pid, selfPid, exec)) {
return { allowed: true, reason: 'this session holds the responder lock' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require sid match before trusting ancestor pid

When Claude Code launches multiple logical sessions under the same parent pid, the bootstrap deliberately demotes a different session_id even if LOCK_PID == $PPID (checked scripts/session-bootstrap.sh:175-184, which notes sibling hook processes can share a parent). This new allow branch only checks that the lock pid is in the MCP server's ancestor chain and ignores lock.sid, so a PASSIVE sibling with the same parent pid is treated as the lock holder and can still use room_post/alert_recipient, bypassing the enforcement this commit adds.

Useful? React with 👍 / 👎.

Codex review on #45: an intermediate ps failure during the
ancestor-chain walk collapsed into "chain ended" and could refuse a
legitimate lock owner. parentOf now distinguishes the two cases via
execFileSync's .status: nonzero exit = ps ran, pid gone = chain
legitimately ended; no .status = ps never ran (ENOENT/EAGAIN) =
unknown, propagated as null so assertRoomVoice fails OPEN. Two
regression tests: spawn-level mid-walk failure -> allowed; exited
walk ancestor -> chain ends -> refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ThinkOffApp

Copy link
Copy Markdown
Owner Author

Fixed in the new commit. parentOf now returns a three-way result: parent pid, 0 when the chain legitimately ended (ps ran and exited nonzero — pid gone), or null when the lookup itself failed at spawn level (no .status on the error — ENOENT/EAGAIN). isSelfOrAncestor propagates null, and assertRoomVoice treats it as fail open ("ancestor lookup failed mid-walk"), never as "not an ancestor". Two regression tests added: intermediate spawn-level failure with the owner genuinely in the chain → allowed; an exited walk ancestor (ps ran, pid gone) → chain ends → refused. 11/11.

@ThinkOffApp
ThinkOffApp merged commit e449864 into main Jul 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant