@alfe.ai/openclaw-identity 0.0.15 → 0.0.17

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.d.cts CHANGED
@@ -1,16 +1,40 @@
1
1
  import plugin from "./plugin.cjs";
2
2
 
3
- //#region src/policy-cache.d.ts
3
+ //#region src/runtime-contract.d.ts
4
4
 
5
- type IdentityFailureMode = "open" | "closed" | "permissive";
6
5
  /**
7
- * Evaluate whether a tool call should be blocked based on the cached policy
8
- * and failure mode.
6
+ * OpenClaw runtime contract for the identity plugin's `before_tool_call` hook.
7
+ *
8
+ * Phase 1 Stage A.0 of the roles/permissions/sessions plan. The plugin's hook
9
+ * signature documents what the OpenClaw daemon must populate when a tool call
10
+ * is dispatched. The actual gate logic (Stage E) reads these fields to make a
11
+ * permit/deny decision via `@auriclabs/roles`.
12
+ *
13
+ * Contract:
14
+ * - `ToolCallEvent` describes the tool being called (name + args).
15
+ * - `ToolCallContext` describes the calling context (tenant, agent, actor).
16
+ *
17
+ * The hook receives `(event, ctx)`. Both arguments are required. The daemon
18
+ * is responsible for populating every required field; the plugin's gate
19
+ * applies the per-agent `IdentityFailureMode` when an optional field is
20
+ * absent and a permission decision can't be made cleanly.
21
+ *
22
+ * Round 6 note (gate context shape): the gate context passed to `sift` is
23
+ * `{ tool: event.toolName, args: event.toolArgs }` with strict namespacing
24
+ * (no spread). This prevents an attacker-controlled `tool` field inside
25
+ * `toolArgs` from overriding the gate's value.
9
26
  */
10
-
11
- //#endregion
12
- //#region src/runtime-contract.d.ts
13
-
27
+ /**
28
+ * Per-agent fallback policy when an optional `ctx.actingIdentityId` is
29
+ * absent (e.g. autonomous tool_chain runs that don't carry a chat-side
30
+ * sender). Round-5 unified plan literals.
31
+ *
32
+ * - "open" — admit the call (legacy default).
33
+ * - "closed" — block the call (production default once Stage E ships).
34
+ * - "permissive" — admit with a warn-level audit log; intermediate during
35
+ * the Stage H dwell window.
36
+ */
37
+ type IdentityFailureMode = "open" | "closed" | "permissive";
14
38
  /**
15
39
  * The tool the agent is about to call.
16
40
  *
package/dist/index.d.ts CHANGED
@@ -1,16 +1,40 @@
1
1
  import plugin from "./plugin.js";
2
2
 
3
- //#region src/policy-cache.d.ts
3
+ //#region src/runtime-contract.d.ts
4
4
 
5
- type IdentityFailureMode = "open" | "closed" | "permissive";
6
5
  /**
7
- * Evaluate whether a tool call should be blocked based on the cached policy
8
- * and failure mode.
6
+ * OpenClaw runtime contract for the identity plugin's `before_tool_call` hook.
7
+ *
8
+ * Phase 1 Stage A.0 of the roles/permissions/sessions plan. The plugin's hook
9
+ * signature documents what the OpenClaw daemon must populate when a tool call
10
+ * is dispatched. The actual gate logic (Stage E) reads these fields to make a
11
+ * permit/deny decision via `@auriclabs/roles`.
12
+ *
13
+ * Contract:
14
+ * - `ToolCallEvent` describes the tool being called (name + args).
15
+ * - `ToolCallContext` describes the calling context (tenant, agent, actor).
16
+ *
17
+ * The hook receives `(event, ctx)`. Both arguments are required. The daemon
18
+ * is responsible for populating every required field; the plugin's gate
19
+ * applies the per-agent `IdentityFailureMode` when an optional field is
20
+ * absent and a permission decision can't be made cleanly.
21
+ *
22
+ * Round 6 note (gate context shape): the gate context passed to `sift` is
23
+ * `{ tool: event.toolName, args: event.toolArgs }` with strict namespacing
24
+ * (no spread). This prevents an attacker-controlled `tool` field inside
25
+ * `toolArgs` from overriding the gate's value.
9
26
  */
