@alfe.ai/openclaw-identity 0.0.19 → 0.1.1

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/dist/plugin2.cjs CHANGED
@@ -147,6 +147,71 @@ function setCachedResolve(key, value) {
147
147
  expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
148
148
  });
149
149
  }
150
+ function invalidateCachedResolve(key) {
151
+ resolveCache.delete(key);
152
+ }
153
+ function findMostRelevantCacheEntry(probeKeys) {
154
+ for (const k of probeKeys) {
155
+ if (!k) continue;
156
+ const hit = getCachedResolve(k);
157
+ if (hit) return hit;
158
+ }
159
+ let mostRecent = null;
160
+ const now = Date.now();
161
+ for (const entry of resolveCache.values()) {
162
+ if (entry.expiresAt < now) continue;
163
+ if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
164
+ }
165
+ return mostRecent;
166
+ }
167
+ const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
168
+ function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
169
+ if (!cacheKey) return false;
170
+ const cached = getCachedResolve(cacheKey);
171
+ if (!cached) return false;
172
+ const argProvider = toolArgs?.provider;
173
+ const argPlatformId = toolArgs?.platformId;
174
+ if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
175
+ return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
176
+ }
177
+ /**
178
+ * Resolve the inbound session identity from `conversationId`. Returns
179
+ * `{ error }` on cache miss / missing param — never falls back to LLM-
180
+ * supplied identity (that's the security property — see header).
181
+ */
182
+ function resolveSessionIdentity(params) {
183
+ const conversationId = params.conversationId;
184
+ if (!conversationId) return {
185
+ ok: false,
186
+ error: "missing_conversation_id: pass conversationId from the inbound message metadata. The verify tools auto-bind to the inbound sender's identity — there is no manual override."
187
+ };
188
+ const cached = getCachedResolve(conversationId);
189
+ if (cached?.identityId == null) return {
190
+ ok: false,
191
+ error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
192
+ };
193
+ return {
194
+ ok: true,
195
+ identityId: cached.identityId,
196
+ senderProvider: cached.senderProvider,
197
+ senderPlatformId: cached.senderPlatformId
198
+ };
199
+ }
200
+ function resolveRequesterContext(params) {
201
+ const session = resolveSessionIdentity(params);
202
+ if (!session.ok) return { error: session.error };
203
+ return {
204
+ claimedIdentityId: session.identityId,
205
+ requestingIdentityId: session.identityId,
206
+ requestingProvider: session.senderProvider,
207
+ requestingPlatformId: session.senderPlatformId
208
+ };
209
+ }
210
+ function resolveClaimedIdentity(params) {
211
+ const session = resolveSessionIdentity(params);
212
+ if (!session.ok) return { error: session.error };
213
+ return { claimedIdentityId: session.identityId };
214
+ }
150
215
  function ok(data) {
151
216
  return {
152
217
  content: [{
@@ -190,6 +255,27 @@ function getClient() {
190
255
  });
191
256
  return cachedClient;
192
257
  }
258
+ let cachedAgentContext = null;
259
+ let inflightWhoami = null;
260
+ async function getAgentContext(client) {
261
+ if (cachedAgentContext) return cachedAgentContext;
262
+ if (inflightWhoami) return inflightWhoami;
263
+ inflightWhoami = (async () => {
264
+ try {
265
+ const r = await client.whoami();
266
+ cachedAgentContext = {
267
+ agentId: r.agentId,
268
+ tenantId: r.tenantId
269
+ };
270
+ return cachedAgentContext;
271
+ } catch {
272
+ return null;
273
+ } finally {
274
+ inflightWhoami = null;
275
+ }
276
+ })();
277
+ return inflightWhoami;
278
+ }
193
279
  const plugin = {
194
280
  id: "@alfe.ai/openclaw-identity",
195
281
  name: "Alfe Identity",
@@ -330,12 +416,9 @@ const plugin = {
330
416
  }),
331
417
  defineTool({
332
418
  name: "request_identity_verification",
333
- description: "Send a verification phrase to verify the user controls an email or mobile endpoint. Use this when the user has just told you their email or mobile number and you want them to confirm it. Pass exactly one of `contactEmail` or `contactMobile`. The person must relay the phrase back via `confirm_identity_verification`.",
419
+ description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. PASS conversationId from the inbound message metadata the verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke). There is no way to verify a different identity via this tool — identity is server-authoritative, resolved at session-start by the chat service. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
334
420
  parameters: _sinclair_typebox.Type.Object({
335
- claimedIdentityId: _sinclair_typebox.Type.String({ description: "Identity being claimed" }),
336
- requestingIdentityId: _sinclair_typebox.Type.String({ description: "Identity of the person making the claim" }),
337
- requestingProvider: _sinclair_typebox.Type.String({ description: "Provider the requester is on (discord, slack, chat, etc.)" }),
338
- requestingPlatformId: _sinclair_typebox.Type.String({ description: "Requester's provider-specific user ID" }),
421
+ conversationId: _sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. REQUIRED — there is no manual override path." }),
339
422
  contactEmail: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
340
423
  contactMobile: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
341
424
  preferredChannel: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
@@ -351,11 +434,13 @@ const plugin = {
351
434
  channel: "mobile",
352
435
  value: contactMobile
353
436
  } : void 0;
437
+ const resolved = resolveRequesterContext(params);
438
+ if ("error" in resolved) return Promise.resolve({ error: resolved.error });
354
439
  return client.requestIdentityVerification({
355
- claimedIdentityId: params.claimedIdentityId,
356
- requestingIdentityId: params.requestingIdentityId,
357
- requestingProvider: params.requestingProvider,
358
- requestingPlatformId: params.requestingPlatformId,
440
+ claimedIdentityId: resolved.claimedIdentityId,
441
+ requestingIdentityId: resolved.requestingIdentityId,
442
+ requestingProvider: resolved.requestingProvider,
443
+ requestingPlatformId: resolved.requestingPlatformId,
359
444
  preferredChannel: params.preferredChannel,
360
445
  contact
361
446
  });
@@ -363,17 +448,26 @@ const plugin = {
363
448
  }),
364
449
  defineTool({
365
450
  name: "confirm_identity_verification",
366
- description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the claimed identity).",
451
+ description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). PASS the same conversationId you used on request_identity_verification — identity is auto-derived from it, no override.",
367
452
  parameters: _sinclair_typebox.Type.Object({
368
- claimedIdentityId: _sinclair_typebox.Type.String({ description: "Identity being claimed (matches request)" }),
453
+ conversationId: _sinclair_typebox.Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. REQUIRED." }),
369
454
  verificationId: _sinclair_typebox.Type.String({ description: "Verification ID returned from request_identity_verification" }),
370
- phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the person received via mobile or email" })
455
+ phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
371
456
  }),
372
- handler: (params) => client.confirmIdentityVerification({
373
- claimedIdentityId: params.claimedIdentityId,
374
- verificationId: params.verificationId,
375
- phrase: params.phrase
376
- })
457
+ handler: async (params) => {
458
+ const resolved = resolveClaimedIdentity(params);
459
+ if ("error" in resolved) return { error: resolved.error };
460
+ const result = await client.confirmIdentityVerification({
461
+ claimedIdentityId: resolved.claimedIdentityId,
462
+ verificationId: params.verificationId,
463
+ phrase: params.phrase
464
+ });
465
+ if (result.verified === true) {
466
+ const conversationId = params.conversationId;
467
+ invalidateCachedResolve(conversationId);
468
+ }
469
+ return result;
470
+ }
377
471
  })
378
472
  ];
379
473
  for (const tool of tools) api.registerTool(tool);
@@ -384,13 +478,17 @@ const plugin = {
384
478
  const provider = ctx.channelId ?? "unknown";
385
479
  const senderId = event.metadata?.UserId ?? event.from;
386
480
  if (!senderId) return;
387
- const agentId = ctx.agentId;
481
+ let agentId = ctx.agentId;
388
482
  if (!agentId) {
389
- log.warn(`message_received without ctx.agentId fail-closed`);
390
- return {
391
- block: true,
392
- blockReason: "Identity: agentId missing on message context"
393
- };
483
+ const fallback = await getAgentContext(client);
484
+ if (fallback) agentId = fallback.agentId;
485
+ else {
486
+ log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
487
+ return {
488
+ block: true,
489
+ blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
490
+ };
491
+ }
394
492
  }
395
493
  const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
396
494
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
@@ -414,7 +512,9 @@ const plugin = {
414
512
  setCachedResolve(cacheKey, {
415
513
  identityId: r.identityId,
416
514
  status: r.status,
417
- permissions: [...r.permissions, ...forwarded]
515
+ permissions: [...r.permissions, ...forwarded],
516
+ senderProvider: provider,
517
+ senderPlatformId: senderId
418
518
  });
419
519
  if (r.identityId == null) {
420
520
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -437,40 +537,68 @@ const plugin = {
437
537
  };
438
538
  }
439
539
  }, { priority: 100 });
440
- const beforeToolCallStub = (event, ctx) => {
441
- if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) {
442
- const blocked = applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
443
- agentId: ctx.agentId,
444
- toolName: event.toolName,
445
- toolArgs: event.toolArgs
446
- }), resolveToolGatingMode(), event.toolName);
447
- return Promise.resolve(blocked);
540
+ const beforeToolCallStub = async (event, ctx) => {
541
+ if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
542
+ if (event.toolName === "who_is_this") {
543
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
544
+ if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
448
545
  }
546
+ let agentId = ctx.agentId;
547
+ if (!agentId) {
548
+ const fallback = await getAgentContext(client);
549
+ if (fallback) agentId = fallback.agentId;
550
+ }
551
+ if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
552
+ agentId,
553
+ toolName: event.toolName,
554
+ toolArgs: event.toolArgs
555
+ }), resolveToolGatingMode(), event.toolName);
449
556
  const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
