@alfe.ai/openclaw-identity 0.0.9 → 0.0.11

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/index.cjs CHANGED
@@ -1,2 +1,27 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
1
5
  const require_plugin = require("./plugin.cjs");
2
- module.exports = require_plugin;
6
+ //#region src/runtime-contract.ts
7
+ /**
8
+ * Build the gate context object used by sift evaluation. This is the
9
+ * round-6 "strict namespacing" shape — `args` is a top-level field, not
10
+ * spread, so an attacker-controlled `tool` field inside `toolArgs` cannot
11
+ * override the gate's `tool` value.
12
+ *
13
+ * Stage E's gate calls:
14
+ * ability.has(
15
+ * { subject: "agent", action: "exec", scope: `agent:${ctx.agentId}` },
16
+ * buildGateContext(event),
17
+ * );
18
+ */
19
+ function buildGateContext(event) {
20
+ return {
21
+ tool: event.toolName,
22
+ args: event.toolArgs
23
+ };
24
+ }
25
+ //#endregion
26
+ exports.buildGateContext = buildGateContext;
27
+ exports.default = require_plugin;
package/dist/index.d.cts CHANGED
@@ -1,2 +1,147 @@
1
1
  import plugin from "./plugin.cjs";
2
- export { plugin as default };
2
+
3
+ //#region src/policy-cache.d.ts
4
+
5
+ type IdentityFailureMode = "open" | "closed" | "permissive";
6
+ /**
7
+ * Evaluate whether a tool call should be blocked based on the cached policy
8
+ * and failure mode.
9
+ */
10
+
11
+ //#endregion
12
+ //#region src/runtime-contract.d.ts
13
+
14
+ /**
15
+ * The tool the agent is about to call.
16
+ *
17
+ * `toolName` SHOULD be namespaced (e.g., `gmail:send_email`, `calendar:create_event`)
18
+ * so sift `$glob: "gmail:*"` conditions work and namespace collisions are
19
+ * impossible. Plugins that don't yet namespace their tools will be migrated
20
+ * during Stage I rollout.
21
+ */
22
+ interface ToolCallEvent {
23
+ /** Namespaced tool name, e.g. "gmail:send_email". */
24
+ readonly toolName: string;
25
+ /** Arbitrary argument bag passed to the tool's `execute` function. */
26
+ readonly toolArgs: Record<string, unknown>;
27
+ }
28
+ /**
29
+ * What initiated the tool call. Three buckets:
30
+ * - "user_message" — a human (or another agent) sent a message that the
31
+ * agent is now responding to. `actingIdentityId` is the
32
+ * identity of the message sender.
33
+ * - "scheduled" — autonomous run (cron/trigger). `actingIdentityId` is
34
+ * the agent's own identity (`idn_agt_*`).
35
+ * - "tool_chain" — a follow-up tool call from a previous tool's result
36
+ * within the same agent invocation. `actingIdentityId`
37
+ * is whatever resolved at the start of the chain.
38
+ * - "api" — a tool call invoked via the agent's REST API by an
39
+ * external caller authenticated with an `alfe_*` token.
40
+ * `actingIdentityId` may be undefined; `tokenPermissions`
41
+ * carries the gate-relevant `Permission[]`.
42
+ */
43
+ type ToolCallTrigger = "user_message" | "scheduled" | "tool_chain" | "api";
44
+ /**
45
+ * How the caller authenticated.
46
+ * - "jwt" — Clerk JWT path (human user). `actingIdentityId` resolved from
47
+ * the JWT's `sub` via the identity service.
48
+ * - "agent" — autonomous agent (no external caller). `actingIdentityId` is
49
+ * the agent's own `idn_agt_*`.
50
+ * - "token" — `alfe_*` API token. `tokenPermissions` is set; the gate uses
51
+ * the token's permissions directly without an identity lookup.
52
+ * - "none" — no auth context (fail-closed in prod via IdentityFailureMode).
53
+ */
54
+ type ToolCallAuthMethod = "jwt" | "agent" | "token" | "none";
55
+ /**
56
+ * Permission shape the gate consumes. This is a structural duplicate of
57
+ * `Permission` from `@auriclabs/roles@0.1.1` so the openclaw plugin doesn't
58
+ * have to depend on the library directly (the library is consumed inside the
59
+ * gate itself in Stage E). Keep the two shapes in lockstep.
60
+ */
61
+ interface RuntimePermission {
62
+ readonly subject: string;
63
+ readonly action: string;
64
+ readonly scope?: string;
65
+ /**
66
+ * Round 7 storage shape: sift conditions stored per-permission. The runtime
67
+ * gate evaluates the condition against `{ tool, args }` (round 6 strict
68
+ * namespacing). Glob source travels under `$glob`; the compiled regex
69
+ * travels under `_glob_re` with a `_glob_v` version marker (round 7 audit
70
+ * 3.6 storage shape).
71
+ */
72
+ readonly conditions?: Record<string, unknown>;
73
+ readonly type?: "can" | "cannot";
74
+ }
75
+ /**
76
+ * Calling context for the tool call. Populated by the OpenClaw daemon at
77
+ * dispatch time.
78
+ */
79
+ interface ToolCallContext {
80
+ /** Required: the tenant (`org_*`) that owns the agent. */
81
+ readonly tenantId: string;
82
+ /** Required: the agent (`agt_*`) that is about to call the tool. */
83
+ readonly agentId: string;
84
+ /** Required: how the caller authenticated. */
85
+ readonly authMethod: ToolCallAuthMethod;
86
+ /** Required: what initiated the call. */
87
+ readonly trigger: ToolCallTrigger;
88
+ /**
89
+ * The identity (`idn_*`) the agent is acting on behalf of. Resolution
90
+ * order:
91
+ * 1. If `trigger === "user_message"`: the chat-context identity (sender).
92
+ * 2. If `trigger === "scheduled"` or `trigger === "tool_chain"`: the
93
+ * agent's own `idn_agt_*` (autonomous default).
94
+ * 3. If `authMethod === "token"`: optional; the token-path gate uses
95
+ * `tokenPermissions` and may skip identity resolution entirely.
96
+ * 4. Otherwise: undefined; the per-agent `IdentityFailureMode` decides
97
+ * whether to allow / deny / log-and-continue.
98
+ */
99
+ readonly actingIdentityId?: string;
100
+ /**
101
+ * Round 5 / Round 7: `alfe_*` tokens carry their own flat `Permission[]`
102
+ * directly. When `authMethod === "token"`, the daemon populates this with
103
+ * `TokenEntity.permissions` (post Stage B.0.7 atomic flip — pre-flip the
104
+ * authorizer derives this from `record.scopes` via `scopesToPermissions`).
105
+ * The Stage E gate evaluates these permissions directly at scope
106
+ * `agent:<agentId>` without an identity lookup.
107
+ */
108
+ readonly tokenPermissions?: readonly RuntimePermission[];
109
+ /** Optional: chat conversation id (when trigger is `user_message`). */
110
+ readonly conversationId?: string;
111
+ /** Optional: channel id (when trigger is `user_message`). */
112
+ readonly channelId?: string;
113
+ }
114
+ /**
115
+ * Return value of the `before_tool_call` hook.
116
+ * - `undefined` — allow.
117
+ * - `{ block: true, blockReason: string }` — deny with reason surfaced to
118
+ * the agent's response (Stage E behaviour).
119
+ */
120
+ type ToolCallHookResult = undefined | {
121
+ block: true;
122
+ blockReason: string;
123
+ };
124
+ /**
125
+ * Strongly-typed `before_tool_call` hook signature. The plugin's stub uses
126
+ * this signature today; Stage E swaps the stub for the real `agent:exec` +
127
+ * sift gate against `@auriclabs/roles`.
128
+ */
129
+ type BeforeToolCallHook = (event: ToolCallEvent, ctx: ToolCallContext) => Promise<ToolCallHookResult>;
130
+ /**
131
+ * Build the gate context object used by sift evaluation. This is the
132
+ * round-6 "strict namespacing" shape — `args` is a top-level field, not
133
+ * spread, so an attacker-controlled `tool` field inside `toolArgs` cannot
134
+ * override the gate's `tool` value.
135
+ *
136
+ * Stage E's gate calls:
137
+ * ability.has(
138
+ * { subject: "agent", action: "exec", scope: `agent:${ctx.agentId}` },
139
+ * buildGateContext(event),
140
+ * );
141
+ */
142
+ declare function buildGateContext(event: ToolCallEvent): {
143
+ tool: string;
144
+ args: Record<string, unknown>;
145
+ };
146
+ //#endregion
147
+ export { type BeforeToolCallHook, type IdentityFailureMode, type RuntimePermission, type ToolCallAuthMethod, type ToolCallContext, type ToolCallEvent, type ToolCallHookResult, type ToolCallTrigger, buildGateContext, plugin as default };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,147 @@
1
1
  import plugin from "./plugin.js";