10
-
11
- //#endregion
12
- //#region src/runtime-contract.d.ts
13
-
27
+ /**
28
+ * Per-agent fallback policy when an optional `ctx.actingIdentityId` is
29
+ * absent (e.g. autonomous tool_chain runs that don't carry a chat-side
30
+ * sender). Round-5 unified plan literals.
31
+ *
32
+ * - "open" — admit the call (legacy default).
33
+ * - "closed" — block the call (production default once Stage E ships).
34
+ * - "permissive" — admit with a warn-level audit log; intermediate during
35
+ * the Stage H dwell window.
36
+ */
37
+ type IdentityFailureMode = "open" | "closed" | "permissive";
14
38
  /**
15
39
  * The tool the agent is about to call.
16
40
  *
package/dist/plugin.d.cts CHANGED
@@ -35,6 +35,7 @@ declare const plugin: {
35
35
  description: string;
36
36
  version: string;
37
37
  activate(api: PluginApi): void;
38
+ deactivate(api: PluginApi): void;
38
39
  };
39
40
  //#endregion
40
41
  export { plugin as default };
package/dist/plugin.d.ts CHANGED
@@ -35,6 +35,7 @@ declare const plugin: {
35
35
  description: string;
36
36
  version: string;
37
37
  activate(api: PluginApi): void;
38
+ deactivate(api: PluginApi): void;
38
39
  };
39
40
  //#endregion
40
41
  export { plugin as default };
package/dist/plugin2.cjs CHANGED
@@ -82,21 +82,49 @@ function resolveToolGatingMode() {
82
82
  if (raw === "fail-open" || raw === "fail-closed") return raw;
83
83
  return "fail-open";
84
84
  }
85
+ /**
86
+ * Evaluate `agent:chat` — the inbound conversational gate (Layer 2 of the
87
+ * default-deny chat path). Mirrors `evaluateAgentExec` but with no
88
+ * tool/args context — chat admission only depends on whether the
89
+ * resolved identity holds `agent:chat` at the agent's scope.
90
+ *
91
+ * Layer 1 (`services/chat`) already gates this for chat WS + channel
92
+ * adapter inject. The OpenClaw plugin's `message_received` hook is
93
+ * defense-in-depth — it catches any path that reaches the agent
94
+ * runtime without going through Layer 1.
95
+ */
96
+ function evaluateAgentChat(permissions, args) {
97
+ if (!args.agentId) return { allowed: false };
98
+ const ability = (0, _auriclabs_roles.createAbility)(permissions);
99
+ const gatePermission = {
100
+ subject: "agent",
101
+ action: "chat",
102
+ scope: `agent:${args.agentId}`
103
+ };
104
+ return { allowed: ability.has(gatePermission, {}) };
105
+ }
85
106
  //#endregion
86
107
  //#region src/plugin.ts
