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
1 change: 1 addition & 0 deletions changelog.d/118-face-human-who.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A face-to-face human line no longer shows a name the sending client chose. The room labels the authenticated Bot Social `from` member (home / handle / display name). A spoofed `FLYBOT-FACE-HUMAN` `who` is ignored; old events without `from` fall back to that peer's home label, not another member. ([#118](https://github.com/ThinkFlowLab/FlyBot/pull/118))
2 changes: 1 addition & 1 deletion docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots

### Face-to-face owner lines

- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The envelope `who` is still taken from the JSON (a follow-up should bind the display name to the sending member record). Still not team mode.
- `onFaceLine` receives every appended room line, including the owner's typed human lines. Channel `send` posts those through `postRoomMessage` (not `sessionController.prompt`) so they reach this hook immediately; the social plugin sends them through Bot Social as the owner's local room Bot with a `FLYBOT-FACE-HUMAN` envelope so every human in the room sees the text; incoming envelopes apply as `authorKind: 'human'` and the stored body is fenced (#98). Bot replies stay bot-authored. The displayed sender is the Bot Social `from` snapshot (`trustedFaceWho`); the envelope `who` is ignored so a peer cannot impersonate another member. An event with no `from` falls back to the authenticated handle's home label, then Someone. Still not team mode.

### Host Bot look for classic-script plugins

Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers

### Face-to-face two-instance rooms (#117)

- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer.
- Friends' Bots polls `social/face-rooms` with `poll: true` about every 5s while the section is open, so an opened room, a new member, and the compact rooms card appear without a reload. While matching, the status chip is a short `10m` / `joining` label so the name and **Start** fit; after **Enter chat** / start the hosted `room.face.status` is `open`. After **Leave**, only **Past rooms** remains — no empty "Face-to-face rooms" header or Auto-send. Waiting tiles use the server `label` (`Chief · BoxB`). A snapshot member that is new on this side still gets a join line when the join event is before the floored `after` cursor. Owner-typed lines in the open room fan out over `onFaceLine`: Bot Social face messages are bot-authored, so FlyBot sends a `FLYBOT-FACE-HUMAN` envelope as the owner's local room Bot (the owner's click/type is consent; a `pending_approval` is decided in that same step) and the other instance applies it as `authorKind: 'human'`. The displayed sender is the server-stamped `from` member, never the envelope `who`. Incoming peer/human bodies are stored fenced in the room log (#98). Join still does not grant the Bot to friends; Friends' Bots also refreshes `social/remote-bots` on its open timer.

### Inbound shared groups

Expand Down
30 changes: 27 additions & 3 deletions plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 42 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -221,6 +221,30 @@
return cleanFaceName(fallback, 'Someone')
}

// Display name for a face-room line: the Bot Social `from` snapshot is the
// authenticated sender. Never take `who` from the FLYBOT-FACE-HUMAN JSON (a
// peer can put another member's name there). Old events without `from` fall
// back to the handle's home label, then Someone — not the claimed name.
export function trustedFaceWho(from) {
if (!from || typeof from !== 'object') return 'Someone'
const label = cleanFaceName(from.label, '')
if (label) return label
Comment on lines +230 to +231

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the home fallback through event normalization

When a legacy room.message has an authenticated from.handle but omits profile fields, renderFaceEvent() first passes from through viewFaceMember(), which always synthesizes a label (for @writer.alice.home, it produces Writer). This early return therefore prevents the handle branch from selecting Alice, so these messages are mislabeled despite the intended home-label fallback; the new test misses this by calling trustedFaceWho() directly on the raw object. Preserve whether the label was server-supplied or derive the home segment before normalization.

Useful? React with 👍 / 👎.

const owner = cleanFaceName(from.owner_name || from.ownerName, '')
if (owner) {
const named = faceMemberLabel(from)
if (named && named !== 'Bot') return named
return owner
}
const handle = String(from.handle || from.id || '')
const parts = handle.replace(/^@+/, '').split('.').filter(Boolean)
const home = parts.length >= 2 && parts.at(-1)?.toLowerCase() === 'home'
? parts[parts.length - 2]
: ''
const shown = cleanFaceName(home, '')
if (!shown) return 'Someone'
return shown.charAt(0).toUpperCase() + shown.slice(1)
}

export function faceRoomTitle(snapshot = {}, { code, room } = {}) {
const owners = []
for (const member of snapshot.members ?? room?.participants ?? []) {
Expand Down Expand Up @@ -309,7 +333,7 @@
|| (typeof member.color === 'string' && !HEX_ONLY.test(member.color) ? member.color.trim().slice(0, 32) : '')
if (color && !/[\n<>]/.test(color)) out.color = color
const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {}
const avatar = { ...(fromServer.avatar ?? {}) }

Check warning on line 336 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape
const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '')
if (image && image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image
Expand Down Expand Up @@ -391,7 +415,7 @@
if (type === 'member.left' && member) return platformLeaveText(member)
if (type === 'room.started') return platformStartedText(reason)
const cleaned = String(text ?? '')
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 418 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand All @@ -406,14 +430,14 @@
const from = viewed.from || {}
const body = viewed.untrustedText ?? ''
const human = parseFaceHuman(body)
const label = human?.who || from.label || faceMemberLabel(from)
const who = trustedFaceWho(from)
const text = human ? human.text : body
return {
kind: undefined,
system: false,
who: label,
who,
text,
fenced: fenceFaceText(label, text),
fenced: fenceFaceText(who, text),
authorId: human ? (from.handle || 'human') : from.handle,
authorKind: human ? 'human' : 'bot',
remote: true,
Expand Down
2 changes: 1 addition & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@
const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-separated-values', 'application/json'])
const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
// Remote strings that reach model-visible text: one line, no brackets or markup, capped.
export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback

Check warning on line 80 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// Owner-only draft body: keep punctuation the friend will see; strip controls and cap.
export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800)

Check warning on line 82 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// Bot Social list_approvals card → the Friends' Bots queue (outbound drafts only).
const looksLikeBotId = (value) => /^bot_[a-z0-9]+$/i.test(String(value ?? '').trim())
export function approvalView(item, { localName } = {}) {
Expand Down Expand Up @@ -125,7 +125,7 @@
}
const SEEN_KEPT = 1000
const CREDENTIAL_NAME = /(^id_(rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|kdbx|keychain)$|credential|secret|token|password|passwd|^flybot-social.*\.json$|^\.?env$|^\.netrc$|^known_hosts$|^authorized_keys$)/i
const safeName = name => String(name ?? 'file').replace(/[\u0000-\u001f\u007f/\\:]/g, '_').replace(/^\.+/, '_').slice(0, 120) || 'file'

Check warning on line 128 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// The text Copy puts on the clipboard for a per-Bot invite: what to do with the link, for a
// person in FlyBot or for their Grok bot.
export function inviteText({ handle, name, home, url, expiresAt }) {
Expand Down Expand Up @@ -564,7 +564,7 @@
if (result?.unavailable) return
} catch (error) {
if (/invalid_avatar|invalid_color|invalid_request/i.test(`${error?.code ?? ''} ${error?.message ?? ''}`)) {
settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint }

Check warning on line 567 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
saveSettings()
return
}
Expand Down Expand Up @@ -1982,7 +1982,7 @@
} else if (line.human || line.authorKind === 'human') {
const local = locals.some(bot => bot.handle && bot.handle === line.authorId)
if (local) continue
const who = humanFaceWho(line, ownerDisplayName(snapshot, hosted))
const who = line.who || 'Someone'
flybot.rooms.applyFaceLine?.(room.id, { ...line, who, remote: true, authorKind: 'human' })
void flybot.rooms.postNotice?.(room.id, `${who}: ${line.text}`)
} else {
Expand Down
27 changes: 26 additions & 1 deletion plugins/social/test/face-rooms.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
parseFaceHuman,
faceOutbound,
humanFaceWho,
trustedFaceWho,
faceRoomTitle,
FACE_HUMAN_MARK,
} from '../src/face-rooms.js'
Expand Down Expand Up @@ -138,10 +139,34 @@ test('owner-typed face-room lines encode as a human envelope', () => {
untrusted_content: { text: packed },
})
assert.equal(rendered.authorKind, 'human')
assert.equal(rendered.who, 'BoxA')
assert.equal(rendered.who, "BoxA's Chief")
assert.equal(rendered.text, 'hello from the owner')
})

