@alfe.ai/openclaw-identity 0.0.14 → 0.0.16

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/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.
@@ -353,6 +381,15 @@ const plugin = {
353
381
  const provider = ctx.channelId ?? "unknown";
354
382
  const senderId = event.metadata?.UserId ?? event.from;
355
383
  if (!senderId) return;
384
+ const agentId = ctx.agentId;
385
+ if (!agentId) {
386
+ log.warn(`message_received without ctx.agentId — fail-closed`);
387
+ return {
388
+ block: true,
389
+ blockReason: "Identity: agentId missing on message context"
390
+ };
391
+ }
392
+ const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
356
393
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
357
394
  const cached = getCachedResolve(cacheKey);
358
395
  if (cached) {
@@ -360,9 +397,9 @@ const plugin = {
360
397
  block: true,
361
398
  blockReason: "Identity: identity not provisioned for this channel"
362
399
  };
363
- if (!cached.accessAllowed) return {
400
+ if (!evaluateAgentChat([...cached.permissions, ...forwarded], { agentId }).allowed) return {
364
401
  block: true,
365
- blockReason: "Identity: access denied for this sender"
402
+ blockReason: "Identity: chat not permitted for this sender"
366
403
  };
367
404
  return;
368
405
  }
@@ -373,8 +410,8 @@ const plugin = {
373
410
  });
374
411
  setCachedResolve(cacheKey, {
375
412
  identityId: r.identityId,
376
- accessAllowed: r.accessAllowed,
377
- status: r.status
413
+ status: r.status,
414
+ permissions: [...r.permissions, ...forwarded]
378
415
  });
379
416
  if (r.identityId == null) {
380
417
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -383,9 +420,10 @@ const plugin = {
383
420
  blockReason: "Identity: identity not provisioned for this channel"
384
421
  };
385
422
  }
386
- if (!r.accessAllowed) return {
423
+ const cachedAfterFill = getCachedResolve(cacheKey);
424
+ if (!evaluateAgentChat(cachedAfterFill ? [...cachedAfterFill.permissions] : [...r.permissions, ...forwarded], { agentId }).allowed) return {
387
425
  block: true,
388
- blockReason: "Identity: access denied for this sender"
426
+ blockReason: "Identity: chat not permitted for this sender"
389
427
  };
390
428
  log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
391
429
  } catch (e) {
@@ -405,7 +443,25 @@ const plugin = {
405
443
  }), resolveToolGatingMode(), event.toolName);
406
444
  return Promise.resolve(blocked);
407
445
  }
408
- return Promise.resolve(void 0);
446
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
447
+ if (cacheKey) {
448
+ const cached = getCachedResolve(cacheKey);
449
+ if (cached && cached.permissions.length > 0) {
450
+ const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
451
+ agentId: ctx.agentId,
452
+ toolName: event.toolName,
453
+ toolArgs: event.toolArgs
454
+ }), resolveToolGatingMode(), event.toolName);
455
+ return Promise.resolve(blocked);
456
+ }
457
+ }
458
+ const fallback = evaluateAgentExec([], {
459
+ agentId: ctx.agentId,
460
+ toolName: event.toolName,
461
+ toolArgs: event.toolArgs
462
+ });
463
+ const mode = resolveToolGatingMode();
464
+ return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
409
465
  };