450
557
  if (cacheKey) {
451
558
  const cached = getCachedResolve(cacheKey);
452
- if (cached && cached.permissions.length > 0) {
453
- const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
454
- agentId: ctx.agentId,
455
- toolName: event.toolName,
456
- toolArgs: event.toolArgs
457
- }), resolveToolGatingMode(), event.toolName);
458
- return Promise.resolve(blocked);
459
- }
559
+ if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
560
+ agentId,
561
+ toolName: event.toolName,
562
+ toolArgs: event.toolArgs
563
+ }), resolveToolGatingMode(), event.toolName);
460
564
  }
461
- const fallback = evaluateAgentExec([], {
462
- agentId: ctx.agentId,
565
+ return applyGateMode(evaluateAgentExec([], {
566
+ agentId,
463
567
  toolName: event.toolName,
464
568
  toolArgs: event.toolArgs
465
- });
466
- const mode = resolveToolGatingMode();
467
- return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
569
+ }), resolveToolGatingMode(), event.toolName);
468
570
  };
469
571
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
572
+ api.on("before_agent_start", (...args) => {
573
+ const ctx = args[1];
574
+ const cached = findMostRelevantCacheEntry([
575
+ ctx.conversationId,
576
+ ctx.sessionKey,
577
+ ctx.sessionId,
578
+ ctx.channelId
579
+ ]);
580
+ if (!cached) return Promise.resolve(void 0);
581
+ if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
582
+ return Promise.resolve({ prependSystemContext: [
583
+ "Identity status: the user has not yet verified their identity.",
584
+ "Tools available now: request_identity_verification,",
585
+ "confirm_identity_verification, who_is_this.",
586
+ "All other tools are restricted until the user verifies.",
587
+ "If the user asks for a capability you cannot perform, briefly",
588
+ "explain that they need to verify first and offer to start the",
589
+ "verification flow."
590
+ ].join(" ") });
591
+ }, { priority: 100 });
592
+ if (!cachedAgentContext) getAgentContext(client).then((c) => {
593
+ if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
594
+ else log.warn("Plugin context warm failed; will retry on next hook fire");
595
+ });
470
596
  log.info("Alfe Identity plugin activated");
