@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 +26 -1
- package/dist/index.d.cts +146 -1
- package/dist/index.d.ts +146 -1
- package/dist/index.js +21 -1
- package/dist/plugin.cjs +104 -173
- package/dist/plugin.js +104 -173
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
*
|
|
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 →
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
const
|
|
22
|
-
function
|
|
23
|
-
const entry =
|
|
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
|
-
|
|
29
|
+
resolveCache.delete(key);
|
|
27
30
|
return null;
|
|
28
31
|
}
|
|
29
|
-
return entry
|
|
32
|
+
return entry;
|
|
30
33
|
}
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
value,
|
|
34
|
-
expiresAt: Date.now() +
|
|
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
|
|
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
|
|
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
|
-
|
|
104
|
-
platformId: _sinclair_typebox.Type.String({ description: "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
242
|
-
description: "
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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) =>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
requestingPlatformId: _sinclair_typebox.Type.String({ description: "Requester's
|
|
276
|
-
|
|
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) =>
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ?? `${
|
|
311
|
-
const cached =
|
|
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
|
|
321
|
-
|
|
288
|
+
const r = await client.resolveIdentity({
|
|
289
|
+
provider,
|
|
322
290
|
platformId: senderId
|
|
323
291
|
});
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}),
|
|
329
|
-
accessAllowed: resolveResult.accessAllowed
|
|
292
|
+
setCachedResolve(cacheKey, {
|
|
293
|
+
identityId: r.identityId,
|
|
294
|
+
accessAllowed: r.accessAllowed,
|
|
295
|
+
status: r.status
|
|
330
296
|
});
|
|
331
|
-
if (
|
|
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} → ${
|
|
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:
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
10
|
-
*
|
|
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 →
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
22
|
-
const
|
|
23
|
-
function
|
|
24
|
-
const entry =
|
|
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
|
-
|
|
30
|
+
resolveCache.delete(key);
|
|
28
31
|
return null;
|
|
29
32
|
}
|
|
30
|
-
return entry
|
|
33
|
+
return entry;
|
|
31
34
|
}
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
value,
|
|
35
|
-
expiresAt: Date.now() +
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
-
platformId: Type.String({ description: "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
243
|
-
description: "
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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) =>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
276
|
-
requestingPlatformId: Type.String({ description: "Requester's
|
|
277
|
-
|
|
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) =>
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ?? `${
|
|
312
|
-
const cached =
|
|
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
|
|
322
|
-
|
|
289
|
+
const r = await client.resolveIdentity({
|
|
290
|
+
provider,
|
|
323
291
|
platformId: senderId
|
|
324
292
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}),
|
|
330
|
-
accessAllowed: resolveResult.accessAllowed
|
|
293
|
+
setCachedResolve(cacheKey, {
|
|
294
|
+
identityId: r.identityId,
|
|
295
|
+
accessAllowed: r.accessAllowed,
|
|
296
|
+
status: r.status
|
|
331
297
|
});
|
|
332
|
-
if (
|
|
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} → ${
|
|
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:
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|