87
108
  /**
88
109
  * @alfe.ai/openclaw-identity — OpenClaw native plugin
89
110
  *
90
- * HTTP-based identity resolution + AccessConfig admission gate. Installed as
111
+ * HTTP-based identity resolution + role-based admission gate. Installed as
91
112
  * part of the core alfe integration on every agent.
92
113
  *
93
114
  * Hooks:
94
- * - message_received → always resolves the sender, gates on accessAllowed,
95
- * blocks unknown senders (read-only resolve).
115
+ * - message_received → resolves the sender via the agent API and gates
116
+ * on `agent:chat` at the agent's scope using the
117
+ * resolved identity's permission set
118
+ * (`evaluateAgentChat`). Blocks unknown senders
119
+ * and any identity without the permission. Layer 2
120
+ * of the default-deny chat path.
96
121
  * - before_tool_call → typed `(event: ToolCallEvent, ctx: ToolCallContext)`
97
- * hook (Phase 1 Stage A.0). Today: permissive no-op
98
- * stub. Phase 1 Stage E swaps the stub for the
99
- * `agent:exec` + sift gate (Decision 17).
122
+ * hook (Phase 1 Stage A.0). Gates `agent:exec`
123
+ * via `evaluateAgentExec` against the token's
124
+ * own permissions (token path) or the cached
125
+ * resolved permissions (JWT path). Sift
126
+ * conditions on the permission narrow which
127
+ * tools the call may invoke.
100
128
  *
101
129
  * `after_tool_call` is intentionally absent — tool-call audit lives in a
102
130
  * future dedicated audit service, not in identity.
@@ -152,6 +180,8 @@ function defineTool(def) {
152
180
  }
153
181
  };
154
182
  }
183
+ const ACTIVATED_KEY = "__alfeIdentityPluginActivated";
184
+ const g = globalThis;
155
185
  const plugin = {
156
186
  id: "@alfe.ai/openclaw-identity",
157
187
  name: "Alfe Identity",
@@ -159,6 +189,11 @@ const plugin = {
159
189
  version: pkg.version,
160
190
  activate(api) {
161
191
  const log = api.logger;
192
+ if (g[ACTIVATED_KEY] === true) {
193
+ log.debug("Alfe Identity plugin already activated — skipping duplicate");
194
+ return;
195
+ }
196
+ g[ACTIVATED_KEY] = true;
162
197
  log.info("Alfe Identity plugin activating...");
163
198
  let client;
164
199
  try {
@@ -168,6 +203,7 @@ const plugin = {
168
203
  apiUrl: config.apiUrl
169
204
  });
170
205
  } catch (err) {
206
+ g[ACTIVATED_KEY] = false;
171
207
  log.error(`Identity plugin: failed to resolve config — ${err instanceof Error ? err.message : String(err)}`);
172
208
  return;
173
209
  }
@@ -353,6 +389,15 @@ const plugin = {
353
389
  const provider = ctx.channelId ?? "unknown";
354
390
  const senderId = event.metadata?.UserId ?? event.from;
355
391
  if (!senderId) return;
392
+ const agentId = ctx.agentId;
393
+ if (!agentId) {
394
+ log.warn(`message_received without ctx.agentId — fail-closed`);
395
+ return {
396
+ block: true,
397
+ blockReason: "Identity: agentId missing on message context"
398
+ };
399
+ }
400
+ const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
356
401
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
357
402
  const cached = getCachedResolve(cacheKey);
358
403
  if (cached) {
@@ -360,9 +405,9 @@ const plugin = {
360
405
  block: true,
361
406
  blockReason: "Identity: identity not provisioned for this channel"
362
407
  };
363
- if (!cached.accessAllowed) return {
408
+ if (!evaluateAgentChat([...cached.permissions, ...forwarded], { agentId }).allowed) return {
364
409
  block: true,
365
- blockReason: "Identity: access denied for this sender"
410
+ blockReason: "Identity: chat not permitted for this sender"
366
411
  };
367
412
  return;
368
413
  }
@@ -373,8 +418,8 @@ const plugin = {
373
418
  });
374
419
  setCachedResolve(cacheKey, {
375
420
  identityId: r.identityId,
376
- accessAllowed: r.accessAllowed,
377
- status: r.status
421
+ status: r.status,
422
+ permissions: [...r.permissions, ...forwarded]
378
423
  });
379
424
  if (r.identityId == null) {
380
425
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -383,9 +428,10 @@ const plugin = {
383
428
  blockReason: "Identity: identity not provisioned for this channel"
384
429
  };
385
430
  }
386
- if (!r.accessAllowed) return {
431
+ const cachedAfterFill = getCachedResolve(cacheKey);
432
+ if (!evaluateAgentChat(cachedAfterFill ? [...cachedAfterFill.permissions] : [...r.permissions, ...forwarded], { agentId }).allowed) return {
387
433
  block: true,
388
- blockReason: "Identity: access denied for this sender"
434
+ blockReason: "Identity: chat not permitted for this sender"
389
435
  };
390
436
  log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
391
437
  } catch (e) {
@@ -405,10 +451,32 @@ const plugin = {
405
451
  }), resolveToolGatingMode(), event.toolName);
406
452
  return Promise.resolve(blocked);
407
453
  }
408
- return Promise.resolve(void 0);
454
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
455
+ if (cacheKey) {
456
+ const cached = getCachedResolve(cacheKey);
457
+ if (cached && cached.permissions.length > 0) {
458
+ const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
459
+ agentId: ctx.agentId,
460
+ toolName: event.toolName,
461
+ toolArgs: event.toolArgs
462
+ }), resolveToolGatingMode(), event.toolName);
463
+ return Promise.resolve(blocked);
464
+ }
465
+ }
466
+ const fallback = evaluateAgentExec([], {
467
+ agentId: ctx.agentId,
468
+ toolName: event.toolName,
469
+ toolArgs: event.toolArgs
470
+ });
471
+ const mode = resolveToolGatingMode();
472
+ return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
409
473
  };
410
474
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
411
475
  log.info("Alfe Identity plugin activated");
476
+ },
477
+ deactivate(api) {
478
+ g[ACTIVATED_KEY] = false;
479
+ api.logger.info("Alfe Identity plugin deactivated");
412
480
  }
413
481
  };
414
482
  //#endregion
package/dist/plugin2.js CHANGED
@@ -82,21 +82,49 @@ function resolveToolGatingMode() {
82
82
  if (raw === "fail-open" || raw === "fail-closed") return raw;
83
83
  return "fail-open";
84
84
  }
85
+ /**
86
+ * Evaluate `agent:chat` — the inbound conversational gate (Layer 2 of the
87
+ * default-deny chat path). Mirrors `evaluateAgentExec` but with no
88
+ * tool/args context — chat admission only depends on whether the
89
+ * resolved identity holds `agent:chat` at the agent's scope.
90
+ *
91
+ * Layer 1 (`services/chat`) already gates this for chat WS + channel
92
+ * adapter inject. The OpenClaw plugin's `message_received` hook is
93
+ * defense-in-depth — it catches any path that reaches the agent
94
+ * runtime without going through Layer 1.
95
+ */
96
+ function evaluateAgentChat(permissions, args) {
97
+ if (!args.agentId) return { allowed: false };
98
+ const ability = createAbility(permissions);
99
+ const gatePermission = {
100
+ subject: "agent",
101
+ action: "chat",
102
+ scope: `agent:${args.agentId}`
103
+ };
104
+ return { allowed: ability.has(gatePermission, {}) };
105
+ }
85
106
  //#endregion