471
597
  },
472
598
  deactivate(api) {
473
599
  cachedClient = null;
600
+ cachedAgentContext = null;
601
+ inflightWhoami = null;
474
602
  api.logger.info("Alfe Identity plugin deactivated");
475
603
  }
476
604
  };
package/dist/plugin2.js CHANGED
@@ -147,6 +147,71 @@ function setCachedResolve(key, value) {
147
147
  expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
148
148
  });
149
149
  }
150
+ function invalidateCachedResolve(key) {
151
+ resolveCache.delete(key);
152
+ }
153
+ function findMostRelevantCacheEntry(probeKeys) {
154
+ for (const k of probeKeys) {
155
+ if (!k) continue;
156
+ const hit = getCachedResolve(k);
157
+ if (hit) return hit;
158
+ }
159
+ let mostRecent = null;
160
+ const now = Date.now();
161
+ for (const entry of resolveCache.values()) {
162
+ if (entry.expiresAt < now) continue;
163
+ if (!mostRecent || entry.expiresAt > mostRecent.expiresAt) mostRecent = entry;
164
+ }
165
+ return mostRecent;
166
+ }
167
+ const UNCONDITIONAL_SELF_SERVICE_TOOLS = new Set(["request_identity_verification", "confirm_identity_verification"]);
168
+ function isSelfScopedWhoIsThis(toolArgs, cacheKey) {
169
+ if (!cacheKey) return false;
170
+ const cached = getCachedResolve(cacheKey);
171
+ if (!cached) return false;
172
+ const argProvider = toolArgs?.provider;
173
+ const argPlatformId = toolArgs?.platformId;
174
+ if (typeof argProvider !== "string" || typeof argPlatformId !== "string") return false;
175
+ return argProvider === cached.senderProvider && argPlatformId === cached.senderPlatformId;
176
+ }
177
+ /**
178
+ * Resolve the inbound session identity from `conversationId`. Returns
179
+ * `{ error }` on cache miss / missing param — never falls back to LLM-
180
+ * supplied identity (that's the security property — see header).
181
+ */
182
+ function resolveSessionIdentity(params) {
183
+ const conversationId = params.conversationId;
184
+ if (!conversationId) return {
185
+ ok: false,
186
+ error: "missing_conversation_id: pass conversationId from the inbound message metadata. The verify tools auto-bind to the inbound sender's identity — there is no manual override."
187
+ };
188
+ const cached = getCachedResolve(conversationId);
189
+ if (cached?.identityId == null) return {
190
+ ok: false,
191
+ error: "session_identity_unavailable: inbound identity cache miss for this conversationId. The plugin populates the cache from the message_received hook on every inbound message; if this fires the inbound channel either hasn't sent a message recently (>60s) or the daemon failed to plumb the hook context."
192
+ };
193
+ return {
194
+ ok: true,
195
+ identityId: cached.identityId,
196
+ senderProvider: cached.senderProvider,
197
+ senderPlatformId: cached.senderPlatformId
198
+ };
199
+ }
200
+ function resolveRequesterContext(params) {
201
+ const session = resolveSessionIdentity(params);
202
+ if (!session.ok) return { error: session.error };
203
+ return {
204
+ claimedIdentityId: session.identityId,
205
+ requestingIdentityId: session.identityId,
206
+ requestingProvider: session.senderProvider,
207
+ requestingPlatformId: session.senderPlatformId
208
+ };
209
+ }
210
+ function resolveClaimedIdentity(params) {
211
+ const session = resolveSessionIdentity(params);
212
+ if (!session.ok) return { error: session.error };
213
+ return { claimedIdentityId: session.identityId };
214
+ }
150
215
  function ok(data) {
151
216
  return {
152
217
  content: [{
@@ -190,6 +255,27 @@ function getClient() {
190
255
  });
191
256
  return cachedClient;
192
257
  }
258
+ let cachedAgentContext = null;
259
+ let inflightWhoami = null;
260
+ async function getAgentContext(client) {
261
+ if (cachedAgentContext) return cachedAgentContext;
262
+ if (inflightWhoami) return inflightWhoami;
263
+ inflightWhoami = (async () => {
264
+ try {
265
+ const r = await client.whoami();
266
+ cachedAgentContext = {
267
+ agentId: r.agentId,
268
+ tenantId: r.tenantId
269
+ };
270
+ return cachedAgentContext;
271
+ } catch {
272
+ return null;
273
+ } finally {
274
+ inflightWhoami = null;
275
+ }
276
+ })();
277
+ return inflightWhoami;
278
+ }
193
279
  const plugin = {
194
280
  id: "@alfe.ai/openclaw-identity",
195
281
  name: "Alfe Identity",
@@ -330,12 +416,9 @@ const plugin = {
330
416
  }),
331
417
  defineTool({
332
418
  name: "request_identity_verification",
333
- description: "Send a verification phrase to verify the user controls an email or mobile endpoint. Use this when the user has just told you their email or mobile number and you want them to confirm it. Pass exactly one of `contactEmail` or `contactMobile`. The person must relay the phrase back via `confirm_identity_verification`.",
419
+ description: "Send a verification phrase to the inbound user's email or mobile to prove they own that contact endpoint. Use when the inbound user has just told you their email or mobile number and you want them to confirm it. PASS conversationId from the inbound message metadata the verification ALWAYS binds to the inbound sender's identity (the WhatsApp / SMS / Discord / Google Chat / chat-web user who just spoke). There is no way to verify a different identity via this tool — identity is server-authoritative, resolved at session-start by the chat service. If the contact you supply already lives on another existing identity (e.g. the user's Clerk web account), the merge happens automatically on confirm. To add a new contact to an already-verified canonical identity (e.g. add a new email to a Clerk account), the user must be talking from a session that already resolves to that canonical identity (post-merge, or chat-web logged in as Clerk).",
334
420
  parameters: Type.Object({
335
- claimedIdentityId: Type.String({ description: "Identity being claimed" }),
336
- requestingIdentityId: Type.String({ description: "Identity of the person making the claim" }),
337
- requestingProvider: Type.String({ description: "Provider the requester is on (discord, slack, chat, etc.)" }),
338
- requestingPlatformId: Type.String({ description: "Requester's provider-specific user ID" }),
421
+ conversationId: Type.String({ description: "Conversation ID from the inbound message — the daemon attaches this to every message. REQUIRED — there is no manual override path." }),
339
422
  contactEmail: Type.Optional(Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
340
423
  contactMobile: Type.Optional(Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
341
424
  preferredChannel: Type.Optional(Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
@@ -351,11 +434,13 @@ const plugin = {
351
434
  channel: "mobile",
352
435
  value: contactMobile
353
436
  } : void 0;
437
+ const resolved = resolveRequesterContext(params);
438
+ if ("error" in resolved) return Promise.resolve({ error: resolved.error });
354
439
  return client.requestIdentityVerification({
355
- claimedIdentityId: params.claimedIdentityId,
356
- requestingIdentityId: params.requestingIdentityId,
357
- requestingProvider: params.requestingProvider,
358
- requestingPlatformId: params.requestingPlatformId,
440
+ claimedIdentityId: resolved.claimedIdentityId,
441
+ requestingIdentityId: resolved.requestingIdentityId,
442
+ requestingProvider: resolved.requestingProvider,
443
+ requestingPlatformId: resolved.requestingPlatformId,
359
444
  preferredChannel: params.preferredChannel,
360
445
  contact
361
446
  });
@@ -363,17 +448,26 @@ const plugin = {
363
448
  }),
364
449
  defineTool({
365
450
  name: "confirm_identity_verification",
366
- description: "Confirm a verification by submitting the phrase the user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor) or 'contact_verified' (the contact was attached to the claimed identity).",
451
+ description: "Confirm a verification by submitting the phrase the inbound user received. Returns `{ verified, identityId, action }` where `action` is 'merged' (the verified contact already lived on another identity, which is now the survivor — the inbound identity merged into it) or 'contact_verified' (the contact was attached to the inbound identity). PASS the same conversationId you used on request_identity_verification — identity is auto-derived from it, no override.",
367
452
  parameters: Type.Object({
368
- claimedIdentityId: Type.String({ description: "Identity being claimed (matches request)" }),
453
+ conversationId: Type.String({ description: "Conversation ID from the inbound message — same one passed to request_identity_verification. REQUIRED." }),
369
454
  verificationId: Type.String({ description: "Verification ID returned from request_identity_verification" }),
370
- phrase: Type.String({ description: "Three-word phrase the person received via mobile or email" })
455
+ phrase: Type.String({ description: "Three-word phrase the inbound user received via mobile or email" })
371
456
  }),
372
- handler: (params) => client.confirmIdentityVerification({
373
- claimedIdentityId: params.claimedIdentityId,
374
- verificationId: params.verificationId,
375
- phrase: params.phrase
376
- })
457
+ handler: async (params) => {
458
+ const resolved = resolveClaimedIdentity(params);
459
+ if ("error" in resolved) return { error: resolved.error };
460
+ const result = await client.confirmIdentityVerification({
461
+ claimedIdentityId: resolved.claimedIdentityId,
462
+ verificationId: params.verificationId,
463
+ phrase: params.phrase
464
+ });
465
+ if (result.verified === true) {
466
+ const conversationId = params.conversationId;
467
+ invalidateCachedResolve(conversationId);
468
+ }
469
+ return result;
470
+ }
377
471
  })
378
472
  ];
379
473
  for (const tool of tools) api.registerTool(tool);
@@ -384,13 +478,17 @@ const plugin = {
384
478
  const provider = ctx.channelId ?? "unknown";
385
479
  const senderId = event.metadata?.UserId ?? event.from;
386
480
  if (!senderId) return;
387
- const agentId = ctx.agentId;
481
+ let agentId = ctx.agentId;
388
482
  if (!agentId) {
389
- log.warn(`message_received without ctx.agentId fail-closed`);
390
- return {
391
- block: true,
392
- blockReason: "Identity: agentId missing on message context"
393
- };
483
+ const fallback = await getAgentContext(client);
484
+ if (fallback) agentId = fallback.agentId;
485
+ else {
486
+ log.warn("message_received without ctx.agentId AND whoami unavailable — fail-closed");
487
+ return {
488
+ block: true,
489
+ blockReason: "Identity: agentId missing on message context and whoami fallback unavailable"
490
+ };
491
+ }
394
492
  }
395
493
  const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
396
494
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
@@ -414,7 +512,9 @@ const plugin = {
414
512
  setCachedResolve(cacheKey, {
415
513
  identityId: r.identityId,
416
514
  status: r.status,
417
- permissions: [...r.permissions, ...forwarded]
515
+ permissions: [...r.permissions, ...forwarded],
516
+ senderProvider: provider,
517
+ senderPlatformId: senderId
418
518
  });
419
519
  if (r.identityId == null) {
420
520
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -437,40 +537,68 @@ const plugin = {
437
537
  };
438
538
  }
439
539
  }, { priority: 100 });
440
- const beforeToolCallStub = (event, ctx) => {
441
- if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) {
442
- const blocked = applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
443
- agentId: ctx.agentId,
444
- toolName: event.toolName,
445
- toolArgs: event.toolArgs
446
- }), resolveToolGatingMode(), event.toolName);
447
- return Promise.resolve(blocked);
540
+ const beforeToolCallStub = async (event, ctx) => {
541
+ if (UNCONDITIONAL_SELF_SERVICE_TOOLS.has(event.toolName)) return;
542
+ if (event.toolName === "who_is_this") {
543
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
544
+ if (isSelfScopedWhoIsThis(event.toolArgs, cacheKey)) return;
448
545
  }
546
+ let agentId = ctx.agentId;
547
+ if (!agentId) {
548
+ const fallback = await getAgentContext(client);
549
+ if (fallback) agentId = fallback.agentId;
550
+ }
551
+ if (ctx.authMethod === "token" && ctx.tokenPermissions !== void 0) return applyGateMode(evaluateAgentExec(ctx.tokenPermissions, {
552
+ agentId,
553
+ toolName: event.toolName,
554
+ toolArgs: event.toolArgs
555
+ }), resolveToolGatingMode(), event.toolName);
449
556
  const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
450
557
  if (cacheKey) {
451
558
  const cached = getCachedResolve(cacheKey);
452
- if (cached && cached.permissions.length > 0) {
453
- const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
454
- agentId: ctx.agentId,
455
- toolName: event.toolName,
456
- toolArgs: event.toolArgs
457
- }), resolveToolGatingMode(), event.toolName);
458
- return Promise.resolve(blocked);
459
- }
559
+ if (cached && cached.permissions.length > 0) return applyGateMode(evaluateAgentExec([...cached.permissions], {
560
+ agentId,
561
+ toolName: event.toolName,
562
+ toolArgs: event.toolArgs
563
+ }), resolveToolGatingMode(), event.toolName);
460
564
  }
461
- const fallback = evaluateAgentExec([], {
462
- agentId: ctx.agentId,
565
+ return applyGateMode(evaluateAgentExec([], {
566
+ agentId,
463
567
  toolName: event.toolName,
464
568
  toolArgs: event.toolArgs
465
- });
466
- const mode = resolveToolGatingMode();
467
- return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
569
+ }), resolveToolGatingMode(), event.toolName);
468
570
  };
469
571
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
572
+ api.on("before_agent_start", (...args) => {
573
+ const ctx = args[1];
574
+ const cached = findMostRelevantCacheEntry([
575
+ ctx.conversationId,
576
+ ctx.sessionKey,
577
+ ctx.sessionId,
578
+ ctx.channelId
579
+ ]);
580
+ if (!cached) return Promise.resolve(void 0);
581
+ if (cached.status !== "anonymous" && cached.status !== "partial") return Promise.resolve(void 0);
582
+ return Promise.resolve({ prependSystemContext: [
583
+ "Identity status: the user has not yet verified their identity.",
584
+ "Tools available now: request_identity_verification,",
585
+ "confirm_identity_verification, who_is_this.",
586
+ "All other tools are restricted until the user verifies.",
587
+ "If the user asks for a capability you cannot perform, briefly",
588
+ "explain that they need to verify first and offer to start the",
589
+ "verification flow."
590
+ ].join(" ") });
591
+ }, { priority: 100 });
592
+ if (!cachedAgentContext) getAgentContext(client).then((c) => {
593
+ if (c) log.info(`Plugin context warmed: agent=${c.agentId} tenant=${c.tenantId}`);
594
+ else log.warn("Plugin context warm failed; will retry on next hook fire");
595
+ });
470
596
  log.info("Alfe Identity plugin activated");
471
597
  },
472
598
  deactivate(api) {
473
599
  cachedClient = null;
600
+ cachedAgentContext = null;
601
+ inflightWhoami = null;
474
602
  api.logger.info("Alfe Identity plugin deactivated");
475
603
  }
476
604
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-identity",
3
- "version": "0.0.19",
3
+ "version": "0.1.1",
4
4
  "description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@auriclabs/roles": "0.1.1",
31
31
  "@sinclair/typebox": "^0.34.48",
32
- "@alfe.ai/agent-api-client": "0.1.2",
32
+ "@alfe.ai/agent-api-client": "0.1.3",
33
33
  "@alfe.ai/config": "0.0.8"
34
34
  },
35
35
  "license": "UNLICENSED",