@poolzin/pool-bot 2026.2.10 → 2026.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/agents/auth-profiles/usage.js +22 -0
- package/dist/agents/auth-profiles.js +1 -1
- package/dist/agents/bash-tools.exec.js +4 -6
- package/dist/agents/glob-pattern.js +42 -0
- package/dist/agents/memory-search.js +33 -0
- package/dist/agents/model-fallback.js +59 -8
- package/dist/agents/pi-tools.before-tool-call.js +145 -4
- package/dist/agents/pi-tools.js +27 -9
- package/dist/agents/pi-tools.policy.js +85 -92
- package/dist/agents/pi-tools.schema.js +54 -27
- package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
- package/dist/agents/sandbox-tool-policy.js +26 -0
- package/dist/agents/sanitize-for-prompt.js +18 -0
- package/dist/agents/session-write-lock.js +203 -39
- package/dist/agents/system-prompt.js +52 -10
- package/dist/agents/tool-loop-detection.js +466 -0
- package/dist/agents/tool-policy.js +6 -0
- package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
- package/dist/auto-reply/reply/post-compaction-context.js +98 -0
- package/dist/build-info.json +3 -3
- package/dist/config/zod-schema.agent-defaults.js +14 -0
- package/dist/config/zod-schema.agent-runtime.js +14 -0
- package/dist/infra/path-safety.js +16 -0
- package/dist/logging/diagnostic-session-state.js +73 -0
- package/dist/logging/diagnostic.js +22 -0
- package/dist/memory/embeddings.js +36 -9
- package/dist/memory/hybrid.js +24 -5
- package/dist/memory/manager.js +76 -28
- package/dist/memory/mmr.js +164 -0
- package/dist/memory/query-expansion.js +331 -0
- package/dist/memory/temporal-decay.js +119 -0
- package/dist/process/kill-tree.js +98 -0
- package/dist/shared/pid-alive.js +12 -0
- package/dist/shared/process-scoped-map.js +10 -0
- package/extensions/bluebubbles/package.json +1 -1
- package/extensions/copilot-proxy/package.json +1 -1
- package/extensions/diagnostics-otel/package.json +1 -1
- package/extensions/discord/package.json +1 -1
- package/extensions/google-antigravity-auth/package.json +1 -1
- package/extensions/google-gemini-cli-auth/package.json +1 -1
- package/extensions/googlechat/package.json +1 -1
- package/extensions/imessage/package.json +1 -1
- package/extensions/line/package.json +1 -1
- package/extensions/llm-task/package.json +1 -1
- package/extensions/lobster/package.json +1 -1
- package/extensions/matrix/CHANGELOG.md +5 -0
- package/extensions/matrix/package.json +1 -1
- package/extensions/mattermost/package.json +1 -1
- package/extensions/memory-core/package.json +1 -1
- package/extensions/memory-lancedb/package.json +1 -1
- package/extensions/msteams/CHANGELOG.md +5 -0
- package/extensions/msteams/package.json +1 -1
- package/extensions/nextcloud-talk/package.json +1 -1
- package/extensions/nostr/CHANGELOG.md +5 -0
- package/extensions/nostr/package.json +1 -1
- package/extensions/open-prose/package.json +1 -1
- package/extensions/signal/package.json +1 -1
- package/extensions/slack/package.json +1 -1
- package/extensions/telegram/package.json +1 -1
- package/extensions/tlon/package.json +1 -1
- package/extensions/twitch/CHANGELOG.md +5 -0
- package/extensions/twitch/package.json +1 -1
- package/extensions/voice-call/CHANGELOG.md +5 -0
- package/extensions/voice-call/package.json +1 -1
- package/extensions/whatsapp/package.json +1 -1
- package/extensions/zalo/CHANGELOG.md +5 -0
- package/extensions/zalo/package.json +1 -1
- package/extensions/zalouser/CHANGELOG.md +5 -0
- package/extensions/zalouser/package.json +1 -1
- package/package.json +1 -1
|
@@ -1,63 +1,42 @@
|
|
|
1
1
|
import { getChannelDock } from "../channels/dock.js";
|
|
2
2
|
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
|
|
3
|
+
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
|
|
4
|
+
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
|
3
5
|
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
|
|
6
|
+
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
|
|
7
|
+
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
|
|
4
8
|
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
|
|
5
|
-
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
|
6
|
-
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
|
|
7
|
-
function compilePattern(pattern) {
|
|
8
|
-
const normalized = normalizeToolName(pattern);
|
|
9
|
-
if (!normalized)
|
|
10
|
-
return { kind: "exact", value: "" };
|
|
11
|
-
if (normalized === "*")
|
|
12
|
-
return { kind: "all" };
|
|
13
|
-
if (!normalized.includes("*"))
|
|
14
|
-
return { kind: "exact", value: normalized };
|
|
15
|
-
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16
|
-
return {
|
|
17
|
-
kind: "regex",
|
|
18
|
-
value: new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`),
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
function compilePatterns(patterns) {
|
|
22
|
-
if (!Array.isArray(patterns))
|
|
23
|
-
return [];
|
|
24
|
-
return expandToolGroups(patterns)
|
|
25
|
-
.map(compilePattern)
|
|
26
|
-
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
|
|
27
|
-
}
|
|
28
|
-
function matchesAny(name, patterns) {
|
|
29
|
-
for (const pattern of patterns) {
|
|
30
|
-
if (pattern.kind === "all")
|
|
31
|
-
return true;
|
|
32
|
-
if (pattern.kind === "exact" && name === pattern.value)
|
|
33
|
-
return true;
|
|
34
|
-
if (pattern.kind === "regex" && pattern.value.test(name))
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
9
|
function makeToolPolicyMatcher(policy) {
|
|
40
|
-
const deny =
|
|
41
|
-
|
|
10
|
+
const deny = compileGlobPatterns({
|
|
11
|
+
raw: expandToolGroups(policy.deny ?? []),
|
|
12
|
+
normalize: normalizeToolName,
|
|
13
|
+
});
|
|
14
|
+
const allow = compileGlobPatterns({
|
|
15
|
+
raw: expandToolGroups(policy.allow ?? []),
|
|
16
|
+
normalize: normalizeToolName,
|
|
17
|
+
});
|
|
42
18
|
return (name) => {
|
|
43
19
|
const normalized = normalizeToolName(name);
|
|
44
|
-
if (
|
|
20
|
+
if (matchesAnyGlobPattern(normalized, deny)) {
|
|
45
21
|
return false;
|
|
46
|
-
|
|
22
|
+
}
|
|
23
|
+
if (allow.length === 0) {
|
|
47
24
|
return true;
|
|
48
|
-
|
|
25
|
+
}
|
|
26
|
+
if (matchesAnyGlobPattern(normalized, allow)) {
|
|
49
27
|
return true;
|
|
50
|
-
|
|
28
|
+
}
|
|
29
|
+
if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) {
|
|
51
30
|
return true;
|
|
31
|
+
}
|
|
52
32
|
return false;
|
|
53
33
|
};
|
|
54
34
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"sessions_spawn",
|
|
35
|
+
/**
|
|
36
|
+
* Tools always denied for sub-agents regardless of depth.
|
|
37
|
+
* These are system-level or interactive tools that sub-agents should never use.
|
|
38
|
+
*/
|
|
39
|
+
const SUBAGENT_TOOL_DENY_ALWAYS = [
|
|
61
40
|
// System admin - dangerous from subagent
|
|
62
41
|
"gateway",
|
|
63
42
|
"agents_list",
|
|
@@ -69,85 +48,95 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
|
|
|
69
48
|
// Memory - pass relevant info in spawn prompt instead
|
|
70
49
|
"memory_search",
|
|
71
50
|
"memory_get",
|
|
51
|
+
// Direct session sends - subagents communicate through announce chain
|
|
52
|
+
"sessions_send",
|
|
72
53
|
];
|
|
73
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Additional tools denied for leaf sub-agents (depth >= maxSpawnDepth).
|
|
56
|
+
* These are tools that only make sense for orchestrator sub-agents that can spawn children.
|
|
57
|
+
*/
|
|
58
|
+
const SUBAGENT_TOOL_DENY_LEAF = ["sessions_list", "sessions_history", "sessions_spawn"];
|
|
59
|
+
/**
|
|
60
|
+
* Build the deny list for a sub-agent at a given depth.
|
|
61
|
+
*
|
|
62
|
+
* - Depth 1 with maxSpawnDepth >= 2 (orchestrator): allowed to use sessions_spawn,
|
|
63
|
+
* subagents, sessions_list, sessions_history so it can manage its children.
|
|
64
|
+
* - Depth >= maxSpawnDepth (leaf): denied sessions_spawn and
|
|
65
|
+
* session management tools. Still allowed subagents (for list/status visibility).
|
|
66
|
+
*/
|
|
67
|
+
function resolveSubagentDenyList(depth, maxSpawnDepth) {
|
|
68
|
+
const isLeaf = depth >= Math.max(1, Math.floor(maxSpawnDepth));
|
|
69
|
+
if (isLeaf) {
|
|
70
|
+
return [...SUBAGENT_TOOL_DENY_ALWAYS, ...SUBAGENT_TOOL_DENY_LEAF];
|
|
71
|
+
}
|
|
72
|
+
// Orchestrator sub-agent: only deny the always-denied tools.
|
|
73
|
+
// sessions_spawn, subagents, sessions_list, sessions_history are allowed.
|
|
74
|
+
return [...SUBAGENT_TOOL_DENY_ALWAYS];
|
|
75
|
+
}
|
|
76
|
+
export function resolveSubagentToolPolicy(cfg, depth) {
|
|
74
77
|
const configured = cfg?.tools?.subagents?.tools;
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
];
|
|
78
|
+
const maxSpawnDepth = cfg?.agents?.defaults?.subagents?.maxSpawnDepth ?? 1;
|
|
79
|
+
const effectiveDepth = typeof depth === "number" && depth >= 0 ? depth : 1;
|
|
80
|
+
const baseDeny = resolveSubagentDenyList(effectiveDepth, maxSpawnDepth);
|
|
81
|
+
const deny = [...baseDeny, ...(Array.isArray(configured?.deny) ? configured.deny : [])];
|
|
79
82
|
const allow = Array.isArray(configured?.allow) ? configured.allow : undefined;
|
|
80
83
|
return { allow, deny };
|
|
81
84
|
}
|
|
82
85
|
export function isToolAllowedByPolicyName(name, policy) {
|
|
83
|
-
if (!policy)
|
|
86
|
+
if (!policy) {
|
|
84
87
|
return true;
|
|
88
|
+
}
|
|
85
89
|
return makeToolPolicyMatcher(policy)(name);
|
|
86
90
|
}
|
|
87
91
|
export function filterToolsByPolicy(tools, policy) {
|
|
88
|
-
if (!policy)
|
|
92
|
+
if (!policy) {
|
|
89
93
|
return tools;
|
|
94
|
+
}
|
|
90
95
|
const matcher = makeToolPolicyMatcher(policy);
|
|
91
96
|
return tools.filter((tool) => matcher(tool.name));
|
|
92
97
|
}
|
|
93
|
-
function unionAllow(base, extra) {
|
|
94
|
-
if (!Array.isArray(extra) || extra.length === 0)
|
|
95
|
-
return base;
|
|
96
|
-
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
|
|
97
|
-
// an implicit allow-all policy.
|
|
98
|
-
if (!Array.isArray(base) || base.length === 0) {
|
|
99
|
-
return Array.from(new Set(["*", ...extra]));
|
|
100
|
-
}
|
|
101
|
-
return Array.from(new Set([...base, ...extra]));
|
|
102
|
-
}
|
|
103
|
-
function pickToolPolicy(config) {
|
|
104
|
-
if (!config)
|
|
105
|
-
return undefined;
|
|
106
|
-
const allow = Array.isArray(config.allow)
|
|
107
|
-
? unionAllow(config.allow, config.alsoAllow)
|
|
108
|
-
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
|
|
109
|
-
? unionAllow(undefined, config.alsoAllow)
|
|
110
|
-
: undefined;
|
|
111
|
-
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
|
112
|
-
if (!allow && !deny)
|
|
113
|
-
return undefined;
|
|
114
|
-
return { allow, deny };
|
|
115
|
-
}
|
|
116
98
|
function normalizeProviderKey(value) {
|
|
117
99
|
return value.trim().toLowerCase();
|
|
118
100
|
}
|
|
119
101
|
function resolveGroupContextFromSessionKey(sessionKey) {
|
|
120
102
|
const raw = (sessionKey ?? "").trim();
|
|
121
|
-
if (!raw)
|
|
103
|
+
if (!raw) {
|
|
122
104
|
return {};
|
|
105
|
+
}
|
|
123
106
|
const base = resolveThreadParentSessionKey(raw) ?? raw;
|
|
124
107
|
const parts = base.split(":").filter(Boolean);
|
|
125
108
|
let body = parts[0] === "agent" ? parts.slice(2) : parts;
|
|
126
109
|
if (body[0] === "subagent") {
|
|
127
110
|
body = body.slice(1);
|
|
128
111
|
}
|
|
129
|
-
if (body.length < 3)
|
|
112
|
+
if (body.length < 3) {
|
|
130
113
|
return {};
|
|
114
|
+
}
|
|
131
115
|
const [channel, kind, ...rest] = body;
|
|
132
|
-
if (kind !== "group" && kind !== "channel")
|
|
116
|
+
if (kind !== "group" && kind !== "channel") {
|
|
133
117
|
return {};
|
|
118
|
+
}
|
|
134
119
|
const groupId = rest.join(":").trim();
|
|
135
|
-
if (!groupId)
|
|
120
|
+
if (!groupId) {
|
|
136
121
|
return {};
|
|
122
|
+
}
|
|
137
123
|
return { channel: channel.trim().toLowerCase(), groupId };
|
|
138
124
|
}
|
|
139
125
|
function resolveProviderToolPolicy(params) {
|
|
140
126
|
const provider = params.modelProvider?.trim();
|
|
141
|
-
if (!provider || !params.byProvider)
|
|
127
|
+
if (!provider || !params.byProvider) {
|
|
142
128
|
return undefined;
|
|
129
|
+
}
|
|
143
130
|
const entries = Object.entries(params.byProvider);
|
|
144
|
-
if (entries.length === 0)
|
|
131
|
+
if (entries.length === 0) {
|
|
145
132
|
return undefined;
|
|
133
|
+
}
|
|
146
134
|
const lookup = new Map();
|
|
147
135
|
for (const [key, value] of entries) {
|
|
148
136
|
const normalized = normalizeProviderKey(key);
|
|
149
|
-
if (!normalized)
|
|
137
|
+
if (!normalized) {
|
|
150
138
|
continue;
|
|
139
|
+
}
|
|
151
140
|
lookup.set(normalized, value);
|
|
152
141
|
}
|
|
153
142
|
const normalizedProvider = normalizeProviderKey(provider);
|
|
@@ -156,8 +145,9 @@ function resolveProviderToolPolicy(params) {
|
|
|
156
145
|
const candidates = [...(fullModelId ? [fullModelId] : []), normalizedProvider];
|
|
157
146
|
for (const key of candidates) {
|
|
158
147
|
const match = lookup.get(key);
|
|
159
|
-
if (match)
|
|
148
|
+
if (match) {
|
|
160
149
|
return match;
|
|
150
|
+
}
|
|
161
151
|
}
|
|
162
152
|
return undefined;
|
|
163
153
|
}
|
|
@@ -179,10 +169,10 @@ export function resolveEffectiveToolPolicy(params) {
|
|
|
179
169
|
});
|
|
180
170
|
return {
|
|
181
171
|
agentId,
|
|
182
|
-
globalPolicy:
|
|
183
|
-
globalProviderPolicy:
|
|
184
|
-
agentPolicy:
|
|
185
|
-
agentProviderPolicy:
|
|
172
|
+
globalPolicy: pickSandboxToolPolicy(globalTools),
|
|
173
|
+
globalProviderPolicy: pickSandboxToolPolicy(providerPolicy),
|
|
174
|
+
agentPolicy: pickSandboxToolPolicy(agentTools),
|
|
175
|
+
agentProviderPolicy: pickSandboxToolPolicy(agentProviderPolicy),
|
|
186
176
|
profile,
|
|
187
177
|
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
|
|
188
178
|
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
|
|
@@ -199,17 +189,20 @@ export function resolveEffectiveToolPolicy(params) {
|
|
|
199
189
|
};
|
|
200
190
|
}
|
|
201
191
|
export function resolveGroupToolPolicy(params) {
|
|
202
|
-
if (!params.config)
|
|
192
|
+
if (!params.config) {
|
|
203
193
|
return undefined;
|
|
194
|
+
}
|
|
204
195
|
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
|
205
196
|
const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy);
|
|
206
197
|
const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId;
|
|
207
|
-
if (!groupId)
|
|
198
|
+
if (!groupId) {
|
|
208
199
|
return undefined;
|
|
200
|
+
}
|
|
209
201
|
const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel;
|
|
210
202
|
const channel = normalizeMessageChannel(channelRaw);
|
|
211
|
-
if (!channel)
|
|
203
|
+
if (!channel) {
|
|
212
204
|
return undefined;
|
|
205
|
+
}
|
|
213
206
|
let dock;
|
|
214
207
|
try {
|
|
215
208
|
dock = getChannelDock(channel);
|
|
@@ -238,7 +231,7 @@ export function resolveGroupToolPolicy(params) {
|
|
|
238
231
|
senderUsername: params.senderUsername,
|
|
239
232
|
senderE164: params.senderE164,
|
|
240
233
|
});
|
|
241
|
-
return
|
|
234
|
+
return pickSandboxToolPolicy(toolsConfig);
|
|
242
235
|
}
|
|
243
236
|
export function isToolAllowedByPolicies(name, policies) {
|
|
244
237
|
return policies.every((policy) => isToolAllowedByPolicyName(name, policy));
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
|
|
2
2
|
function extractEnumValues(schema) {
|
|
3
|
-
if (!schema || typeof schema !== "object")
|
|
3
|
+
if (!schema || typeof schema !== "object") {
|
|
4
4
|
return undefined;
|
|
5
|
+
}
|
|
5
6
|
const record = schema;
|
|
6
|
-
if (Array.isArray(record.enum))
|
|
7
|
+
if (Array.isArray(record.enum)) {
|
|
7
8
|
return record.enum;
|
|
8
|
-
|
|
9
|
+
}
|
|
10
|
+
if ("const" in record) {
|
|
9
11
|
return [record.const];
|
|
12
|
+
}
|
|
10
13
|
const variants = Array.isArray(record.anyOf)
|
|
11
14
|
? record.anyOf
|
|
12
15
|
: Array.isArray(record.oneOf)
|
|
@@ -22,50 +25,61 @@ function extractEnumValues(schema) {
|
|
|
22
25
|
return undefined;
|
|
23
26
|
}
|
|
24
27
|
function mergePropertySchemas(existing, incoming) {
|
|
25
|
-
if (!existing)
|
|
28
|
+
if (!existing) {
|
|
26
29
|
return incoming;
|
|
27
|
-
|
|
30
|
+
}
|
|
31
|
+
if (!incoming) {
|
|
28
32
|
return existing;
|
|
33
|
+
}
|
|
29
34
|
const existingEnum = extractEnumValues(existing);
|
|
30
35
|
const incomingEnum = extractEnumValues(incoming);
|
|
31
36
|
if (existingEnum || incomingEnum) {
|
|
32
37
|
const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])]));
|
|
33
38
|
const merged = {};
|
|
34
39
|
for (const source of [existing, incoming]) {
|
|
35
|
-
if (!source || typeof source !== "object")
|
|
40
|
+
if (!source || typeof source !== "object") {
|
|
36
41
|
continue;
|
|
42
|
+
}
|
|
37
43
|
const record = source;
|
|
38
44
|
for (const key of ["title", "description", "default"]) {
|
|
39
|
-
if (!(key in merged) && key in record)
|
|
45
|
+
if (!(key in merged) && key in record) {
|
|
40
46
|
merged[key] = record[key];
|
|
47
|
+
}
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
const types = new Set(values.map((value) => typeof value));
|
|
44
|
-
if (types.size === 1)
|
|
51
|
+
if (types.size === 1) {
|
|
45
52
|
merged.type = Array.from(types)[0];
|
|
53
|
+
}
|
|
46
54
|
merged.enum = values;
|
|
47
55
|
return merged;
|
|
48
56
|
}
|
|
49
57
|
return existing;
|
|
50
58
|
}
|
|
51
|
-
export function normalizeToolParameters(tool) {
|
|
59
|
+
export function normalizeToolParameters(tool, options) {
|
|
52
60
|
const schema = tool.parameters && typeof tool.parameters === "object"
|
|
53
61
|
? tool.parameters
|
|
54
62
|
: undefined;
|
|
55
|
-
if (!schema)
|
|
63
|
+
if (!schema) {
|
|
56
64
|
return tool;
|
|
65
|
+
}
|
|
57
66
|
// Provider quirks:
|
|
58
67
|
// - Gemini rejects several JSON Schema keywords, so we scrub those.
|
|
59
68
|
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
|
|
60
69
|
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
|
|
70
|
+
// - Anthropic (google-antigravity) expects full JSON Schema draft 2020-12 compliance.
|
|
61
71
|
//
|
|
62
72
|
// Normalize once here so callers can always pass `tools` through unchanged.
|
|
73
|
+
const isGeminiProvider = options?.modelProvider?.toLowerCase().includes("google") ||
|
|
74
|
+
options?.modelProvider?.toLowerCase().includes("gemini");
|
|
75
|
+
const isAnthropicProvider = options?.modelProvider?.toLowerCase().includes("anthropic") ||
|
|
76
|
+
options?.modelProvider?.toLowerCase().includes("google-antigravity");
|
|
63
77
|
// If schema already has type + properties (no top-level anyOf to merge),
|
|
64
|
-
//
|
|
78
|
+
// clean it for Gemini compatibility (but only if using Gemini, not Anthropic)
|
|
65
79
|
if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) {
|
|
66
80
|
return {
|
|
67
81
|
...tool,
|
|
68
|
-
parameters: cleanSchemaForGemini(schema),
|
|
82
|
+
parameters: isGeminiProvider && !isAnthropicProvider ? cleanSchemaForGemini(schema) : schema,
|
|
69
83
|
};
|
|
70
84
|
}
|
|
71
85
|
// Some tool schemas (esp. unions) may omit `type` at the top-level. If we see
|
|
@@ -74,9 +88,12 @@ export function normalizeToolParameters(tool) {
|
|
|
74
88
|
(typeof schema.properties === "object" || Array.isArray(schema.required)) &&
|
|
75
89
|
!Array.isArray(schema.anyOf) &&
|
|
76
90
|
!Array.isArray(schema.oneOf)) {
|
|
91
|
+
const schemaWithType = { ...schema, type: "object" };
|
|
77
92
|
return {
|
|
78
93
|
...tool,
|
|
79
|
-
parameters:
|
|
94
|
+
parameters: isGeminiProvider && !isAnthropicProvider
|
|
95
|
+
? cleanSchemaForGemini(schemaWithType)
|
|
96
|
+
: schemaWithType,
|
|
80
97
|
};
|
|
81
98
|
}
|
|
82
99
|
const variantKey = Array.isArray(schema.anyOf)
|
|
@@ -84,18 +101,21 @@ export function normalizeToolParameters(tool) {
|
|
|
84
101
|
: Array.isArray(schema.oneOf)
|
|
85
102
|
? "oneOf"
|
|
86
103
|
: null;
|
|
87
|
-
if (!variantKey)
|
|
104
|
+
if (!variantKey) {
|
|
88
105
|
return tool;
|
|
106
|
+
}
|
|
89
107
|
const variants = schema[variantKey];
|
|
90
108
|
const mergedProperties = {};
|
|
91
109
|
const requiredCounts = new Map();
|
|
92
110
|
let objectVariants = 0;
|
|
93
111
|
for (const entry of variants) {
|
|
94
|
-
if (!entry || typeof entry !== "object")
|
|
112
|
+
if (!entry || typeof entry !== "object") {
|
|
95
113
|
continue;
|
|
114
|
+
}
|
|
96
115
|
const props = entry.properties;
|
|
97
|
-
if (!props || typeof props !== "object")
|
|
116
|
+
if (!props || typeof props !== "object") {
|
|
98
117
|
continue;
|
|
118
|
+
}
|
|
99
119
|
objectVariants += 1;
|
|
100
120
|
for (const [key, value] of Object.entries(props)) {
|
|
101
121
|
if (!(key in mergedProperties)) {
|
|
@@ -108,8 +128,9 @@ export function normalizeToolParameters(tool) {
|
|
|
108
128
|
? entry.required
|
|
109
129
|
: [];
|
|
110
130
|
for (const key of required) {
|
|
111
|
-
if (typeof key !== "string")
|
|
131
|
+
if (typeof key !== "string") {
|
|
112
132
|
continue;
|
|
133
|
+
}
|
|
113
134
|
requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1);
|
|
114
135
|
}
|
|
115
136
|
}
|
|
@@ -124,24 +145,30 @@ export function normalizeToolParameters(tool) {
|
|
|
124
145
|
.map(([key]) => key)
|
|
125
146
|
: undefined;
|
|
126
147
|
const nextSchema = { ...schema };
|
|
148
|
+
const flattenedSchema = {
|
|
149
|
+
type: "object",
|
|
150
|
+
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
|
|
151
|
+
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
|
|
152
|
+
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}),
|
|
153
|
+
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
|
|
154
|
+
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true,
|
|
155
|
+
};
|
|
127
156
|
return {
|
|
128
157
|
...tool,
|
|
129
158
|
// Flatten union schemas into a single object schema:
|
|
130
159
|
// - Gemini doesn't allow top-level `type` together with `anyOf`.
|
|
131
160
|
// - OpenAI rejects schemas without top-level `type: "object"`.
|
|
161
|
+
// - Anthropic accepts proper JSON Schema with constraints.
|
|
132
162
|
// Merging properties preserves useful enums like `action` while keeping schemas portable.
|
|
133
|
-
parameters:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...(typeof nextSchema.description === "string"
|
|
137
|
-
? { description: nextSchema.description }
|
|
138
|
-
: {}),
|
|
139
|
-
properties: Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}),
|
|
140
|
-
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
|
|
141
|
-
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true,
|
|
142
|
-
}),
|
|
163
|
+
parameters: isGeminiProvider && !isAnthropicProvider
|
|
164
|
+
? cleanSchemaForGemini(flattenedSchema)
|
|
165
|
+
: flattenedSchema,
|
|
143
166
|
};
|
|
144
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* @deprecated Use normalizeToolParameters with modelProvider instead.
|
|
170
|
+
* This function should only be used for Gemini providers.
|
|
171
|
+
*/
|
|
145
172
|
export function cleanToolSchemaForGemini(schema) {
|
|
146
173
|
return cleanSchemaForGemini(schema);
|
|
147
174
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox security validation — blocks dangerous Docker configurations.
|
|
3
|
+
*
|
|
4
|
+
* Threat model: local-trusted config, but protect against foot-guns and config injection.
|
|
5
|
+
* Enforced at runtime when creating sandbox containers.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
8
|
+
import { posix } from "node:path";
|
|
9
|
+
// Targeted denylist: host paths that should never be exposed inside sandbox containers.
|
|
10
|
+
// Exported for reuse in security audit collectors.
|
|
11
|
+
export const BLOCKED_HOST_PATHS = [
|
|
12
|
+
"/etc",
|
|
13
|
+
"/private/etc",
|
|
14
|
+
"/proc",
|
|
15
|
+
"/sys",
|
|
16
|
+
"/dev",
|
|
17
|
+
"/root",
|
|
18
|
+
"/boot",
|
|
19
|
+
// Directories that commonly contain (or alias) the Docker socket.
|
|
20
|
+
"/run",
|
|
21
|
+
"/var/run",
|
|
22
|
+
"/private/var/run",
|
|
23
|
+
"/var/run/docker.sock",
|
|
24
|
+
"/private/var/run/docker.sock",
|
|
25
|
+
"/run/docker.sock",
|
|
26
|
+
];
|
|
27
|
+
const BLOCKED_NETWORK_MODES = new Set(["host"]);
|
|
28
|
+
const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]);
|
|
29
|
+
const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]);
|
|
30
|
+
/**
|
|
31
|
+
* Parse the host/source path from a Docker bind mount string.
|
|
32
|
+
* Format: `source:target[:mode]`
|
|
33
|
+
*/
|
|
34
|
+
export function parseBindSourcePath(bind) {
|
|
35
|
+
const trimmed = bind.trim();
|
|
36
|
+
const firstColon = trimmed.indexOf(":");
|
|
37
|
+
if (firstColon <= 0) {
|
|
38
|
+
// No colon or starts with colon — treat as source.
|
|
39
|
+
return trimmed;
|
|
40
|
+
}
|
|
41
|
+
return trimmed.slice(0, firstColon);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Normalize a POSIX path: resolve `.`, `..`, collapse `//`, strip trailing `/`.
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeHostPath(raw) {
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
return posix.normalize(trimmed).replace(/\/+$/, "") || "/";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* String-only blocked-path check (no filesystem I/O).
|
|
52
|
+
* Blocks:
|
|
53
|
+
* - binds that target blocked paths (equal or under)
|
|
54
|
+
* - binds that cover the system root (mounting "/" is never safe)
|
|
55
|
+
* - non-absolute source paths (relative / volume names) because they are hard to validate safely
|
|
56
|
+
*/
|
|
57
|
+
export function getBlockedBindReason(bind) {
|
|
58
|
+
const sourceRaw = parseBindSourcePath(bind);
|
|
59
|
+
if (!sourceRaw.startsWith("/")) {
|
|
60
|
+
return { kind: "non_absolute", sourcePath: sourceRaw };
|
|
61
|
+
}
|
|
62
|
+
const normalized = normalizeHostPath(sourceRaw);
|
|
63
|
+
return getBlockedReasonForSourcePath(normalized);
|
|
64
|
+
}
|
|
65
|
+
export function getBlockedReasonForSourcePath(sourceNormalized) {
|
|
66
|
+
if (sourceNormalized === "/") {
|
|
67
|
+
return { kind: "covers", blockedPath: "/" };
|
|
68
|
+
}
|
|
69
|
+
for (const blocked of BLOCKED_HOST_PATHS) {
|
|
70
|
+
if (sourceNormalized === blocked || sourceNormalized.startsWith(blocked + "/")) {
|
|
71
|
+
return { kind: "targets", blockedPath: blocked };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function tryRealpathAbsolute(path) {
|
|
77
|
+
if (!path.startsWith("/")) {
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
if (!existsSync(path)) {
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
// Use native when available (keeps platform semantics); normalize for prefix checks.
|
|
85
|
+
return normalizeHostPath(realpathSync.native(path));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function formatBindBlockedError(params) {
|
|
92
|
+
if (params.reason.kind === "non_absolute") {
|
|
93
|
+
return new Error(`Sandbox security: bind mount "${params.bind}" uses a non-absolute source path ` +
|
|
94
|
+
`"${params.reason.sourcePath}". Only absolute POSIX paths are supported for sandbox binds.`);
|
|
95
|
+
}
|
|
96
|
+
const verb = params.reason.kind === "covers" ? "covers" : "targets";
|
|
97
|
+
return new Error(`Sandbox security: bind mount "${params.bind}" ${verb} blocked path "${params.reason.blockedPath}". ` +
|
|
98
|
+
"Mounting system directories (or Docker socket paths) into sandbox containers is not allowed. " +
|
|
99
|
+
"Use project-specific paths instead (e.g. /home/user/myproject).");
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate bind mounts — throws if any source path is dangerous.
|
|
103
|
+
* Includes a symlink/realpath pass when the source path exists.
|
|
104
|
+
*/
|
|
105
|
+
export function validateBindMounts(binds) {
|
|
106
|
+
if (!binds?.length) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const rawBind of binds) {
|
|
110
|
+
const bind = rawBind.trim();
|
|
111
|
+
if (!bind) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Fast string-only check (covers .., //, ancestor/descendant logic).
|
|
115
|
+
const blocked = getBlockedBindReason(bind);
|
|
116
|
+
if (blocked) {
|
|
117
|
+
throw formatBindBlockedError({ bind, reason: blocked });
|
|
118
|
+
}
|
|
119
|
+
// Symlink escape hardening: resolve existing absolute paths and re-check.
|
|
120
|
+
const sourceRaw = parseBindSourcePath(bind);
|
|
121
|
+
const sourceNormalized = normalizeHostPath(sourceRaw);
|
|
122
|
+
const sourceReal = tryRealpathAbsolute(sourceNormalized);
|
|
123
|
+
if (sourceReal !== sourceNormalized) {
|
|
124
|
+
const reason = getBlockedReasonForSourcePath(sourceReal);
|
|
125
|
+
if (reason) {
|
|
126
|
+
throw formatBindBlockedError({ bind, reason });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function validateNetworkMode(network) {
|
|
132
|
+
if (network && BLOCKED_NETWORK_MODES.has(network.trim().toLowerCase())) {
|
|
133
|
+
throw new Error(`Sandbox security: network mode "${network}" is blocked. ` +
|
|
134
|
+
'Network "host" mode bypasses container network isolation. ' +
|
|
135
|
+
'Use "bridge" or "none" instead.');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function validateSeccompProfile(profile) {
|
|
139
|
+
if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) {
|
|
140
|
+
throw new Error(`Sandbox security: seccomp profile "${profile}" is blocked. ` +
|
|
141
|
+
"Disabling seccomp removes syscall filtering and weakens sandbox isolation. " +
|
|
142
|
+
"Use a custom seccomp profile file or omit this setting.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function validateApparmorProfile(profile) {
|
|
146
|
+
if (profile && BLOCKED_APPARMOR_PROFILES.has(profile.trim().toLowerCase())) {
|
|
147
|
+
throw new Error(`Sandbox security: apparmor profile "${profile}" is blocked. ` +
|
|
148
|
+
"Disabling AppArmor removes mandatory access controls and weakens sandbox isolation. " +
|
|
149
|
+
"Use a named AppArmor profile or omit this setting.");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function validateSandboxSecurity(cfg) {
|
|
153
|
+
validateBindMounts(cfg.binds);
|
|
154
|
+
validateNetworkMode(cfg.network);
|
|
155
|
+
validateSeccompProfile(cfg.seccompProfile);
|
|
156
|
+
validateApparmorProfile(cfg.apparmorProfile);
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function unionAllow(base, extra) {
|
|
2
|
+
if (!Array.isArray(extra) || extra.length === 0) {
|
|
3
|
+
return base;
|
|
4
|
+
}
|
|
5
|
+
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
|
|
6
|
+
// an implicit allow-all policy.
|
|
7
|
+
if (!Array.isArray(base) || base.length === 0) {
|
|
8
|
+
return Array.from(new Set(["*", ...extra]));
|
|
9
|
+
}
|
|
10
|
+
return Array.from(new Set([...base, ...extra]));
|
|
11
|
+
}
|
|
12
|
+
export function pickSandboxToolPolicy(config) {
|
|
13
|
+
if (!config) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const allow = Array.isArray(config.allow)
|
|
17
|
+
? unionAllow(config.allow, config.alsoAllow)
|
|
18
|
+
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
|
|
19
|
+
? unionAllow(undefined, config.alsoAllow)
|
|
20
|
+
: undefined;
|
|
21
|
+
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
|
22
|
+
if (!allow && !deny) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return { allow, deny };
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize untrusted strings before embedding them into an LLM prompt.
|
|
3
|
+
*
|
|
4
|
+
* Threat model (OC-19): attacker-controlled directory names (or other runtime strings)
|
|
5
|
+
* that contain newline/control characters can break prompt structure and inject
|
|
6
|
+
* arbitrary instructions.
|
|
7
|
+
*
|
|
8
|
+
* Strategy (Option 3 hardening):
|
|
9
|
+
* - Strip Unicode "control" (Cc) + "format" (Cf) characters (includes CR/LF/NUL, bidi marks, zero-width chars).
|
|
10
|
+
* - Strip explicit line/paragraph separators (Zl/Zp): U+2028/U+2029.
|
|
11
|
+
*
|
|
12
|
+
* Notes:
|
|
13
|
+
* - This is intentionally lossy; it trades edge-case path fidelity for prompt integrity.
|
|
14
|
+
* - If you need lossless representation, escape instead of stripping.
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeForPromptLiteral(value) {
|
|
17
|
+
return value.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "");
|
|
18
|
+
}
|