410
466
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
411
467
  log.info("Alfe Identity plugin activated");
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.
@@ -353,6 +381,15 @@ const plugin = {
353
381
  const provider = ctx.channelId ?? "unknown";
354
382
  const senderId = event.metadata?.UserId ?? event.from;
355
383
  if (!senderId) return;
384
+ const agentId = ctx.agentId;
385
+ if (!agentId) {
386
+ log.warn(`message_received without ctx.agentId — fail-closed`);
387
+ return {
388
+ block: true,
389
+ blockReason: "Identity: agentId missing on message context"
390
+ };
391
+ }
392
+ const forwarded = Array.isArray(event.metadata?.SenderPermissions) ? event.metadata.SenderPermissions.filter((p) => typeof p === "string") : [];
356
393
  const cacheKey = ctx.conversationId ?? `${provider}:${senderId}`;
357
394
  const cached = getCachedResolve(cacheKey);
358
395
  if (cached) {
@@ -360,9 +397,9 @@ const plugin = {
360
397
  block: true,
361
398
  blockReason: "Identity: identity not provisioned for this channel"
362
399
  };
363
- if (!cached.accessAllowed) return {
400
+ if (!evaluateAgentChat([...cached.permissions, ...forwarded], { agentId }).allowed) return {
364
401
  block: true,
365
- blockReason: "Identity: access denied for this sender"
402
+ blockReason: "Identity: chat not permitted for this sender"
366
403
  };
367
404
  return;
368
405
  }
@@ -373,8 +410,8 @@ const plugin = {
373
410
  });
374
411
  setCachedResolve(cacheKey, {
375
412
  identityId: r.identityId,
376
- accessAllowed: r.accessAllowed,
377
- status: r.status
413
+ status: r.status,
414
+ permissions: [...r.permissions, ...forwarded]
378
415
  });
379
416
  if (r.identityId == null) {
380
417
  log.warn(`Identity not provisioned for ${provider}:${senderId} — blocking inbound message`);
@@ -383,9 +420,10 @@ const plugin = {
383
420
  blockReason: "Identity: identity not provisioned for this channel"
384
421
  };
385
422
  }
386
- if (!r.accessAllowed) return {
423
+ const cachedAfterFill = getCachedResolve(cacheKey);
424
+ if (!evaluateAgentChat(cachedAfterFill ? [...cachedAfterFill.permissions] : [...r.permissions, ...forwarded], { agentId }).allowed) return {
387
425
  block: true,
388
- blockReason: "Identity: access denied for this sender"
426
+ blockReason: "Identity: chat not permitted for this sender"
389
427
  };
390
428
  log.info(`Identity resolved: ${senderId} → ${r.identityId} (${r.status})`);
391
429
  } catch (e) {
@@ -405,7 +443,25 @@ const plugin = {
405
443
  }), resolveToolGatingMode(), event.toolName);
406
444
  return Promise.resolve(blocked);
407
445
  }
408
- return Promise.resolve(void 0);
446
+ const cacheKey = ctx.conversationId ?? (ctx.channelId && ctx.actingIdentityId ? `${ctx.channelId}:${ctx.actingIdentityId}` : null);
447
+ if (cacheKey) {
448
+ const cached = getCachedResolve(cacheKey);
449
+ if (cached && cached.permissions.length > 0) {
450
+ const blocked = applyGateMode(evaluateAgentExec([...cached.permissions], {
451
+ agentId: ctx.agentId,
452
+ toolName: event.toolName,
453
+ toolArgs: event.toolArgs
454
+ }), resolveToolGatingMode(), event.toolName);
455
+ return Promise.resolve(blocked);
456
+ }
457
+ }
458
+ const fallback = evaluateAgentExec([], {
459
+ agentId: ctx.agentId,
460
+ toolName: event.toolName,
461
+ toolArgs: event.toolArgs
462
+ });
463
+ const mode = resolveToolGatingMode();
464
+ return Promise.resolve(applyGateMode(fallback, mode, event.toolName));
409
465
  };
410
466
  api.on("before_tool_call", (...args) => beforeToolCallStub(args[0], args[1]), { priority: 100 });
411
467
  log.info("Alfe Identity plugin activated");
@@ -7,5 +7,21 @@
7
7
  "type": "object",
8
8
  "additionalProperties": false,
9
9
  "properties": {}
10
+ },
11
+ "activation": { "onStartup": true },
12
+ "contracts": {
13
+ "tools": [
14
+ "who_is_this",
15
+ "lookup_identity",
16
+ "tag_identity",
17
+ "add_identity_note",
18
+ "update_identity",
19
+ "merge_identities",
20
+ "unmerge_identities",
21
+ "request_identity_verification",
22
+ "confirm_identity_verification",
23
+ "get_identity_changelog",
24
+ "rollback_identity"
25
+ ]
10
26
  }
11
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-identity",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "OpenClaw identity plugin — identity resolution, access gating, permission enforcement",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",