test('a spoofed FLYBOT-FACE-HUMAN who cannot impersonate another room member', () => {
const packed = encodeFaceHuman({ who: 'Alice', text: 'I am Alice' })
const spoofed = renderFaceEvent({
event_id: 'fev_spoof', event_cursor: 10, type: 'room.message',
from: { handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob', label: "Bob's Chief" },
untrusted_content: { text: packed },
})
assert.equal(spoofed.authorKind, 'human')
assert.equal(spoofed.who, "Bob's Chief")
assert.notEqual(spoofed.who, 'Alice')
assert.match(spoofed.fenced, /from="Bob's Chief"/)
assert.doesNotMatch(spoofed.fenced, /from="Alice"/)
assert.equal(spoofed.text, 'I am Alice')
const noFrom = renderFaceEvent({
event_id: 'fev_old', event_cursor: 11, type: 'room.message',
untrusted_content: { text: packed },
})
assert.equal(noFrom.who, 'Someone')
assert.notEqual(noFrom.who, 'Alice')
assert.equal(trustedFaceWho({ handle: '@writer.alice.home' }), 'Alice')
assert.equal(trustedFaceWho({ label: 'Chief · BoxA', display_name: 'Chief', owner_name: 'BoxA' }), 'Chief · BoxA')
assert.equal(trustedFaceWho(undefined), 'Someone')
})

test('peer room.message text is fenced and cannot rebuild a closing marker', () => {
const event = viewFaceEvent({
event_id: 'fev_1',
Expand Down
28 changes: 28 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,34 @@ test('face-to-face: an owner-typed line is fanned out and shown as a human on th
assert.match(String(human.text), /^<untrusted-message from="/)
})

test('a spoofed FLYBOT-FACE-HUMAN who is ignored; the room shows the authenticated from', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
const server = withFaceRooms(mockServer())
const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, {
client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }),
home: { id: 'h1', handle: '@danyang.home' },
})
t.after(() => social.dispose())
await grantTeam(ctx.flybot)
await social.registerAll()
await call('flybot/social/face-join', { code: '123456' })
const body = server.face.rooms.get('frm_123456')
body.event_cursor += 1
body.events.push({
event_id: `fev_${body.event_cursor}`, event_cursor: body.event_cursor, type: 'room.message',
from: { handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob', published: false, label: "Bob's Chief" },
trust: 'peer_untrusted', untrusted_content: { text: encodeFaceHuman({ who: 'Alice', text: 'hi I am Alice' }) },
})
await call('flybot/social/face-rooms', { poll: true })
const hosted = ctx.flybot.rooms.ofFace('frm_123456')
const spoofed = (hosted.log ?? []).find(line => /hi I am Alice/.test(line.text || ''))
assert.ok(spoofed, 'the spoofed envelope still delivers its text')
assert.equal(spoofed.authorKind, 'human')
assert.match(String(spoofed.who), /Bob/)
assert.doesNotMatch(String(spoofed.who), /Alice/)
})

test('face-to-face send pending_approval uses decide_approval', async (t) => {
const { ctx, call, stop } = await team()
t.after(stop)
Expand Down
Loading