2
- export { plugin as default };
2
+
3
+ //#region src/policy-cache.d.ts
4
+
5
+ type IdentityFailureMode = "open" | "closed" | "permissive";
6
+ /**
7
+ * Evaluate whether a tool call should be blocked based on the cached policy
8
+ * and failure mode.
9
+ */
10
+
11
+ //#endregion
12
+ //#region src/runtime-contract.d.ts
13
+
14
+ /**
15
+ * The tool the agent is about to call.
16
+ *
17
+ * `toolName` SHOULD be namespaced (e.g., `gmail:send_email`, `calendar:create_event`)
18
+ * so sift `$glob: "gmail:*"` conditions work and namespace collisions are
19
+ * impossible. Plugins that don't yet namespace their tools will be migrated
20
+ * during Stage I rollout.
21
+ */
22
+ interface ToolCallEvent {
23
+ /** Namespaced tool name, e.g. "gmail:send_email". */
24
+ readonly toolName: string;
25
+ /** Arbitrary argument bag passed to the tool's `execute` function. */
26
+ readonly toolArgs: Record<string, unknown>;
27
+ }
28
+ /**
29
+ * What initiated the tool call. Three buckets:
30
+ * - "user_message" — a human (or another agent) sent a message that the
31
+ * agent is now responding to. `actingIdentityId` is the
32
+ * identity of the message sender.
33
+ * - "scheduled" — autonomous run (cron/trigger). `actingIdentityId` is
34
+ * the agent's own identity (`idn_agt_*`).
35
+ * - "tool_chain" — a follow-up tool call from a previous tool's result
36
+ * within the same agent invocation. `actingIdentityId`
37
+ * is whatever resolved at the start of the chain.
38
+ * - "api" — a tool call invoked via the agent's REST API by an
39
+ * external caller authenticated with an `alfe_*` token.
40
+ * `actingIdentityId` may be undefined; `tokenPermissions`
41
+ * carries the gate-relevant `Permission[]`.
42
+ */
43
+ type ToolCallTrigger = "user_message" | "scheduled" | "tool_chain" | "api";
44
+ /**
45
+ * How the caller authenticated.
46
+ * - "jwt" — Clerk JWT path (human user). `actingIdentityId` resolved from
47
+ * the JWT's `sub` via the identity service.
48
+ * - "agent" — autonomous agent (no external caller). `actingIdentityId` is
49
+ * the agent's own `idn_agt_*`.
50
+ * - "token" — `alfe_*` API token. `tokenPermissions` is set; the gate uses
51
+ * the token's permissions directly without an identity lookup.
52
+ * - "none" — no auth context (fail-closed in prod via IdentityFailureMode).
53
+ */
54
+ type ToolCallAuthMethod = "jwt" | "agent" | "token" | "none";
55
+ /**
56
+ * Permission shape the gate consumes. This is a structural duplicate of
57
+ * `Permission` from `@auriclabs/roles@0.1.1` so the openclaw plugin doesn't
58
+ * have to depend on the library directly (the library is consumed inside the
59
+ * gate itself in Stage E). Keep the two shapes in lockstep.
60
+ */
61
+ interface RuntimePermission {
62
+ readonly subject: string;
63
+ readonly action: string;
64
+ readonly scope?: string;
65
+ /**
66
+ * Round 7 storage shape: sift conditions stored per-permission. The runtime
67
+ * gate evaluates the condition against `{ tool, args }` (round 6 strict
68
+ * namespacing). Glob source travels under `$glob`; the compiled regex
69
+ * travels under `_glob_re` with a `_glob_v` version marker (round 7 audit
70
+ * 3.6 storage shape).
71
+ */
72
+ readonly conditions?: Record<string, unknown>;
73
+ readonly type?: "can" | "cannot";
74
+ }
75
+ /**
76
+ * Calling context for the tool call. Populated by the OpenClaw daemon at
77
+ * dispatch time.
78
+ */
79
+ interface ToolCallContext {
80
+ /** Required: the tenant (`org_*`) that owns the agent. */
81
+ readonly tenantId: string;
82
+ /** Required: the agent (`agt_*`) that is about to call the tool. */
83
+ readonly agentId: string;
84
+ /** Required: how the caller authenticated. */
85
+ readonly authMethod: ToolCallAuthMethod;
86
+ /** Required: what initiated the call. */
87
+ readonly trigger: ToolCallTrigger;
88
+ /**
89
+ * The identity (`idn_*`) the agent is acting on behalf of. Resolution
90
+ * order:
91
+ * 1. If `trigger === "user_message"`: the chat-context identity (sender).
92
+ * 2. If `trigger === "scheduled"` or `trigger === "tool_chain"`: the
93
+ * agent's own `idn_agt_*` (autonomous default).
94
+ * 3. If `authMethod === "token"`: optional; the token-path gate uses
95
+ * `tokenPermissions` and may skip identity resolution entirely.
96
+ * 4. Otherwise: undefined; the per-agent `IdentityFailureMode` decides
97
+ * whether to allow / deny / log-and-continue.
98
+ */
99
+ readonly actingIdentityId?: string;
100
+ /**
101
+ * Round 5 / Round 7: `alfe_*` tokens carry their own flat `Permission[]`
102
+ * directly. When `authMethod === "token"`, the daemon populates this with
103
+ * `TokenEntity.permissions` (post Stage B.0.7 atomic flip — pre-flip the
104
+ * authorizer derives this from `record.scopes` via `scopesToPermissions`).
105
+ * The Stage E gate evaluates these permissions directly at scope
106
+ * `agent:<agentId>` without an identity lookup.
107
+ */
108
+ readonly tokenPermissions?: readonly RuntimePermission[];
109
+ /** Optional: chat conversation id (when trigger is `user_message`). */
110
+ readonly conversationId?: string;
111
+ /** Optional: channel id (when trigger is `user_message`). */
112
+ readonly channelId?: string;
113
+ }
114
+ /**
115
+ * Return value of the `before_tool_call` hook.
116
+ * - `undefined` — allow.
117
+ * - `{ block: true, blockReason: string }` — deny with reason surfaced to
118
+ * the agent's response (Stage E behaviour).
119
+ */
120
+ type ToolCallHookResult = undefined | {
121
+ block: true;
122
+ blockReason: string;
123
+ };
124
+ /**
125
+ * Strongly-typed `before_tool_call` hook signature. The plugin's stub uses
126
+ * this signature today; Stage E swaps the stub for the real `agent:exec` +
127
+ * sift gate against `@auriclabs/roles`.
128
+ */
129
+ type BeforeToolCallHook = (event: ToolCallEvent, ctx: ToolCallContext) => Promise<ToolCallHookResult>;
130
+ /**
131
+ * Build the gate context object used by sift evaluation. This is the
132
+ * round-6 "strict namespacing" shape — `args` is a top-level field, not
133
+ * spread, so an attacker-controlled `tool` field inside `toolArgs` cannot
134
+ * override the gate's `tool` value.
135
+ *
136
+ * Stage E's gate calls:
137
+ * ability.has(
138
+ * { subject: "agent", action: "exec", scope: `agent:${ctx.agentId}` },
139
+ * buildGateContext(event),
140
+ * );
141
+ */
142
+ declare function buildGateContext(event: ToolCallEvent): {
143
+ tool: string;
144
+ args: Record<string, unknown>;
145
+ };
146
+ //#endregion
147
+ export { type BeforeToolCallHook, type IdentityFailureMode, type RuntimePermission, type ToolCallAuthMethod, type ToolCallContext, type ToolCallEvent, type ToolCallHookResult, type ToolCallTrigger, buildGateContext, plugin as default };
package/dist/index.js CHANGED
@@ -1,2 +1,22 @@
1
1
  import plugin from "./plugin.js";
