@agfpd/iapeer 0.2.38 → 0.2.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.38",
3
+ "version": "0.2.39",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -487,6 +487,46 @@ function validateAttachments(value: readonly string[] = []): Result<string[]> {
487
487
  return ok(out)
488
488
  }
489
489
 
490
+ // ─── Telegram-domain sender policy (defect track 11.06, sanctioned by Артур) ─
491
+
492
+ /** A SENDER face in the telegram domain: a non-empty interfaces.telegram.bot (the
493
+ * bot machine-key) in the peer's registry passport. Deliberately bot-ONLY, not
494
+ * bot|user_id — the bridge picks the SENDING bot from the sender's bot key
495
+ * (owner fact, telegram-runtime сверка 11.06): a user_id grants a receive/operator
496
+ * identity but no ability to send, so a user_id-only sender passing the guard
497
+ * would still die at the bridge — a late asynchronous failure where the guard
498
+ * exists to give a synchronous one. The guard passes exactly what the bridge can
499
+ * deliver. */
500
+ export function hasTelegramPresence(record: PeerRecord): boolean {
501
+ const tg = record.interfaces?.telegram
502
+ if (!tg || typeof tg !== 'object' || Array.isArray(tg)) return false
503
+ const t = tg as Record<string, unknown>
504
+ return typeof t.bot === 'string' && t.bot.trim() !== ''
505
+ }
506
+
507
+ /** Sender policy for the telegram domain: a peer with NO declared telegram presence
508
+ * must not write into it — the recipient (a human in Telegram) would face a message
509
+ * from a peer that has no telegram face (identity confusion: who writes, by what
510
+ * right?). Bot channel = declared presence; no presence — no right to the domain.
511
+ * Enforced at the ROUTER, not the bridge: the refusal is synchronous and lands in
512
+ * the sender's tool result; a bridge-side check would fire AFTER the router's
513
+ * ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
514
+ * the INTENDED channel (override / target default — before any wake side-effect,
515
+ * since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
516
+ * channel at both delivery points (the only-live-session fallback can land on
517
+ * telegram without either). */
518
+ function telegramSenderGuard(
519
+ caller: ResolvedCaller,
520
+ targetRuntime: string,
521
+ targetPersonality: string,
522
+ ): Result<void> {
523
+ if (targetRuntime !== 'telegram') return ok(undefined)
524
+ if (hasTelegramPresence(caller.record)) return ok(undefined)
525
+ return err(
526
+ `telegram policy: sender "${caller.personality}" has no telegram face (no interfaces.telegram.bot in its passport) — message to "${targetPersonality}" NOT delivered; route via a telegram-faced peer (e.g. boris) or provision a bot for this sender`,
527
+ )
528
+ }
529
+
490
530
  /**
491
531
  * Route a send from a request-resolved caller to a target peer and deliver it.
492
532
  * hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
@@ -529,6 +569,12 @@ export async function routeSend(
529
569
  return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
530
570
  }
531
571
 
572
+ // Telegram sender policy — INTENDED channel (explicit override, or the target's
573
+ // default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
574
+ // the boot first-message, past the resolved-target guards further down.
575
+ const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
576
+ if (!intendedGuard.ok) return intendedGuard
577
+
532
578
  // Built once — it is both the live-delivery payload and, on a miss, the wake
533
579
  // first-message (the woken session receives it as its boot task).
534
580
  const envelope = buildEnvelope({
@@ -554,6 +600,10 @@ export async function routeSend(
554
600
  const target = resolvePeerDeliveryTarget(personality, runtime, peer)
555
601
  if (target.ok) {
556
602
  if (target.value.address === caller.address) return err('cannot send to self')
603
+ // Telegram sender policy — RESOLVED channel (the only-live-session fallback can
604
+ // land on telegram without an override and without telegram being the default).
605
+ const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
606
+ if (!liveGuard.ok) return liveGuard
557
607
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
558
608
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
559
609
  if (!delivered.ok) return delivered
@@ -581,6 +631,11 @@ export async function routeSend(
581
631
  return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
582
632
  }
583
633
  if (live.value.address === caller.address) return err('cannot send to self')
634
+ // Telegram sender policy — post-wake RESOLVED channel (airtight: a wake should
635
+ // never bring up a telegram session — they are launchd-held — but the policy is
636
+ // enforced on every delivery point, not on an assumption about the wake).
637
+ const postWakeGuard = telegramSenderGuard(caller, live.value.runtime, personality)
638
+ if (!postWakeGuard.ok) return postWakeGuard
584
639
  // taskDelivered:false — the wake took the idempotent live-session fast path (a
585
640
  // CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
586
641
  // the boot first-message). OUR envelope was not delivered by anything yet —
@@ -17,7 +17,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
- import { routeSend, type WakeFn } from './index.ts'
20
+ import { hasTelegramPresence, routeSend, type WakeFn } from './index.ts'
21
21
  import type { ResolvedCaller } from '../identity/index.ts'
22
22
  import type { PeerRecord } from '../registry/index.ts'
23
23
 
@@ -48,6 +48,10 @@ const callerRecord = {
48
48
  description: '',
49
49
  intelligence: 'artificial',
50
50
  cwd: '/tmp/boris',
51
+ // Telegram sender policy: the target of these suites is a telegram peer, so the
52
+ // caller must declare a telegram presence (the bot machine-key) — these suites
53
+ // double as the "bot-faced sender delivers as before" half of the policy criterion.
54
+ interfaces: { telegram: { bot: 'boris' } },
51
55
  } as unknown as PeerRecord
52
56
  const caller: ResolvedCaller = {
53
57
  personality: 'boris',
@@ -213,3 +217,89 @@ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)'
213
217
  killTargetSession()
214
218
  })
215
219
  })
220
+
221
+ // ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
222
+ // A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
223
+ // must get a SYNCHRONOUS refusal when the delivery channel is telegram — the
224
+ // incident class: the bridge silently dropped such envelopes while the router
225
+ // reported ok:true. The bot-faced "delivers as before" half of the criterion is
226
+ // carried by the suites above (their caller declares interfaces.telegram.bot).
227
+
228
+ const facelessCaller: ResolvedCaller = {
229
+ personality: 'ghost',
230
+ runtime: 'claude',
231
+ address: 'claude-ghost',
232
+ description: '',
233
+ intelligence: 'artificial',
234
+ cwd: '/tmp/ghost',
235
+ record: {
236
+ personality: 'ghost',
237
+ runtime: 'claude',
238
+ runtimes: ['claude'],
239
+ description: '',
240
+ intelligence: 'artificial',
241
+ cwd: '/tmp/ghost',
242
+ } as unknown as PeerRecord,
243
+ }
244
+
245
+ describe('telegram sender policy — hasTelegramPresence', () => {
246
+ const rec = (tg: unknown): PeerRecord =>
247
+ ({ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/x', ...(tg !== undefined ? { interfaces: { telegram: tg } } : {}) }) as unknown as PeerRecord
248
+ test('bot machine-key (agent peers) → sender face present', () => {
249
+ expect(hasTelegramPresence(rec({ bot: 'boris' }))).toBe(true)
250
+ })
251
+ test('user_id alone (natural peers — arthur) → NO sender face: the bridge sends FROM the bot key; user_id is a receive/operator identity (owner fact, bot-only predicate)', () => {
252
+ expect(hasTelegramPresence(rec({ user_id: '409502965' }))).toBe(false)
253
+ expect(hasTelegramPresence(rec({ user_id: 409502965 }))).toBe(false)
254
+ })
255
+ test('no interfaces / no telegram / empty bot → absent', () => {
256
+ expect(hasTelegramPresence(rec(undefined))).toBe(false)
257
+ expect(hasTelegramPresence(rec({}))).toBe(false)
258
+ expect(hasTelegramPresence(rec({ bot: '' }))).toBe(false)
259
+ expect(hasTelegramPresence(rec({ bot: ' ' }))).toBe(false)
260
+ expect(hasTelegramPresence(rec({ aliases: { '/alias-new': 'x' } }))).toBe(false)
261
+ })
262
+ })
263
+
264
+ describe('telegram sender policy — routeSend refusal', () => {
265
+ test('INTENDED channel (target default = telegram, peer offline): refused BEFORE the wake — wake never invoked', async () => {
266
+ let wakeCalled = false
267
+ const wake: WakeFn = async () => {
268
+ wakeCalled = true
269
+ return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
270
+ }
271
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'm' }, { wake })
272
+ expect(r.ok).toBe(false)
273
+ if (!r.ok) {
274
+ expect(r.error.message).toContain('telegram policy')
275
+ expect(r.error.message).toContain('"ghost"')
276
+ expect(r.error.message).toContain('NOT delivered')
277
+ }
278
+ expect(wakeCalled).toBe(false)
279
+ })
280
+
281
+ test('explicit runtime=telegram override: refused with the policy line, not "offline"', async () => {
282
+ const r = await routeSend(facelessCaller, { personality: TARGET, runtime: 'telegram', message: 'm' })
283
+ expect(r.ok).toBe(false)
284
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
285
+ })
286
+ })
287
+
288
+ describe.if(tmuxAvailable)('telegram sender policy — live telegram session', () => {
289
+ test('faceless sender + LIVE telegram target → refused, envelope NEVER reaches the pane', async () => {
290
+ startTargetSession()
291
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'POLICY-MUST-BLOCK' })
292
+ expect(r.ok).toBe(false)
293
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
294
+ expect(capturePane()).not.toContain('POLICY-MUST-BLOCK')
295
+ killTargetSession()
296
+ })
297
+
298
+ test('bot-faced sender (boris) + LIVE telegram target → delivered as before', async () => {
299
+ startTargetSession()
300
+ const r = await routeSend(caller, { personality: TARGET, message: 'BOT-FACED-OK' })
301
+ expect(r.ok).toBe(true)
302
+ expect(capturePane()).toContain('BOT-FACED-OK')
303
+ killTargetSession()
304
+ })
305
+ })