86
107
  //#region src/plugin.ts
87
108
  /**
88
109
  * @alfe.ai/openclaw-identity — OpenClaw native plugin
89
110
  *
90
- * HTTP-based identity resolution + AccessConfig admission gate. Installed as
111
+ * HTTP-based identity resolution + role-based admission gate. Installed as
91
112
  * part of the core alfe integration on every agent.
92
113
  *
93
114
  * Hooks:
94
- * - message_received → always resolves the sender, gates on accessAllowed,
95
- * blocks unknown senders (read-only resolve).
115
+ * - message_received → resolves the sender via the agent API and gates
116
+ * on `agent:chat` at the agent's scope using the
117
+ * resolved identity's permission set
118
+ * (`evaluateAgentChat`). Blocks unknown senders
119
+ * and any identity without the permission. Layer 2
120
+ * of the default-deny chat path.
96
121
  * - before_tool_call → typed `(event: ToolCallEvent, ctx: ToolCallContext)`
97
- * hook (Phase 1 Stage A.0). Today: permissive no-op
98
- * stub. Phase 1 Stage E swaps the stub for the
99
- * `agent:exec` + sift gate (Decision 17).
122
+ * hook (Phase 1 Stage A.0). Gates `agent:exec`
123
+ * via `evaluateAgentExec` against the token's
124
+ * own permissions (token path) or the cached
125
+ * resolved permissions (JWT path). Sift
126
+ * conditions on the permission narrow which
127
+ * tools the call may invoke.
100
128
  *
101
129
  * `after_tool_call` is intentionally absent — tool-call audit lives in a
102
130
  * future dedicated audit service, not in identity.
@@ -152,6 +180,8 @@ function defineTool(def) {
152
180
  }
153
181
  };
154
182
  }
183
+ const ACTIVATED_KEY = "__alfeIdentityPluginActivated";
184
+ const g = globalThis;
155
185
  const plugin = {
156
186
  id: "@alfe.ai/openclaw-identity",
157
187
  name: "Alfe Identity",
@@ -159,6 +189,11 @@ const plugin = {
159
189
  version: pkg.version,
160
190
  activate(api) {
161
191
  const log = api.logger;
192
+ if (g[ACTIVATED_KEY] === true) {
193
+ log.debug("Alfe Identity plugin already activated — skipping duplicate");
194
+ return;
195
+ }
196
+ g[ACTIVATED_KEY] = true;
162
197
  log.info("Alfe Identity plugin activating...");
163
198
  let client;
164
199
  try {
@@ -168,6 +203,7 @@ const plugin = {
168
203
  apiUrl: config.apiUrl
169
204
  });
170
205
  } catch (err) {
206
+ g[ACTIVATED_KEY] = false;
171
207
  log.error(`Identity plugin: failed to resolve config — ${err instanceof Error ? err.message : String(err)}`);
172
208
  return;
173
209
  }
@@ -353,6 +389,15 @@ const plugin = {
353
389
  const provider = ctx.channelId ?? "unknown";
354
390
  const senderId = event.metadata?.UserId ?? event.from;
355
391
  if (!senderId) return;
392
+ const agentId = ctx.agentId;
393
+ if (!agentId) {
394
+ log.warn(`message_received without ctx.agentId — fail-closed`);
395
+ return {
396
+ block: true,
397
+ blockReason: "Identity: agentId missing on message context"
398
+ };
399
+ }
400
+ const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
356
401
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
357
402
  const cached = getCachedResolve(cacheKey);
358
403
  if (cached) {
@@ -360,9 +405,9 @@ const plugin = {
360
405
  block: true,
361
406
  blockReason: "Identity: identity not provisioned for this channel"
362
407
  };
363
- if (!cached.accessAllowed) return {
408
+ if (!evaluateAgentChat([...cached.permissions, ...forwarded], { agentId }).allowed) return {
364
409
  block: true,
365
- blockReason: "Identity: access denied for this sender"
410
+ blockReason: "Identity: chat not permitted for this sender"
366
411
  };
367
412
  return;
368
413
  }
@@ -373,8 +418,8 @@ const plugin = {
373
418
  });
374
419
  setCachedResolve(cacheKey, {
375
420
  identityId: r.identityId,
376
- accessAllowed: r.accessAllowed,
377
- status: r.status
421
+ status: r.status,
422
+ permissions: [...r.permissions, ...forwarded]
378
423
  });
379
424
  if (r.identityId == null) {
380
425
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -383,9 +428,10 @@ const plugin = {
383
428
  blockReason: "Identity: identity not provisioned for this channel"
384
429
  };
385
430
  }
386
- if (!r.accessAllowed) return {
431
+ const cachedAfterFill = getCachedResolve(cacheKey);
432
+ if (!evaluateAgentChat(cachedAfterFill ? [...cachedAfterFill.permissions] : [...r.permissions, ...forwarded], { agentId }).allowed) return {
387
433
  block: true,
388
- blockReason: "Identity: access denied for this sender"
434
+ blockReason: "Identity: chat not permitted for this sender"
389
435
  };
390
436
  log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
391
437
  } catch (e) {
@@ -405,10 +451,32 @@ const plugin = {
405
451
  }), resolveToolGatingMode(), event.toolName);
406
452
  return Promise.resolve(blocked);
407
453
  }
408
- return Promise.resolve(void 0);
454
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
455
+ if (cacheKey) {
456
+ const cached = getCachedResolve(cacheKey);
457
+ if (cached && cached.permissions.length > 0) {
458
+ const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
459
+ agentId: ctx.agentId,
460
+ toolName: event.toolName,
461
+ toolArgs: event.toolArgs
462
+ }), resolveToolGatingMode(), event.toolName);
463
+ return Promise.resolve(blocked);
464
+ }
465
+ }
466
+ const fallback = evaluateAgentExec([], {
467
+ agentId: ctx.agentId,
468
+ toolName: event.toolName,
469
+ toolArgs: event.toolArgs
470
+ });
471
+ const mode = resolveToolGatingMode();
472
+ return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
409
473
  };
410
474
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
411
475
  log.info("Alfe Identity plugin activated");
476
+ },
477
+ deactivate(api) {
478
+ g[ACTIVATED_KEY] = false;
479
+ api.logger.info("Alfe Identity plugin deactivated");
412
480
  }
413
481
  };
414
482
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-identity",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",