2
- export { plugin as default };
2
+ //#region src/runtime-contract.ts
3
+ /**
4
+ * Build the gate context object used by sift evaluation. This is the
5
+ * round-6 "strict namespacing" shape — `args` is a top-level field, not
6
+ * spread, so an attacker-controlled `tool` field inside `toolArgs` cannot
7
+ * override the gate's `tool` value.
8
+ *
9
+ * Stage E's gate calls:
10
+ * ability.has(
11
+ * { subject: "agent", action: "exec", scope: `agent:${ctx.agentId}` },
12
+ * buildGateContext(event),
13
+ * );
14
+ */
15
+ function buildGateContext(event) {
16
+ return {
17
+ tool: event.toolName,
18
+ args: event.toolArgs
19
+ };
20
+ }
21
+ //#endregion
22
+ export { buildGateContext, plugin as default };
package/dist/plugin.cjs CHANGED
@@ -5,33 +5,36 @@ let _sinclair_typebox = require("@sinclair/typebox");
5
5
  /**
6
6
  * @alfe.ai/openclaw-identity — OpenClaw native plugin
7
7
  *
8
- * HTTP-based identity resolution, permission enforcement, and CRM tools.
9
- * Installed as part of the core alfe integration on every agent.
8
+ * HTTP-based identity resolution + AccessConfig admission gate. Installed as
9
+ * part of the core alfe integration on every agent.
10
10
  *
11
11
  * Hooks:
12
- * - message_received → resolve sender identity via HTTP, cache, gate access
13
- * - before_tool_call → enforce permissions from cached policy
14
- * - after_tool_call log tool execution audit
12
+ * - message_received → always resolves the sender, gates on accessAllowed,
13
+ * blocks unknown senders (read-only resolve).
14
+ * - before_tool_call typed `(event: ToolCallEvent, ctx: ToolCallContext)`
15
+ * hook (Phase 1 Stage A.0). Today: permissive no-op
16
+ * stub. Phase 1 Stage E swaps the stub for the
17
+ * `agent:exec` + sift gate (Decision 17).
15
18
  *
16
- * All data access via AgentApiClient (/agent/identity/* routes).
17
- * Uses the agent's own API key (from ~/.alfe/config.toml) for authentication.
19
+ * `after_tool_call` is intentionally absent tool-call audit lives in a
20
+ * future dedicated audit service, not in identity.
18
21
  */
