@poolzin/pool-bot 2026.3.6 → 2026.3.7
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/agents/pi-tools.js +32 -2
- package/dist/auto-reply/reply/get-reply.js +6 -0
- package/dist/auto-reply/reply/message-preprocess-hooks.js +17 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/banner.js +20 -1
- package/dist/cli/security-cli.js +211 -2
- package/dist/cli/tagline.js +7 -0
- package/dist/config/types.cli.js +1 -0
- package/dist/config/types.security.js +33 -0
- package/dist/config/zod-schema.js +15 -0
- package/dist/config/zod-schema.providers-core.js +1 -0
- package/dist/config/zod-schema.security.js +113 -0
- package/dist/discord/monitor/message-handler.preflight.js +11 -2
- package/dist/gateway/http-common.js +6 -1
- package/dist/hooks/fire-and-forget.js +6 -0
- package/dist/hooks/internal-hooks.js +64 -19
- package/dist/hooks/message-hook-mappers.js +179 -0
- package/dist/security/capability-guards.js +89 -0
- package/dist/security/capability-manager.js +76 -0
- package/dist/security/capability.js +147 -0
- package/dist/security/index.js +7 -0
- package/dist/security/middleware.js +105 -0
- package/dist/slack/monitor/context.js +1 -0
- package/dist/slack/monitor/message-handler/dispatch.js +14 -1
- package/dist/slack/monitor/provider.js +2 -0
- package/package.json +1 -1
|
@@ -5,8 +5,18 @@
|
|
|
5
5
|
* like command processing, session lifecycle, etc.
|
|
6
6
|
*/
|
|
7
7
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Registry of hook handlers by event key.
|
|
10
|
+
*
|
|
11
|
+
* Uses a globalThis singleton so that registerInternalHook and
|
|
12
|
+
* triggerInternalHook always share the same Map even when the bundler
|
|
13
|
+
* emits multiple copies of this module into separate chunks (bundle
|
|
14
|
+
* splitting). Without the singleton, handlers registered in one chunk
|
|
15
|
+
* are invisible to triggerInternalHook in another chunk, causing hooks
|
|
16
|
+
* to silently fire with zero handlers.
|
|
17
|
+
*/
|
|
18
|
+
const _g = globalThis;
|
|
19
|
+
const handlers = (_g.__poolbot_internal_hook_handlers__ ??= new Map());
|
|
10
20
|
const log = createSubsystemLogger("internal-hooks");
|
|
11
21
|
/**
|
|
12
22
|
* Register a hook handler for a specific event type or event:action combination
|
|
@@ -112,45 +122,80 @@ export function createInternalHookEvent(type, action, sessionKey, context = {})
|
|
|
112
122
|
messages: [],
|
|
113
123
|
};
|
|
114
124
|
}
|
|
125
|
+
function isHookEventTypeAndAction(event, type, action) {
|
|
126
|
+
return event.type === type && event.action === action;
|
|
127
|
+
}
|
|
128
|
+
function getHookContext(event) {
|
|
129
|
+
const context = event.context;
|
|
130
|
+
if (!context || typeof context !== "object") {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
function hasStringContextField(context, key) {
|
|
136
|
+
return typeof context[key] === "string";
|
|
137
|
+
}
|
|
138
|
+
function hasBooleanContextField(context, key) {
|
|
139
|
+
return typeof context[key] === "boolean";
|
|
140
|
+
}
|
|
115
141
|
export function isAgentBootstrapEvent(event) {
|
|
116
|
-
if (event
|
|
142
|
+
if (!isHookEventTypeAndAction(event, "agent", "bootstrap")) {
|
|
117
143
|
return false;
|
|
118
144
|
}
|
|
119
|
-
const context = event
|
|
120
|
-
if (!context
|
|
145
|
+
const context = getHookContext(event);
|
|
146
|
+
if (!context) {
|
|
121
147
|
return false;
|
|
122
148
|
}
|
|
123
|
-
if (
|
|
149
|
+
if (!hasStringContextField(context, "workspaceDir")) {
|
|
124
150
|
return false;
|
|
125
151
|
}
|
|
126
152
|
return Array.isArray(context.bootstrapFiles);
|
|
127
153
|
}
|
|
128
154
|
export function isGatewayStartupEvent(event) {
|
|
129
|
-
if (event
|
|
155
|
+
if (!isHookEventTypeAndAction(event, "gateway", "startup")) {
|
|
130
156
|
return false;
|
|
131
157
|
}
|
|
132
|
-
|
|
133
|
-
return Boolean(context && typeof context === "object");
|
|
158
|
+
return Boolean(getHookContext(event));
|
|
134
159
|
}
|
|
135
160
|
export function isMessageReceivedEvent(event) {
|
|
136
|
-
if (event
|
|
161
|
+
if (!isHookEventTypeAndAction(event, "message", "received")) {
|
|
137
162
|
return false;
|
|
138
163
|
}
|
|
139
|
-
const context = event
|
|
140
|
-
if (!context
|
|
164
|
+
const context = getHookContext(event);
|
|
165
|
+
if (!context) {
|
|
141
166
|
return false;
|
|
142
167
|
}
|
|
143
|
-
return
|
|
168
|
+
return hasStringContextField(context, "from") && hasStringContextField(context, "channelId");
|
|
144
169
|
}
|
|
145
170
|
export function isMessageSentEvent(event) {
|
|
146
|
-
if (event
|
|
171
|
+
if (!isHookEventTypeAndAction(event, "message", "sent")) {
|
|
147
172
|
return false;
|
|
148
173
|
}
|
|
149
|
-
const context = event
|
|
150
|
-
if (!context
|
|
174
|
+
const context = getHookContext(event);
|
|
175
|
+
if (!context) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return (hasStringContextField(context, "to") &&
|
|
179
|
+
hasStringContextField(context, "channelId") &&
|
|
180
|
+
hasBooleanContextField(context, "success"));
|
|
181
|
+
}
|
|
182
|
+
export function isMessageTranscribedEvent(event) {
|
|
183
|
+
if (!isHookEventTypeAndAction(event, "message", "transcribed")) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const context = getHookContext(event);
|
|
187
|
+
if (!context) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return (hasStringContextField(context, "transcript") && hasStringContextField(context, "channelId"));
|
|
191
|
+
}
|
|
192
|
+
export function isMessagePreprocessedEvent(event) {
|
|
193
|
+
if (!isHookEventTypeAndAction(event, "message", "preprocessed")) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const context = getHookContext(event);
|
|
197
|
+
if (!context) {
|
|
151
198
|
return false;
|
|
152
199
|
}
|
|
153
|
-
return (
|
|
154
|
-
typeof context.channelId === "string" &&
|
|
155
|
-
typeof context.success === "boolean");
|
|
200
|
+
return hasStringContextField(context, "channelId");
|
|
156
201
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export function deriveInboundMessageHookContext(ctx, overrides) {
|
|
2
|
+
const content = overrides?.content ??
|
|
3
|
+
(typeof ctx.BodyForCommands === "string"
|
|
4
|
+
? ctx.BodyForCommands
|
|
5
|
+
: typeof ctx.RawBody === "string"
|
|
6
|
+
? ctx.RawBody
|
|
7
|
+
: typeof ctx.Body === "string"
|
|
8
|
+
? ctx.Body
|
|
9
|
+
: "");
|
|
10
|
+
const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
|
|
11
|
+
const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
|
|
12
|
+
const isGroup = Boolean(ctx.GroupSubject || ctx.GroupChannel);
|
|
13
|
+
return {
|
|
14
|
+
from: ctx.From ?? "",
|
|
15
|
+
to: ctx.To,
|
|
16
|
+
content,
|
|
17
|
+
body: ctx.Body,
|
|
18
|
+
bodyForAgent: ctx.BodyForAgent,
|
|
19
|
+
transcript: ctx.Transcript,
|
|
20
|
+
timestamp: typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
|
|
21
|
+
? ctx.Timestamp
|
|
22
|
+
: undefined,
|
|
23
|
+
channelId,
|
|
24
|
+
accountId: ctx.AccountId,
|
|
25
|
+
conversationId,
|
|
26
|
+
messageId: overrides?.messageId ??
|
|
27
|
+
ctx.MessageSidFull ??
|
|
28
|
+
ctx.MessageSid ??
|
|
29
|
+
ctx.MessageSidFirst ??
|
|
30
|
+
ctx.MessageSidLast,
|
|
31
|
+
senderId: ctx.SenderId,
|
|
32
|
+
senderName: ctx.SenderName,
|
|
33
|
+
senderUsername: ctx.SenderUsername,
|
|
34
|
+
senderE164: ctx.SenderE164,
|
|
35
|
+
provider: ctx.Provider,
|
|
36
|
+
surface: ctx.Surface,
|
|
37
|
+
threadId: ctx.MessageThreadId,
|
|
38
|
+
mediaPath: ctx.MediaPath,
|
|
39
|
+
mediaType: ctx.MediaType,
|
|
40
|
+
originatingChannel: ctx.OriginatingChannel,
|
|
41
|
+
originatingTo: ctx.OriginatingTo,
|
|
42
|
+
guildId: ctx.GroupSpace,
|
|
43
|
+
channelName: ctx.GroupChannel,
|
|
44
|
+
isGroup,
|
|
45
|
+
groupId: isGroup ? conversationId : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function buildCanonicalSentMessageHookContext(params) {
|
|
49
|
+
return {
|
|
50
|
+
to: params.to,
|
|
51
|
+
content: params.content,
|
|
52
|
+
success: params.success,
|
|
53
|
+
error: params.error,
|
|
54
|
+
channelId: params.channelId,
|
|
55
|
+
accountId: params.accountId,
|
|
56
|
+
conversationId: params.conversationId ?? params.to,
|
|
57
|
+
messageId: params.messageId,
|
|
58
|
+
isGroup: params.isGroup,
|
|
59
|
+
groupId: params.groupId,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function toPluginMessageContext(canonical) {
|
|
63
|
+
return {
|
|
64
|
+
channelId: canonical.channelId,
|
|
65
|
+
accountId: canonical.accountId,
|
|
66
|
+
conversationId: canonical.conversationId,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function toPluginMessageReceivedEvent(canonical) {
|
|
70
|
+
return {
|
|
71
|
+
from: canonical.from,
|
|
72
|
+
content: canonical.content,
|
|
73
|
+
timestamp: canonical.timestamp,
|
|
74
|
+
metadata: {
|
|
75
|
+
to: canonical.to,
|
|
76
|
+
provider: canonical.provider,
|
|
77
|
+
surface: canonical.surface,
|
|
78
|
+
threadId: canonical.threadId,
|
|
79
|
+
originatingChannel: canonical.originatingChannel,
|
|
80
|
+
originatingTo: canonical.originatingTo,
|
|
81
|
+
messageId: canonical.messageId,
|
|
82
|
+
senderId: canonical.senderId,
|
|
83
|
+
senderName: canonical.senderName,
|
|
84
|
+
senderUsername: canonical.senderUsername,
|
|
85
|
+
senderE164: canonical.senderE164,
|
|
86
|
+
guildId: canonical.guildId,
|
|
87
|
+
channelName: canonical.channelName,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function toPluginMessageSentEvent(canonical) {
|
|
92
|
+
return {
|
|
93
|
+
to: canonical.to,
|
|
94
|
+
content: canonical.content,
|
|
95
|
+
success: canonical.success,
|
|
96
|
+
...(canonical.error ? { error: canonical.error } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export function toInternalMessageReceivedContext(canonical) {
|
|
100
|
+
return {
|
|
101
|
+
from: canonical.from,
|
|
102
|
+
content: canonical.content,
|
|
103
|
+
timestamp: canonical.timestamp,
|
|
104
|
+
channelId: canonical.channelId,
|
|
105
|
+
accountId: canonical.accountId,
|
|
106
|
+
conversationId: canonical.conversationId,
|
|
107
|
+
messageId: canonical.messageId,
|
|
108
|
+
metadata: {
|
|
109
|
+
to: canonical.to,
|
|
110
|
+
provider: canonical.provider,
|
|
111
|
+
surface: canonical.surface,
|
|
112
|
+
threadId: canonical.threadId,
|
|
113
|
+
senderId: canonical.senderId,
|
|
114
|
+
senderName: canonical.senderName,
|
|
115
|
+
senderUsername: canonical.senderUsername,
|
|
116
|
+
senderE164: canonical.senderE164,
|
|
117
|
+
guildId: canonical.guildId,
|
|
118
|
+
channelName: canonical.channelName,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function toInternalMessageTranscribedContext(canonical, cfg) {
|
|
123
|
+
return {
|
|
124
|
+
from: canonical.from,
|
|
125
|
+
to: canonical.to,
|
|
126
|
+
body: canonical.body,
|
|
127
|
+
bodyForAgent: canonical.bodyForAgent,
|
|
128
|
+
transcript: canonical.transcript ?? "",
|
|
129
|
+
timestamp: canonical.timestamp,
|
|
130
|
+
channelId: canonical.channelId,
|
|
131
|
+
conversationId: canonical.conversationId,
|
|
132
|
+
messageId: canonical.messageId,
|
|
133
|
+
senderId: canonical.senderId,
|
|
134
|
+
senderName: canonical.senderName,
|
|
135
|
+
senderUsername: canonical.senderUsername,
|
|
136
|
+
provider: canonical.provider,
|
|
137
|
+
surface: canonical.surface,
|
|
138
|
+
mediaPath: canonical.mediaPath,
|
|
139
|
+
mediaType: canonical.mediaType,
|
|
140
|
+
cfg,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function toInternalMessagePreprocessedContext(canonical, cfg) {
|
|
144
|
+
return {
|
|
145
|
+
from: canonical.from,
|
|
146
|
+
to: canonical.to,
|
|
147
|
+
body: canonical.body,
|
|
148
|
+
bodyForAgent: canonical.bodyForAgent,
|
|
149
|
+
transcript: canonical.transcript,
|
|
150
|
+
timestamp: canonical.timestamp,
|
|
151
|
+
channelId: canonical.channelId,
|
|
152
|
+
conversationId: canonical.conversationId,
|
|
153
|
+
messageId: canonical.messageId,
|
|
154
|
+
senderId: canonical.senderId,
|
|
155
|
+
senderName: canonical.senderName,
|
|
156
|
+
senderUsername: canonical.senderUsername,
|
|
157
|
+
provider: canonical.provider,
|
|
158
|
+
surface: canonical.surface,
|
|
159
|
+
mediaPath: canonical.mediaPath,
|
|
160
|
+
mediaType: canonical.mediaType,
|
|
161
|
+
isGroup: canonical.isGroup,
|
|
162
|
+
groupId: canonical.groupId,
|
|
163
|
+
cfg,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export function toInternalMessageSentContext(canonical) {
|
|
167
|
+
return {
|
|
168
|
+
to: canonical.to,
|
|
169
|
+
content: canonical.content,
|
|
170
|
+
success: canonical.success,
|
|
171
|
+
...(canonical.error ? { error: canonical.error } : {}),
|
|
172
|
+
channelId: canonical.channelId,
|
|
173
|
+
accountId: canonical.accountId,
|
|
174
|
+
conversationId: canonical.conversationId,
|
|
175
|
+
messageId: canonical.messageId,
|
|
176
|
+
...(canonical.isGroup != null ? { isGroup: canonical.isGroup } : {}),
|
|
177
|
+
...(canonical.groupId ? { groupId: canonical.groupId } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { getCapabilityManager } from "./capability-manager.js";
|
|
2
|
+
/** Error thrown when a capability check fails. */
|
|
3
|
+
export class CapabilityError extends Error {
|
|
4
|
+
agentId;
|
|
5
|
+
required;
|
|
6
|
+
constructor(message, agentId, required) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.agentId = agentId;
|
|
9
|
+
this.required = required;
|
|
10
|
+
this.name = "CapabilityError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Guard function that throws if capability is denied. */
|
|
14
|
+
export function requireCapability(agentId, required) {
|
|
15
|
+
const manager = getCapabilityManager();
|
|
16
|
+
const check = manager.check(agentId, required);
|
|
17
|
+
if (!check.granted) {
|
|
18
|
+
throw new CapabilityError(check.reason, agentId, required);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Async guard that returns a promise rejection if denied. */
|
|
22
|
+
export async function withCapability(agentId, required, fn) {
|
|
23
|
+
requireCapability(agentId, required);
|
|
24
|
+
return await fn();
|
|
25
|
+
}
|
|
26
|
+
/** Helper to check file read capability. */
|
|
27
|
+
export function checkFileRead(agentId, path) {
|
|
28
|
+
return getCapabilityManager().check(agentId, {
|
|
29
|
+
type: "file:read",
|
|
30
|
+
pattern: path,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/** Helper to check file write capability. */
|
|
34
|
+
export function checkFileWrite(agentId, path) {
|
|
35
|
+
return getCapabilityManager().check(agentId, {
|
|
36
|
+
type: "file:write",
|
|
37
|
+
pattern: path,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Helper to check tool invocation capability. */
|
|
41
|
+
export function checkToolInvoke(agentId, toolId) {
|
|
42
|
+
return getCapabilityManager().check(agentId, {
|
|
43
|
+
type: "tool:invoke",
|
|
44
|
+
toolId,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Helper to check network connection capability. */
|
|
48
|
+
export function checkNetConnect(agentId, host) {
|
|
49
|
+
return getCapabilityManager().check(agentId, {
|
|
50
|
+
type: "net:connect",
|
|
51
|
+
pattern: host,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Helper to check shell execution capability. */
|
|
55
|
+
export function checkShellExec(agentId, command) {
|
|
56
|
+
return getCapabilityManager().check(agentId, {
|
|
57
|
+
type: "shell:exec",
|
|
58
|
+
pattern: command,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Helper to check LLM query capability. */
|
|
62
|
+
export function checkLlmQuery(agentId, model) {
|
|
63
|
+
return getCapabilityManager().check(agentId, {
|
|
64
|
+
type: "llm:query",
|
|
65
|
+
pattern: model,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** Helper to check agent spawn capability. */
|
|
69
|
+
export function checkAgentSpawn(agentId) {
|
|
70
|
+
return getCapabilityManager().check(agentId, { type: "agent:spawn" });
|
|
71
|
+
}
|
|
72
|
+
/** Helper to check memory read capability. */
|
|
73
|
+
export function checkMemoryRead(agentId, scope) {
|
|
74
|
+
return getCapabilityManager().check(agentId, {
|
|
75
|
+
type: "memory:read",
|
|
76
|
+
scope,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Helper to check memory write capability. */
|
|
80
|
+
export function checkMemoryWrite(agentId, scope) {
|
|
81
|
+
return getCapabilityManager().check(agentId, {
|
|
82
|
+
type: "memory:write",
|
|
83
|
+
scope,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Helper to check gateway admin capability. */
|
|
87
|
+
export function checkGatewayAdmin(agentId) {
|
|
88
|
+
return getCapabilityManager().check(agentId, { type: "gateway:admin" });
|
|
89
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { capabilityMatches, CapabilityResult } from "./capability.js";
|
|
2
|
+
/**
|
|
3
|
+
* Manages capability grants for all agents.
|
|
4
|
+
* Uses a Map for O(1) lookups. Thread-safe for single-threaded Node.js.
|
|
5
|
+
*/
|
|
6
|
+
export class CapabilityManager {
|
|
7
|
+
grants = new Map();
|
|
8
|
+
/** Grant capabilities to an agent. Replaces any existing grants. */
|
|
9
|
+
grant(agentId, capabilities) {
|
|
10
|
+
this.grants.set(agentId, capabilities);
|
|
11
|
+
}
|
|
12
|
+
/** Add capabilities to an agent's existing grants. */
|
|
13
|
+
add(agentId, capabilities) {
|
|
14
|
+
const existing = this.grants.get(agentId) ?? [];
|
|
15
|
+
this.grants.set(agentId, [...existing, ...capabilities]);
|
|
16
|
+
}
|
|
17
|
+
/** Check whether an agent has a specific capability. */
|
|
18
|
+
check(agentId, required) {
|
|
19
|
+
const grants = this.grants.get(agentId);
|
|
20
|
+
if (!grants) {
|
|
21
|
+
return CapabilityResult.denied(`No capabilities registered for agent ${agentId}`);
|
|
22
|
+
}
|
|
23
|
+
for (const granted of grants) {
|
|
24
|
+
if (capabilityMatches(granted, required)) {
|
|
25
|
+
return CapabilityResult.granted();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return CapabilityResult.denied(`Agent ${agentId} does not have capability: ${JSON.stringify(required)}`);
|
|
29
|
+
}
|
|
30
|
+
/** Check multiple capabilities at once. Returns first denial or grants all. */
|
|
31
|
+
checkAll(agentId, required) {
|
|
32
|
+
for (const req of required) {
|
|
33
|
+
const result = this.check(agentId, req);
|
|
34
|
+
if (!result.granted)
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
return CapabilityResult.granted();
|
|
38
|
+
}
|
|
39
|
+
/** List all capabilities for an agent. */
|
|
40
|
+
list(agentId) {
|
|
41
|
+
return this.grants.get(agentId) ?? [];
|
|
42
|
+
}
|
|
43
|
+
/** Remove all capabilities for an agent. */
|
|
44
|
+
revokeAll(agentId) {
|
|
45
|
+
this.grants.delete(agentId);
|
|
46
|
+
}
|
|
47
|
+
/** Check if an agent has any capabilities registered. */
|
|
48
|
+
has(agentId) {
|
|
49
|
+
return this.grants.has(agentId);
|
|
50
|
+
}
|
|
51
|
+
/** Get all registered agent IDs. */
|
|
52
|
+
agents() {
|
|
53
|
+
return Array.from(this.grants.keys());
|
|
54
|
+
}
|
|
55
|
+
/** Clear all grants. */
|
|
56
|
+
clear() {
|
|
57
|
+
this.grants.clear();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Global singleton instance. */
|
|
61
|
+
let globalManager;
|
|
62
|
+
/** Get or create the global capability manager. */
|
|
63
|
+
export function getCapabilityManager() {
|
|
64
|
+
if (!globalManager) {
|
|
65
|
+
globalManager = new CapabilityManager();
|
|
66
|
+
}
|
|
67
|
+
return globalManager;
|
|
68
|
+
}
|
|
69
|
+
/** Reset the global manager (useful for testing). */
|
|
70
|
+
export function resetCapabilityManager() {
|
|
71
|
+
globalManager = undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Set a custom global manager (useful for testing). */
|
|
74
|
+
export function setCapabilityManager(manager) {
|
|
75
|
+
globalManager = manager;
|
|
76
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-based security system for Pool Bot.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by OpenFang's capability system, adapted for TypeScript.
|
|
5
|
+
* Agents can only perform actions they've been explicitly granted permission for.
|
|
6
|
+
* Capabilities are immutable after agent creation and enforced at runtime.
|
|
7
|
+
*/
|
|
8
|
+
/** All available capability types for CLI and validation. */
|
|
9
|
+
export const CAPABILITY_TYPES = [
|
|
10
|
+
// File system
|
|
11
|
+
"file:read",
|
|
12
|
+
"file:write",
|
|
13
|
+
// Network
|
|
14
|
+
"net:connect",
|
|
15
|
+
"net:listen",
|
|
16
|
+
// Tools
|
|
17
|
+
"tool:invoke",
|
|
18
|
+
"tool:all",
|
|
19
|
+
// LLM
|
|
20
|
+
"llm:query",
|
|
21
|
+
"llm:maxTokens",
|
|
22
|
+
// Agent interaction
|
|
23
|
+
"agent:spawn",
|
|
24
|
+
"agent:message",
|
|
25
|
+
"agent:kill",
|
|
26
|
+
// Memory
|
|
27
|
+
"memory:read",
|
|
28
|
+
"memory:write",
|
|
29
|
+
// Shell
|
|
30
|
+
"shell:exec",
|
|
31
|
+
"env:read",
|
|
32
|
+
// Gateway
|
|
33
|
+
"gateway:admin",
|
|
34
|
+
"gateway:channels:read",
|
|
35
|
+
"gateway:channels:write",
|
|
36
|
+
// Economic
|
|
37
|
+
"econ:spend",
|
|
38
|
+
"econ:earn",
|
|
39
|
+
"econ:transfer",
|
|
40
|
+
];
|
|
41
|
+
/** Helper to create capability check results. */
|
|
42
|
+
export const CapabilityResult = {
|
|
43
|
+
granted() {
|
|
44
|
+
return { granted: true };
|
|
45
|
+
},
|
|
46
|
+
denied(reason) {
|
|
47
|
+
return { granted: false, reason };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
/** Simple glob pattern matching supporting '*' as wildcard. */
|
|
51
|
+
export function globMatches(pattern, value) {
|
|
52
|
+
if (pattern === "*")
|
|
53
|
+
return true;
|
|
54
|
+
if (pattern === value)
|
|
55
|
+
return true;
|
|
56
|
+
// Prefix wildcard: "*.example.com"
|
|
57
|
+
if (pattern.startsWith("*")) {
|
|
58
|
+
const suffix = pattern.slice(1);
|
|
59
|
+
return value.endsWith(suffix);
|
|
60
|
+
}
|
|
61
|
+
// Suffix wildcard: "api.*"
|
|
62
|
+
if (pattern.endsWith("*")) {
|
|
63
|
+
const prefix = pattern.slice(0, -1);
|
|
64
|
+
return value.startsWith(prefix);
|
|
65
|
+
}
|
|
66
|
+
// Middle wildcard: "api.*.com"
|
|
67
|
+
const starPos = pattern.indexOf("*");
|
|
68
|
+
if (starPos !== -1) {
|
|
69
|
+
const prefix = pattern.slice(0, starPos);
|
|
70
|
+
const suffix = pattern.slice(starPos + 1);
|
|
71
|
+
return (value.startsWith(prefix) &&
|
|
72
|
+
value.endsWith(suffix) &&
|
|
73
|
+
value.length >= prefix.length + suffix.length);
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Check whether a required capability matches any granted capability.
|
|
79
|
+
*/
|
|
80
|
+
export function capabilityMatches(granted, required) {
|
|
81
|
+
// Tool:all grants any specific tool
|
|
82
|
+
if (granted.type === "tool:all" && required.type === "tool:invoke") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
// Same variant type matching
|
|
86
|
+
if (granted.type !== required.type)
|
|
87
|
+
return false;
|
|
88
|
+
switch (granted.type) {
|
|
89
|
+
case "file:read":
|
|
90
|
+
case "file:write":
|
|
91
|
+
return globMatches(granted.pattern, required.pattern);
|
|
92
|
+
case "net:connect":
|
|
93
|
+
return globMatches(granted.pattern, required.pattern);
|
|
94
|
+
case "net:listen":
|
|
95
|
+
return granted.port === required.port;
|
|
96
|
+
case "tool:invoke":
|
|
97
|
+
return granted.toolId === required.toolId || granted.toolId === "*";
|
|
98
|
+
case "llm:query":
|
|
99
|
+
return globMatches(granted.pattern, required.pattern);
|
|
100
|
+
case "llm:maxTokens":
|
|
101
|
+
return granted.limit >= required.limit;
|
|
102
|
+
case "agent:spawn":
|
|
103
|
+
return true;
|
|
104
|
+
case "agent:message":
|
|
105
|
+
case "agent:kill":
|
|
106
|
+
return globMatches(granted.pattern, required.pattern);
|
|
107
|
+
case "memory:read":
|
|
108
|
+
case "memory:write":
|
|
109
|
+
return globMatches(granted.scope, required.scope);
|
|
110
|
+
case "shell:exec":
|
|
111
|
+
return globMatches(granted.pattern, required.pattern);
|
|
112
|
+
case "env:read":
|
|
113
|
+
return globMatches(granted.pattern, required.pattern);
|
|
114
|
+
case "gateway:admin":
|
|
115
|
+
case "gateway:channels:read":
|
|
116
|
+
return true;
|
|
117
|
+
case "gateway:channels:write":
|
|
118
|
+
return globMatches(granted.pattern, required.pattern);
|
|
119
|
+
case "econ:spend":
|
|
120
|
+
return granted.limit >= required.limit;
|
|
121
|
+
case "econ:earn":
|
|
122
|
+
return true;
|
|
123
|
+
case "econ:transfer":
|
|
124
|
+
return globMatches(granted.pattern, required.pattern);
|
|
125
|
+
case "tool:all":
|
|
126
|
+
// tool:all only matches tool:all (already handled above for tool:invoke)
|
|
127
|
+
return false;
|
|
128
|
+
default:
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Validate that child capabilities are a subset of parent capabilities.
|
|
134
|
+
* Prevents privilege escalation.
|
|
135
|
+
*/
|
|
136
|
+
export function validateCapabilityInheritance(parentCaps, childCaps) {
|
|
137
|
+
for (const childCap of childCaps) {
|
|
138
|
+
const isCovered = parentCaps.some((parentCap) => capabilityMatches(parentCap, childCap));
|
|
139
|
+
if (!isCovered) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
reason: `Privilege escalation denied: child requests ${JSON.stringify(childCap)} but parent does not have a matching grant`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { valid: true };
|
|
147
|
+
}
|