From 98010a4c00e1589811770156327998ca2481c19d Mon Sep 17 00:00:00 2001 From: claudemm Date: Mon, 3 Aug 2026 19:56:56 +0300 Subject: [PATCH] Owner identities are configured, because a person is not one handle petrus tapped Approve from his tablet and nothing happened. The guard compared the sender against the literal string "petrus"; his tablet posts as "@petrus-boox". Verified in the raw API payload rather than inferred: 13:43:46 from='@petrus-boox' isHuman=False -> dropped, intent stayed pending 14:12:42 from='petrus' isHuman=False -> settled instantly Same person, same room, same command, 29 minutes apart. `owners` is now a list, defaulting to ['petrus'] so existing callers behave exactly as before, read from mcp.confirmations.owners. DELIBERATELY AN EXPLICIT LIST, NOT A PREFIX MATCH. A `petrus-*` rule would be shorter and would hand approval authority to any agent that registers itself as "petrus-helper" -- which is precisely the attack this guard was added for, after a fleet agent auto-replied "/approve " to a card. Verified: petrus-helper is rejected, @petrus-boox is accepted. The `@` prefix is stripped and case is normalised on both sides, so "@Petrus-BOOX" in config matches "petrus-boox" on the wire. Worth knowing what this does NOT fix: it covers a TYPED /approve. The tablet's Approve button is a separate path, and while testing this we found the app had stopped posting from that device entirely since 14:03, so the button remains unverified either way. Granting a device handle approval authority means granting it to whoever holds that device's API key. petrus asked for it explicitly, knowing that, after the alternative was laid out. Co-Authored-By: Claude Opus 5 --- bin/iak-mcp-daemon.mjs | 3 +++ src/confirmations.mjs | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/bin/iak-mcp-daemon.mjs b/bin/iak-mcp-daemon.mjs index 2771064..7dad42f 100755 --- a/bin/iak-mcp-daemon.mjs +++ b/bin/iak-mcp-daemon.mjs @@ -86,6 +86,9 @@ if (!apiKey) { } else { startChatReplyPoller({ apiKey, room, intervalMs: 5000, + // Owner identities that may settle intents. A person is not one handle: + // petrus posts from a web session, a tablet and (soon) a watch. + owners: cc.owners, log: (msg) => console.log(`[iak-mcp-daemon] ${msg}`), }); console.log(`[iak-mcp-daemon] chat-reply poller watching room "${room}" every 5s`); diff --git a/src/confirmations.mjs b/src/confirmations.mjs index 5658841..e14ca88 100644 --- a/src/confirmations.mjs +++ b/src/confirmations.mjs @@ -1065,11 +1065,20 @@ export function composeAnnouncers(map) { // // Logs go to stderr only (stdout is the MCP stdio protocol channel — writing // there would corrupt it). Returns the interval handle so callers can stop it. -export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log }) { +// `owners` is an EXPLICIT list of handles allowed to settle intents. It is a +// list of exact names, deliberately not a prefix match: an agent can register +// any handle it likes, so `petrus-*` would let a fleet agent call itself +// "petrus-helper" and inherit approval authority — which is the precise attack +// this guard was written to stop. +export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log, owners = ['petrus'] }) { if (!apiKey || !room) { process.stderr.write('[iak-mcp] chat-reply poller: missing apiKey or room — disabled\n'); return null; } + const ownerSet = new Set( + (Array.isArray(owners) && owners.length ? owners : ['petrus']) + .map((o) => String(o).replace(/^@/, '').toLowerCase()) + ); const emit = log || ((msg) => process.stderr.write(`[iak-mcp] ${msg}\n`)); const seen = new Set(); let primed = false; @@ -1092,8 +1101,13 @@ export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log }) { // which this poller happily executed — any agent could approve any // gated command. Agent senders carry a handle ("@ether", "hermes"); // the owner posts as plain "petrus" (CodeWatch button taps included). + // 2026-08-03: this used to compare against the literal "petrus", so a + // decision from petrus's own tablet ("@petrus-boox") was dropped in + // silence — he tapped Approve on camera and nothing happened. Owner + // identities are now configured, because a person is not one handle: + // they are a laptop, a tablet and a watch. const sender = String(m.from || '').replace(/^@/, '').toLowerCase(); - if (sender !== 'petrus' && m.isHuman !== true) { + if (!ownerSet.has(sender) && m.isHuman !== true) { emit(`${text} from ${m.from}: sender is not the owner — ignoring`); continue; }