@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/agents/auth-profiles/usage.js +22 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/bash-tools.exec.js +4 -6
  5. package/dist/agents/glob-pattern.js +42 -0
  6. package/dist/agents/memory-search.js +33 -0
  7. package/dist/agents/model-fallback.js +59 -8
  8. package/dist/agents/pi-tools.before-tool-call.js +145 -4
  9. package/dist/agents/pi-tools.js +27 -9
  10. package/dist/agents/pi-tools.policy.js +85 -92
  11. package/dist/agents/pi-tools.schema.js +54 -27
  12. package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
  13. package/dist/agents/sandbox-tool-policy.js +26 -0
  14. package/dist/agents/sanitize-for-prompt.js +18 -0
  15. package/dist/agents/session-write-lock.js +203 -39
  16. package/dist/agents/system-prompt.js +52 -10
  17. package/dist/agents/tool-loop-detection.js +466 -0
  18. package/dist/agents/tool-policy.js +6 -0
  19. package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
  20. package/dist/auto-reply/reply/post-compaction-context.js +98 -0
  21. package/dist/build-info.json +3 -3
  22. package/dist/config/zod-schema.agent-defaults.js +14 -0
  23. package/dist/config/zod-schema.agent-runtime.js +14 -0
  24. package/dist/infra/path-safety.js +16 -0
  25. package/dist/logging/diagnostic-session-state.js +73 -0
  26. package/dist/logging/diagnostic.js +22 -0
  27. package/dist/memory/embeddings.js +36 -9
  28. package/dist/memory/hybrid.js +24 -5
  29. package/dist/memory/manager.js +76 -28
  30. package/dist/memory/mmr.js +164 -0
  31. package/dist/memory/query-expansion.js +331 -0
  32. package/dist/memory/temporal-decay.js +119 -0
  33. package/dist/process/kill-tree.js +98 -0
  34. package/dist/shared/pid-alive.js +12 -0
  35. package/dist/shared/process-scoped-map.js +10 -0
  36. package/extensions/bluebubbles/package.json +1 -1
  37. package/extensions/copilot-proxy/package.json +1 -1
  38. package/extensions/diagnostics-otel/package.json +1 -1
  39. package/extensions/discord/package.json +1 -1
  40. package/extensions/google-antigravity-auth/package.json +1 -1
  41. package/extensions/google-gemini-cli-auth/package.json +1 -1
  42. package/extensions/googlechat/package.json +1 -1
  43. package/extensions/imessage/package.json +1 -1
  44. package/extensions/line/package.json +1 -1
  45. package/extensions/llm-task/package.json +1 -1
  46. package/extensions/lobster/package.json +1 -1
  47. package/extensions/matrix/CHANGELOG.md +5 -0
  48. package/extensions/matrix/package.json +1 -1
  49. package/extensions/mattermost/package.json +1 -1
  50. package/extensions/memory-core/package.json +1 -1
  51. package/extensions/memory-lancedb/package.json +1 -1
  52. package/extensions/msteams/CHANGELOG.md +5 -0
  53. package/extensions/msteams/package.json +1 -1
  54. package/extensions/nextcloud-talk/package.json +1 -1
  55. package/extensions/nostr/CHANGELOG.md +5 -0
  56. package/extensions/nostr/package.json +1 -1
  57. package/extensions/open-prose/package.json +1 -1
  58. package/extensions/signal/package.json +1 -1
  59. package/extensions/slack/package.json +1 -1
  60. package/extensions/telegram/package.json +1 -1
  61. package/extensions/tlon/package.json +1 -1
  62. package/extensions/twitch/CHANGELOG.md +5 -0
  63. package/extensions/twitch/package.json +1 -1
  64. package/extensions/voice-call/CHANGELOG.md +5 -0
  65. package/extensions/voice-call/package.json +1 -1
  66. package/extensions/whatsapp/package.json +1 -1
  67. package/extensions/zalo/CHANGELOG.md +5 -0
  68. package/extensions/zalo/package.json +1 -1
  69. package/extensions/zalouser/CHANGELOG.md +5 -0
  70. package/extensions/zalouser/package.json +1 -1
  71. 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 = compilePatterns(policy.deny);
41
- const allow = compilePatterns(policy.allow);
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 (matchesAny(normalized, deny))
20
+ if (matchesAnyGlobPattern(normalized, deny)) {
45
21
  return false;
46
- if (allow.length === 0)
22
+ }
23
+ if (allow.length === 0) {
47
24
  return true;
48
- if (matchesAny(normalized, allow))
25
+ }
26
+ if (matchesAnyGlobPattern(normalized, allow)) {
49
27
  return true;
50
- if (normalized === "apply_patch" && matchesAny("exec", allow))
28
+ }
29
+ if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) {
51
30
  return true;
31
+ }
52
32
  return false;
53
33
  };
54
34
  }
55
- const DEFAULT_SUBAGENT_TOOL_DENY = [
56
- // Session management - main agent orchestrates
57
- "sessions_list",
58
- "sessions_history",
59
- "sessions_send",
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
- export function resolveSubagentToolPolicy(cfg) {
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 deny = [
76
- ...DEFAULT_SUBAGENT_TOOL_DENY,
77
- ...(Array.isArray(configured?.deny) ? configured.deny : []),
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: pickToolPolicy(globalTools),
183
- globalProviderPolicy: pickToolPolicy(providerPolicy),
184
- agentPolicy: pickToolPolicy(agentTools),
185
- agentProviderPolicy: pickToolPolicy(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 pickToolPolicy(toolsConfig);
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
- if ("const" in record)
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
- if (!incoming)
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
- // still clean it for Gemini compatibility
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: cleanSchemaForGemini({ ...schema, type: "object" }),
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: cleanSchemaForGemini({
134
- type: "object",
135
- ...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
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
+ }