19
22
  const pkg = (0, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
20
- const CACHE_TTL_MS = 6e4;
21
- const sessionCache = /* @__PURE__ */ new Map();
22
- function getCached(key) {
23
- const entry = sessionCache.get(key);
23
+ const RESOLVE_CACHE_TTL_MS = 6e4;
24
+ const resolveCache = /* @__PURE__ */ new Map();
25
+ function getCachedResolve(key) {
26
+ const entry = resolveCache.get(key);
24
27
  if (!entry) return null;
25
28
  if (Date.now() > entry.expiresAt) {
26
- sessionCache.delete(key);
29
+ resolveCache.delete(key);
27
30
  return null;
28
31
  }
29
- return entry.value;
32
+ return entry;
30
33
  }
31
- function setCached(key, value) {
32
- sessionCache.set(key, {
33
- value,
34
- expiresAt: Date.now() + CACHE_TTL_MS
34
+ function setCachedResolve(key, value) {
35
+ resolveCache.set(key, {
36
+ ...value,
37
+ expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
35
38
  });
36
39
  }
37
40
  function ok(data) {
@@ -70,7 +73,7 @@ function defineTool(def) {
70
73
  const plugin = {
71
74
  id: "@alfe.ai/openclaw-identity",
72
75
  name: "Alfe Identity",
73
- description: "Identity resolution, access gating, and permission enforcement",
76
+ description: "Identity resolution and access gating for inbound messages",
74
77
  version: pkg.version,
75
78
  activate(api) {
76
79
  const log = api.logger;
@@ -86,26 +89,17 @@ const plugin = {
86
89
  log.error(`Identity plugin: failed to resolve config — ${err instanceof Error ? err.message : String(err)}`);
87
90
  return;
88
91
  }
89
- let failureMode = "open";
90
- client.getIntegrationConfig("alfe").then((alfeConfig) => {
91
- const mode = alfeConfig.config.identity_failure_mode;
92
- if (mode === "open" || mode === "closed" || mode === "permissive") failureMode = mode;
93
- log.info(`Identity failure mode: ${failureMode}`);
94
- }).catch(() => {
95
- log.info(`Identity failure mode: ${failureMode} (default — config fetch failed)`);
96
- });
97
- const identityToolNames = /* @__PURE__ */ new Set();
98
92
  const tools = [
99
93
  defineTool({
100
94
  name: "who_is_this",
101
- description: "Look up full identity context by platform and ID — returns profile, notes, tags, platforms, and recent changelog",
95
+ description: "Look up an identity by provider + platformId — returns full identity context (profile, contacts, platforms, notes, tags, recent changelog). Returns `{found:false}` for unknown senders. This tool DOES NOT create new identities.",
102
96
  parameters: _sinclair_typebox.Type.Object({
103
- platform: _sinclair_typebox.Type.String({ description: "Platform name (discord, slack, chat, sms, whatsapp, etc.)" }),
104
- platformId: _sinclair_typebox.Type.String({ description: "Platform-specific user identifier" })
97
+ provider: _sinclair_typebox.Type.String({ description: "Provider name (discord, slack, chat, google-chat, clerk, etc.)" }),
98
+ platformId: _sinclair_typebox.Type.String({ description: "Provider-specific user identifier" })
105
99
  }),
106
100
  handler: async (params) => {
107
101
  const result = await client.resolveIdentity({
108
- platform: params.platform,
102
+ provider: params.provider,
109
103
  platformId: params.platformId
110
104
  });
111
105
  if (!result.identityId) return { found: false };
@@ -114,37 +108,19 @@ const plugin = {
114
108
  }),
115
109
  defineTool({
116
110
  name: "lookup_identity",
117
- description: "Search identities by name, email, phone, tag, or platform. Returns multiple matches.",
111
+ description: "Search identities by name. Returns multiple matches.",
118
112
  parameters: _sinclair_typebox.Type.Object({
119
113
  query: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Text search query" })),
120
- status: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String()),
121
- tag: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String()),
122
- platform: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
114
+ status: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
123
115
  }),
124
116
  handler: (params) => client.searchIdentities({
125
117
  q: params.query,
126
- status: params.status,
127
- tag: params.tag,
128
- platform: params.platform
129
- })
130
- }),
131
- defineTool({
132
- name: "create_identity",
133
- description: "Create a new identity record with profile fields",
134
- parameters: _sinclair_typebox.Type.Object({
135
- platform: _sinclair_typebox.Type.String(),
136
- platformId: _sinclair_typebox.Type.String(),
137
- displayName: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
138
- }),
139
- handler: (params) => client.resolveIdentity({
140
- platform: params.platform,
141
- platformId: params.platformId,
142
- displayName: params.displayName
118
+ status: params.status
143
119
  })
144
120
  }),
145
121
  defineTool({
146
122
  name: "merge_identities",
147
- description: "Merge two identity records — transfers notes, tags, aliases, platforms to survivor",
123
+ description: "Merge two identity records — transfers contacts, platforms, notes, tags, aliases to the survivor. Hard-move, transactional.",
148
124
  parameters: _sinclair_typebox.Type.Object({
149
125
  survivorId: _sinclair_typebox.Type.String({ description: "Identity to keep" }),
150
126
  mergedId: _sinclair_typebox.Type.String({ description: "Identity to merge into survivor" })
@@ -159,29 +135,16 @@ const plugin = {
159
135
  }),
160
136
  defineTool({
161
137
  name: "unmerge_identities",
162
- description: "Reverse a merge — restore previously merged identity",
138
+ description: "Reverse a merge — restore previously merged identity.",
163
139
  parameters: _sinclair_typebox.Type.Object({ mergedId: _sinclair_typebox.Type.String({ description: "Identity that was merged (has mergedInto pointer)" }) }),
164
140
  handler: (params) => client.unmergeIdentity(params.mergedId, { changedBy: {
165
141
  type: "agent",
166
142
  id: "plugin"
167
143
  } })
168
144
  }),
169
- defineTool({
170
- name: "link_platform",
171
- description: "Link a platform identity to an existing identity record",
172
- parameters: _sinclair_typebox.Type.Object({
173
- identityId: _sinclair_typebox.Type.String(),
174
- platform: _sinclair_typebox.Type.String(),
175
- platformId: _sinclair_typebox.Type.String()
176
- }),
177
- handler: (params) => client.resolveIdentity({
178
- platform: params.platform,
179
- platformId: params.platformId
180
- })
181
- }),
182
145
  defineTool({
183
146
  name: "add_identity_note",
184
- description: "Add an observation or note about a contact",
147
+ description: "Add an observation or note about an identity. Use category 'context' for unverified affiliation claims (self-reported title/company) — those do NOT belong on the identity row directly.",
185
148
  parameters: _sinclair_typebox.Type.Object({
186
149
  identityId: _sinclair_typebox.Type.String(),
187
150
  content: _sinclair_typebox.Type.String(),
@@ -198,7 +161,7 @@ const plugin = {
198
161
  }),
199
162
  defineTool({
200
163
  name: "tag_identity",
201
- description: "Add or remove a tag on an identity",
164
+ description: "Add or remove a tag on an identity.",
202
165
  parameters: _sinclair_typebox.Type.Object({
203
166
  identityId: _sinclair_typebox.Type.String(),
204
167
  tag: _sinclair_typebox.Type.String(),
@@ -215,7 +178,7 @@ const plugin = {
215
178
  }),
216
179
  defineTool({
217
180
  name: "get_identity_changelog",
218
- description: "Get full changelog for an identity — all versions, diffs, who changed what",
181
+ description: "Get the changelog for an identity — versions, diffs, who changed what.",
219
182
  parameters: _sinclair_typebox.Type.Object({
220
183
  identityId: _sinclair_typebox.Type.String(),
221
184
  limit: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number())
@@ -224,7 +187,7 @@ const plugin = {
224
187
  }),
225
188
  defineTool({
226
189
  name: "rollback_identity",
227
- description: "Revert an identity to a previous version",
190
+ description: "Revert an identity to a previous version.",
228
191
  parameters: _sinclair_typebox.Type.Object({
229
192
  identityId: _sinclair_typebox.Type.String(),
230
193
  targetVersion: _sinclair_typebox.Type.Number()
@@ -238,78 +201,83 @@ const plugin = {
238
201
  })
239
202
  }),
240
203
  defineTool({
241
- name: "enforce_policy",
242
- description: "Resolve sender identity and return their tool policy",
243
- parameters: _sinclair_typebox.Type.Object({
244
- platform: _sinclair_typebox.Type.String(),
245
- senderId: _sinclair_typebox.Type.String(),
246
- channelId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
247
- }),
248
- handler: (params) => client.enforcePolicy({
249
- platform: params.platform,
250
- senderId: params.senderId,
251
- channelId: params.channelId
252
- })
253
- }),
254
- defineTool({
255
- name: "check_permission",
256
- description: "Check if a specific tool call is allowed for a sender",
204
+ name: "update_identity",
205
+ description: "Update display-shape fields on an identity: name / avatarUrl / timezone / locale. Contact mutations go through the verify flow — this tool will NOT accept email or mobile. Title and company live on org membership rows, not here.",
257
206
  parameters: _sinclair_typebox.Type.Object({
258
- platform: _sinclair_typebox.Type.String(),
259
- senderId: _sinclair_typebox.Type.String(),
260
- toolName: _sinclair_typebox.Type.String()
207
+ identityId: _sinclair_typebox.Type.String(),
208
+ name: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String()),
209
+ avatarUrl: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String()),
210
+ timezone: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String()),
211
+ locale: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String())
261
212
  }),
262
- handler: (params) => client.checkToolPermission({
263
- platform: params.platform,
264
- senderId: params.senderId,
265
- toolName: params.toolName
266
- })
213
+ handler: (params) => {
214
+ const { identityId, ...rest } = params;
215
+ return client.updateIdentity(identityId, rest);
216
+ }
267
217
  }),
268
218
  defineTool({
269
219
  name: "request_identity_verification",
270
- description: "Send a verification phrase to the claimed identity's phone (SMS) or email. The person must relay the phrase back to confirm they own that identity.",
220
+ 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`.",
271
221
  parameters: _sinclair_typebox.Type.Object({
272
222
  claimedIdentityId: _sinclair_typebox.Type.String({ description: "Identity being claimed" }),
273
223
  requestingIdentityId: _sinclair_typebox.Type.String({ description: "Identity of the person making the claim" }),
274
- requestingPlatform: _sinclair_typebox.Type.String({ description: "Platform the requester is on (discord, slack, chat, etc.)" }),
275
- requestingPlatformId: _sinclair_typebox.Type.String({ description: "Requester's platform-specific user ID" }),
276
- preferredChannel: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "'sms' or 'email'" }))
224
+ requestingProvider: _sinclair_typebox.Type.String({ description: "Provider the requester is on (discord, slack, chat, etc.)" }),
225
+ requestingPlatformId: _sinclair_typebox.Type.String({ description: "Requester's provider-specific user ID" }),
226
+ contactEmail: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
227
+ contactMobile: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
228
+ 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'" }))
277
229
  }),
278
- handler: (params) => client.requestIdentityVerification({
279
- claimedIdentityId: params.claimedIdentityId,
280
- requestingIdentityId: params.requestingIdentityId,
281
- requestingPlatform: params.requestingPlatform,
282
- requestingPlatformId: params.requestingPlatformId,
283
- preferredChannel: params.preferredChannel
284
- })
230
+ handler: (params) => {
231
+ const contactEmail = params.contactEmail;
232
+ const contactMobile = params.contactMobile;
233
+ if (contactEmail && contactMobile) return Promise.resolve({ error: "Specify exactly one of contactEmail or contactMobile, not both" });
234
+ const contact = contactEmail ? {
235
+ channel: "email",
236
+ value: contactEmail
237
+ } : contactMobile ? {
238
+ channel: "mobile",
239
+ value: contactMobile
240
+ } : void 0;
241
+ return client.requestIdentityVerification({
242
+ claimedIdentityId: params.claimedIdentityId,
243
+ requestingIdentityId: params.requestingIdentityId,
244
+ requestingProvider: params.requestingProvider,
245
+ requestingPlatformId: params.requestingPlatformId,
246
+ preferredChannel: params.preferredChannel,
247
+ contact
248
+ });
249
+ }
285
250
  }),
286
251
  defineTool({
287
252
  name: "confirm_identity_verification",
288
- description: "Confirm a verification by providing the three-word phrase. On success, the requesting identity is merged into the claimed identity.",
253
+ 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).",
289
254
  parameters: _sinclair_typebox.Type.Object({
255
+ claimedIdentityId: _sinclair_typebox.Type.String({ description: "Identity being claimed (matches request)" }),
290
256
  verificationId: _sinclair_typebox.Type.String({ description: "Verification ID returned from request_identity_verification" }),
291
- phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the person received via SMS or email" })
257
+ phrase: _sinclair_typebox.Type.String({ description: "Three-word phrase the person received via mobile or email" })
292
258
  }),
293
259
  handler: (params) => client.confirmIdentityVerification({
260
+ claimedIdentityId: params.claimedIdentityId,
294
261
  verificationId: params.verificationId,
295
262
  phrase: params.phrase
296
263
  })
297
264
  })
298
265
  ];
299
- for (const tool of tools) {
300
- api.registerTool(tool);
301
- identityToolNames.add(tool.name);
302
- }
266
+ for (const tool of tools) api.registerTool(tool);
303
267
  log.info(`Registered ${String(tools.length)} identity tools`);
304
268
  api.on("message_received", async (...args) => {
305
269
  const event = args[0];
306
270
  const ctx = args[1];
307
- const platform = ctx.channelId ?? "unknown";
308
- const senderId = event.from;
271
+ const provider = ctx.channelId ?? "unknown";
272
+ const senderId = event.metadata?.UserId ?? event.from;
309
273
  if (!senderId) return;
310
- const cacheKey = ctx.conversationId ?? `${platform}:${senderId}`;
311
- const cached = getCached(cacheKey);
274
+ const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
275
+ const cached = getCachedResolve(cacheKey);
312
276
  if (cached) {
277
+ if (cached.identityId == null) return {
278
+ block: true,
279
+ blockReason: "Identity: identity not provisioned for this channel"
280
+ };
313
281
  if (!cached.accessAllowed) return {
314
282
  block: true,
315
283
  blockReason: "Identity: access denied for this sender"
@@ -317,76 +285,39 @@ const plugin = {
317
285
  return;
318
286
  }
319
287
  try {
320
- const resolveResult = await client.resolveIdentity({
321
- platform,
288
+ const r = await client.resolveIdentity({
289
+ provider,
322
290
  platformId: senderId
323
291
  });
324
- setCached(cacheKey, {
325
- ...await client.enforcePolicy({
326
- platform,
327
- senderId
328
- }),
329
- accessAllowed: resolveResult.accessAllowed
292
+ setCachedResolve(cacheKey, {
293
+ identityId: r.identityId,
294
+ accessAllowed: r.accessAllowed,
295
+ status: r.status
330
296
  });
331
- if (!resolveResult.accessAllowed) return {
297
+ if (r.identityId == null) {
298
+ log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
299
+ return {
300
+ block: true,
301
+ blockReason: "Identity: identity not provisioned for this channel"
302
+ };
303
+ }
304
+ if (!r.accessAllowed) return {
332
305
  block: true,
333
306
  blockReason: "Identity: access denied for this sender"
334
307
  };
335
- log.info(`Identity resolved: ${senderId} → ${resolveResult.identityId ?? "unknown"} (${resolveResult.status})`);
308
+ log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
336
309
  } catch (e) {
337
310
  log.error(`Identity resolution failed for ${senderId}: ${e.message}`);
338
- if (failureMode === "closed") return {
339
- block: true,
340
- blockReason: "Identity: identity service unavailable — access denied (closed mode)"
341
- };
342
- }
343
- }, { priority: 100 });
344
- api.on("before_tool_call", async (...args) => {
345
- const event = args[0];
346
- const ctx = args[1];
347
- if (identityToolNames.has(event.toolName)) return;
348
- const sessionKey = ctx.sessionKey;
349
- if (!sessionKey) return;
350
- const perms = getCached(sessionKey);
351
- if (!perms) {
352
- if (failureMode === "permissive") return;
353
- return {
354
- block: true,
355
- blockReason: "Identity: no identity context established — tool access denied"
356
- };
357
- }
358
- if (!perms.identified) {
359
- if (failureMode === "permissive") return;
360
311
  return {
361
312
  block: true,
362
- blockReason: "Identity: unknown sender identitytool access denied"
313
+ blockReason: "Identity: identity service unavailable — access denied (fail-closed)"
363
314
  };
364
315
  }
365
- if (perms.deniedTools.includes("*") || perms.deniedTools.includes(event.toolName)) return {
366
- block: true,
367
- blockReason: `Identity: tool '${event.toolName}' is denied for your role`
368
- };
369
- if (perms.allowedTools.length > 0 && !perms.allowedTools.includes(event.toolName)) return {
370
- block: true,
371
- blockReason: `Identity: tool '${event.toolName}' is not in your allowed tools`
372
- };
373
316
  }, { priority: 100 });
374
- api.on("after_tool_call", async (...args) => {
375
- const event = args[0];
376
- const sessionKey = args[1].sessionKey;
377
- if (!sessionKey) return;
378
- const perms = getCached(sessionKey);
379
- if (!perms?.identityId) return;
380
- try {
381
- await client.checkToolPermission({
382
- platform: "tool_audit",
383
- senderId: perms.identityId,
384
- toolName: event.toolName
385
- });
386
- } catch (e) {
387
- log.error(`Audit logging failed: ${e.message}`);
388
- }
389
- });
317
+ const beforeToolCallStub = (event, ctx) => {
318
+ return Promise.resolve(void 0);
319
+ };
320
+ api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
390
321
  log.info("Alfe Identity plugin activated");
391
322
  }
392
323
  };
package/dist/plugin.js CHANGED
@@ -6,33 +6,36 @@ import { Type } from "@sinclair/typebox";
6
6
  /**
7
7
  * @alfe.ai/openclaw-identity — OpenClaw native plugin
8
8
  *
9
- * HTTP-based identity resolution, permission enforcement, and CRM tools.
10
- * Installed as part of the core alfe integration on every agent.
9
+ * HTTP-based identity resolution + AccessConfig admission gate. Installed as
10
+ * part of the core alfe integration on every agent.
11
11
  *
12
12
  * Hooks:
13
- * - message_received → resolve sender identity via HTTP, cache, gate access
14
- * - before_tool_call → enforce permissions from cached policy
15
- * - after_tool_call log tool execution audit
13
+ * - message_received → always resolves the sender, gates on accessAllowed,
14
+ * blocks unknown senders (read-only resolve).
15
+ * - before_tool_call typed `(event: ToolCallEvent, ctx: ToolCallContext)`
16
+ * hook (Phase 1 Stage A.0). Today: permissive no-op
17
+ * stub. Phase 1 Stage E swaps the stub for the
18
+ * `agent:exec` + sift gate (Decision 17).
16
19
  *
17
- * All data access via AgentApiClient (/agent/identity/* routes).
18
- * Uses the agent's own API key (from ~/.alfe/config.toml) for authentication.
20
+ * `after_tool_call` is intentionally absent tool-call audit lives in a
21
+ * future dedicated audit service, not in identity.
19
22
  */
20
23
  const pkg = createRequire(import.meta.url)("../package.json");
21
- const CACHE_TTL_MS = 6e4;
22
- const sessionCache = /* @__PURE__ */ new Map();
23
- function getCached(key) {
24
- const entry = sessionCache.get(key);
24
+ const RESOLVE_CACHE_TTL_MS = 6e4;
25
+ const resolveCache = /* @__PURE__ */ new Map();
26
+ function getCachedResolve(key) {
27
+ const entry = resolveCache.get(key);
25
28
  if (!entry) return null;
26
29
  if (Date.now() > entry.expiresAt) {
27
- sessionCache.delete(key);
30
+ resolveCache.delete(key);
28
31
  return null;
29
32
  }
30
- return entry.value;
33
+ return entry;
31
34
  }
32
- function setCached(key, value) {
33
- sessionCache.set(key, {
34
- value,
35
- expiresAt: Date.now() + CACHE_TTL_MS
35
+ function setCachedResolve(key, value) {
36
+ resolveCache.set(key, {
37
+ ...value,
38
+ expiresAt: Date.now() + RESOLVE_CACHE_TTL_MS
36
39
  });
37
40
  }
38
41
  function ok(data) {
@@ -71,7 +74,7 @@ function defineTool(def) {
71
74
  const plugin = {
72
75
  id: "@alfe.ai/openclaw-identity",
73
76
  name: "Alfe Identity",
74
- description: "Identity resolution, access gating, and permission enforcement",
77
+ description: "Identity resolution and access gating for inbound messages",
75
78
  version: pkg.version,
76
79
  activate(api) {
77
80
  const log = api.logger;
@@ -87,26 +90,17 @@ const plugin = {
87
90
  log.error(`Identity plugin: failed to resolve config — ${err instanceof Error ? err.message : String(err)}`);
88
91
  return;
89
92
  }
90
- let failureMode = "open";
91
- client.getIntegrationConfig("alfe").then((alfeConfig) => {
92
- const mode = alfeConfig.config.identity_failure_mode;
93
- if (mode === "open" || mode === "closed" || mode === "permissive") failureMode = mode;
94
- log.info(`Identity failure mode: ${failureMode}`);
95
- }).catch(() => {
96
- log.info(`Identity failure mode: ${failureMode} (default — config fetch failed)`);
97
- });
98
- const identityToolNames = /* @__PURE__ */ new Set();
99
93
  const tools = [
100
94
  defineTool({
101
95
  name: "who_is_this",
102
- description: "Look up full identity context by platform and ID — returns profile, notes, tags, platforms, and recent changelog",
96
+ description: "Look up an identity by provider + platformId — returns full identity context (profile, contacts, platforms, notes, tags, recent changelog). Returns `{found:false}` for unknown senders. This tool DOES NOT create new identities.",
103
97
  parameters: Type.Object({
104
- platform: Type.String({ description: "Platform name (discord, slack, chat, sms, whatsapp, etc.)" }),
105
- platformId: Type.String({ description: "Platform-specific user identifier" })
98
+ provider: Type.String({ description: "Provider name (discord, slack, chat, google-chat, clerk, etc.)" }),
99
+ platformId: Type.String({ description: "Provider-specific user identifier" })
106
100
  }),
107
101
  handler: async (params) => {
108
102
  const result = await client.resolveIdentity({
109
- platform: params.platform,
103
+ provider: params.provider,
110
104
  platformId: params.platformId
111
105
  });
112
106
  if (!result.identityId) return { found: false };
@@ -115,37 +109,19 @@ const plugin = {
115
109
  }),
116
110
  defineTool({
117
111
  name: "lookup_identity",
118
- description: "Search identities by name, email, phone, tag, or platform. Returns multiple matches.",
112
+ description: "Search identities by name. Returns multiple matches.",
119
113
  parameters: Type.Object({
120
114
  query: Type.Optional(Type.String({ description: "Text search query" })),
121
- status: Type.Optional(Type.String()),
122
- tag: Type.Optional(Type.String()),
123
- platform: Type.Optional(Type.String())
115
+ status: Type.Optional(Type.String())
124
116
  }),
125
117
  handler: (params) => client.searchIdentities({
126
118
  q: params.query,
127
- status: params.status,
128
- tag: params.tag,
129
- platform: params.platform
130
- })
131
- }),
132
- defineTool({
133
- name: "create_identity",
134
- description: "Create a new identity record with profile fields",
135
- parameters: Type.Object({
136
- platform: Type.String(),
137
- platformId: Type.String(),
138
- displayName: Type.Optional(Type.String())
139
- }),
140
- handler: (params) => client.resolveIdentity({
141
- platform: params.platform,
142
- platformId: params.platformId,
143
- displayName: params.displayName
119
+ status: params.status
144
120
  })
145
121
  }),
146
122
  defineTool({
147
123
  name: "merge_identities",
148
- description: "Merge two identity records — transfers notes, tags, aliases, platforms to survivor",
124
+ description: "Merge two identity records — transfers contacts, platforms, notes, tags, aliases to the survivor. Hard-move, transactional.",
149
125
  parameters: Type.Object({
150
126
  survivorId: Type.String({ description: "Identity to keep" }),
151
127
  mergedId: Type.String({ description: "Identity to merge into survivor" })
@@ -160,29 +136,16 @@ const plugin = {
160
136
  }),
161
137
  defineTool({
162
138
  name: "unmerge_identities",
163
- description: "Reverse a merge — restore previously merged identity",
139
+ description: "Reverse a merge — restore previously merged identity.",
164
140
  parameters: Type.Object({ mergedId: Type.String({ description: "Identity that was merged (has mergedInto pointer)" }) }),
165
141
  handler: (params) => client.unmergeIdentity(params.mergedId, { changedBy: {
166
142
  type: "agent",
167
143
  id: "plugin"
168
144
  } })
169
145
  }),
170
- defineTool({
171
- name: "link_platform",
172
- description: "Link a platform identity to an existing identity record",
173
- parameters: Type.Object({
174
- identityId: Type.String(),
175
- platform: Type.String(),
176
- platformId: Type.String()
177
- }),
178
- handler: (params) => client.resolveIdentity({
179
- platform: params.platform,
180
- platformId: params.platformId
181
- })
182
- }),
183
146
  defineTool({
184
147
  name: "add_identity_note",
185
- description: "Add an observation or note about a contact",
148
+ description: "Add an observation or note about an identity. Use category 'context' for unverified affiliation claims (self-reported title/company) — those do NOT belong on the identity row directly.",
186
149
  parameters: Type.Object({
187
150
  identityId: Type.String(),
188
151
  content: Type.String(),
@@ -199,7 +162,7 @@ const plugin = {
199
162
  }),
200
163
  defineTool({
201
164
  name: "tag_identity",
202
- description: "Add or remove a tag on an identity",
165
+ description: "Add or remove a tag on an identity.",
203
166
  parameters: Type.Object({
204
167
  identityId: Type.String(),
205
168
  tag: Type.String(),
@@ -216,7 +179,7 @@ const plugin = {
216
179
  }),
217
180
  defineTool({
218
181
  name: "get_identity_changelog",
219
- description: "Get full changelog for an identity — all versions, diffs, who changed what",
182
+ description: "Get the changelog for an identity — versions, diffs, who changed what.",
220
183
  parameters: Type.Object({
221
184
  identityId: Type.String(),
222
185
  limit: Type.Optional(Type.Number())
@@ -225,7 +188,7 @@ const plugin = {
225
188
  }),
226
189
  defineTool({
227
190
  name: "rollback_identity",
228
- description: "Revert an identity to a previous version",
191
+ description: "Revert an identity to a previous version.",
229
192
  parameters: Type.Object({
230
193
  identityId: Type.String(),
231
194
  targetVersion: Type.Number()
@@ -239,78 +202,83 @@ const plugin = {
239
202
  })
240
203
  }),
241
204
  defineTool({
242
- name: "enforce_policy",
243
- description: "Resolve sender identity and return their tool policy",
244
- parameters: Type.Object({
245
- platform: Type.String(),
246
- senderId: Type.String(),
247
- channelId: Type.Optional(Type.String())
248
- }),
249
- handler: (params) => client.enforcePolicy({
250
- platform: params.platform,
251
- senderId: params.senderId,
252
- channelId: params.channelId
253
- })
254
- }),
255
- defineTool({
256
- name: "check_permission",
257
- description: "Check if a specific tool call is allowed for a sender",
205
+ name: "update_identity",
206
+ description: "Update display-shape fields on an identity: name / avatarUrl / timezone / locale. Contact mutations go through the verify flow — this tool will NOT accept email or mobile. Title and company live on org membership rows, not here.",
258
207
  parameters: Type.Object({
259
- platform: Type.String(),
260
- senderId: Type.String(),
261
- toolName: Type.String()
208
+ identityId: Type.String(),
209
+ name: Type.Optional(Type.String()),
210
+ avatarUrl: Type.Optional(Type.String()),
211
+ timezone: Type.Optional(Type.String()),
212
+ locale: Type.Optional(Type.String())
262
213
  }),
263
- handler: (params) => client.checkToolPermission({
264
- platform: params.platform,
265
- senderId: params.senderId,
266
- toolName: params.toolName
267
- })
214
+ handler: (params) => {
215
+ const { identityId, ...rest } = params;
216
+ return client.updateIdentity(identityId, rest);
217
+ }
268
218
  }),
269
219
  defineTool({
270
220
  name: "request_identity_verification",
271
- description: "Send a verification phrase to the claimed identity's phone (SMS) or email. The person must relay the phrase back to confirm they own that identity.",
221
+ 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`.",
272
222
  parameters: Type.Object({
273
223
  claimedIdentityId: Type.String({ description: "Identity being claimed" }),
274
224
  requestingIdentityId: Type.String({ description: "Identity of the person making the claim" }),
275
- requestingPlatform: Type.String({ description: "Platform the requester is on (discord, slack, chat, etc.)" }),
276
- requestingPlatformId: Type.String({ description: "Requester's platform-specific user ID" }),
277
- preferredChannel: Type.Optional(Type.String({ description: "'sms' or 'email'" }))
225
+ requestingProvider: Type.String({ description: "Provider the requester is on (discord, slack, chat, etc.)" }),
226
+ requestingPlatformId: Type.String({ description: "Requester's provider-specific user ID" }),
227
+ contactEmail: Type.Optional(Type.String({ description: "Email to verify (mutually exclusive with contactMobile)" })),
228
+ contactMobile: Type.Optional(Type.String({ description: "E.164 mobile to verify (mutually exclusive with contactEmail)" })),
229
+ preferredChannel: Type.Optional(Type.String({ description: "When neither contactEmail nor contactMobile is provided, pick which existing verified contact to deliver to: 'mobile' or 'email'" }))
278
230
  }),
279
- handler: (params) => client.requestIdentityVerification({
280
- claimedIdentityId: params.claimedIdentityId,
281
- requestingIdentityId: params.requestingIdentityId,
282
- requestingPlatform: params.requestingPlatform,
283
- requestingPlatformId: params.requestingPlatformId,
284
- preferredChannel: params.preferredChannel
285
- })
231
+ handler: (params) => {
232
+ const contactEmail = params.contactEmail;
233
+ const contactMobile = params.contactMobile;
234
+ if (contactEmail && contactMobile) return Promise.resolve({ error: "Specify exactly one of contactEmail or contactMobile, not both" });
235
+ const contact = contactEmail ? {
236
+ channel: "email",
237
+ value: contactEmail
238
+ } : contactMobile ? {
239
+ channel: "mobile",
240
+ value: contactMobile
241
+ } : void 0;
242
+ return client.requestIdentityVerification({
243
+ claimedIdentityId: params.claimedIdentityId,
244
+ requestingIdentityId: params.requestingIdentityId,
245
+ requestingProvider: params.requestingProvider,
246
+ requestingPlatformId: params.requestingPlatformId,
247
+ preferredChannel: params.preferredChannel,
248
+ contact
249
+ });
250
+ }
286
251
  }),
287
252
  defineTool({
288
253
  name: "confirm_identity_verification",
289
- description: "Confirm a verification by providing the three-word phrase. On success, the requesting identity is merged into the claimed identity.",
254
+ 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).",
290
255
  parameters: Type.Object({
256
+ claimedIdentityId: Type.String({ description: "Identity being claimed (matches request)" }),
291
257
  verificationId: Type.String({ description: "Verification ID returned from request_identity_verification" }),
292
- phrase: Type.String({ description: "Three-word phrase the person received via SMS or email" })
258
+ phrase: Type.String({ description: "Three-word phrase the person received via mobile or email" })
293
259
  }),
294
260
  handler: (params) => client.confirmIdentityVerification({
261
+ claimedIdentityId: params.claimedIdentityId,
295
262
  verificationId: params.verificationId,
296
263
  phrase: params.phrase
297
264
  })
298
265
  })
299
266
  ];
300
- for (const tool of tools) {
301
- api.registerTool(tool);
302
- identityToolNames.add(tool.name);
303
- }
267
+ for (const tool of tools) api.registerTool(tool);
304
268
  log.info(`Registered ${String(tools.length)} identity tools`);
305
269
  api.on("message_received", async (...args) => {
306
270
  const event = args[0];
307
271
  const ctx = args[1];
308
- const platform = ctx.channelId ?? "unknown";
309
- const senderId = event.from;
272
+ const provider = ctx.channelId ?? "unknown";
273
+ const senderId = event.metadata?.UserId ?? event.from;
310
274
  if (!senderId) return;
311
- const cacheKey = ctx.conversationId ?? `${platform}:${senderId}`;
312
- const cached = getCached(cacheKey);
275
+ const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
276
+ const cached = getCachedResolve(cacheKey);
313
277
  if (cached) {
278
+ if (cached.identityId == null) return {
279
+ block: true,
280
+ blockReason: "Identity: identity not provisioned for this channel"
281
+ };
314
282
  if (!cached.accessAllowed) return {
315
283
  block: true,
316
284
  blockReason: "Identity: access denied for this sender"
@@ -318,76 +286,39 @@ const plugin = {
318
286
  return;
319
287
  }
320
288
  try {
321
- const resolveResult = await client.resolveIdentity({
322
- platform,
289
+ const r = await client.resolveIdentity({
290
+ provider,
323
291
  platformId: senderId
324
292
  });
325
- setCached(cacheKey, {
326
- ...await client.enforcePolicy({
327
- platform,
328
- senderId
329
- }),
330
- accessAllowed: resolveResult.accessAllowed
293
+ setCachedResolve(cacheKey, {
294
+ identityId: r.identityId,
295
+ accessAllowed: r.accessAllowed,
296
+ status: r.status
331
297
  });
332
- if (!resolveResult.accessAllowed) return {
298
+ if (r.identityId == null) {
299
+ log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
300
+ return {
301
+ block: true,
302
+ blockReason: "Identity: identity not provisioned for this channel"
303
+ };
304
+ }
305
+ if (!r.accessAllowed) return {
333
306
  block: true,
334
307
  blockReason: "Identity: access denied for this sender"
335
308
  };
336
- log.info(`Identity resolved: ${senderId} → ${resolveResult.identityId ?? "unknown"} (${resolveResult.status})`);
309
+ log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
337
310
  } catch (e) {
338
311
  log.error(`Identity resolution failed for ${senderId}: ${e.message}`);
339
- if (failureMode === "closed") return {
340
- block: true,
341
- blockReason: "Identity: identity service unavailable — access denied (closed mode)"
342
- };
343
- }
344
- }, { priority: 100 });
345
- api.on("before_tool_call", async (...args) => {
346
- const event = args[0];
347
- const ctx = args[1];
348
- if (identityToolNames.has(event.toolName)) return;
349
- const sessionKey = ctx.sessionKey;
350
- if (!sessionKey) return;
351
- const perms = getCached(sessionKey);
352
- if (!perms) {
353
- if (failureMode === "permissive") return;
354
- return {
355
- block: true,
356
- blockReason: "Identity: no identity context established — tool access denied"
357
- };
358
- }
359
- if (!perms.identified) {
360
- if (failureMode === "permissive") return;
361
312
  return {
362
313
  block: true,
363
- blockReason: "Identity: unknown sender identitytool access denied"
314
+ blockReason: "Identity: identity service unavailable — access denied (fail-closed)"
364
315
  };
365
316
  }
366
- if (perms.deniedTools.includes("*") || perms.deniedTools.includes(event.toolName)) return {
367
- block: true,
368
- blockReason: `Identity: tool '${event.toolName}' is denied for your role`
369
- };
370
- if (perms.allowedTools.length > 0 && !perms.allowedTools.includes(event.toolName)) return {
371
- block: true,
372
- blockReason: `Identity: tool '${event.toolName}' is not in your allowed tools`
373
- };
374
317
  }, { priority: 100 });
375
- api.on("after_tool_call", async (...args) => {
376
- const event = args[0];
377
- const sessionKey = args[1].sessionKey;
378
- if (!sessionKey) return;
379
- const perms = getCached(sessionKey);
380
- if (!perms?.identityId) return;
381
- try {
382
- await client.checkToolPermission({
383
- platform: "tool_audit",
384
- senderId: perms.identityId,
385
- toolName: event.toolName
386
- });
387
- } catch (e) {
388
- log.error(`Audit logging failed: ${e.message}`);
389
- }
390
- });
318
+ const beforeToolCallStub = (event, ctx) => {
319
+ return Promise.resolve(void 0);
320
+ };
321
+ api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
391
322
  log.info("Alfe Identity plugin activated");
392
323
  }
393
324
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-identity",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,13 +28,14 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@sinclair/typebox": "^0.34.48",
31
- "@alfe.ai/agent-api-client": "0.0.12",
31
+ "@alfe.ai/agent-api-client": "0.0.13",
32
32
  "@alfe.ai/config": "0.0.8"
33
33
  },
34
34
  "license": "UNLICENSED",
35
35
  "scripts": {
36
36
  "build": "tsdown",
37
37
  "dev": "tsdown --watch",
38
+ "test": "vitest run",
38
39
  "typecheck": "tsc --noEmit",
39
40
  "lint": "eslint ."
40
41
  }