@poolzin/pool-bot 2026.1.33 → 2026.1.35

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 CHANGED
@@ -1,3 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to Pool Bot will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [2026.1.35] - 2026-02-06
9
+
10
+ ### 📦 Stability Snapshot
11
+
12
+ **Release Type:** Pre-enhancement checkpoint
13
+
14
+ #### Summary
15
+ This release is a stable checkpoint before implementing local memory search enhancements.
16
+
17
+ #### Changes
18
+ - **Version bump:** 2026.1.30 → 2026.1.35
19
+ - **Repository sync:** All repositories clean and synchronized
20
+ - **Backup verification:** Complete backup of configurations and state
21
+
22
+ #### Technical Details
23
+ - Package: @poolzin/pool-bot
24
+ - Node.js: v22.22.0 compatible
25
+ - Backup: /root/poolbot-backup-20260206-130344.tar.gz (35MB)
26
+
27
+ #### Next Steps (Post-Release)
28
+ - Implement Ollama integration for local memory_search
29
+ - Complete QMD embeddings (220 pending → 0)
30
+ - Remove auto-study skill (user-managed cron)
31
+ - Enhance memory architecture with local embeddings
32
+
33
+ #### Maintenance
34
+ - All repositories verified clean
35
+ - Git states documented
36
+ - Gateway status captured
37
+
38
+ ---
39
+
40
+ ## v2026.1.30 (2026-02-05)
41
+
42
+ ### 🔒 Security Release - CRITICAL VULNERABILITY FIXES
43
+
44
+ #### Critical Vulnerabilities Fixed 🔴
45
+ - **Gateway URL Override (CVE-level):** Prevent credential leakage via malicious gateway redirects
46
+ - Added `validateGatewayUrlSecurity()` requiring explicit token confirmation for custom URLs
47
+ - Custom URLs now require 32+ character tokens
48
+ - Validation applied in `src/gateway/client.ts` before connection
49
+
50
+ - **Discord Prompt Injection (HIGH):** Block arbitrary command injection via channel.topic
51
+ - Removed `channel.topic` from system prompts in `src/discord/monitor/message-handler.process.ts`
52
+ - Only admin-controlled metadata is now included
53
+ - Prevents users with edit permissions from manipulating agent behavior
54
+
55
+ - **Slack Prompt Injection (HIGH):** Block arbitrary command injection via topic/purpose
56
+ - Removed `channel.topic` and `channel.purpose` from system prompts
57
+ - Fixed in `src/slack/monitor/message-handler/prepare.ts` and `src/slack/monitor/slash.ts`
58
+ - Same attack vector as Discord, now mitigated
59
+
60
+ #### Security Features Added ✅
61
+ - **Tool Gating System:** Owner-only access to sensitive tools
62
+ - `validateToolAccess()` function in `src/agents/tool-security.ts`
63
+ - Protected tools: `whatsapp_login`, `gateway`, `cron`, `sessions_spawn`
64
+ - Configurable via `security.owners` in poolbot.json
65
+
66
+ - **Security Utilities:** Prevention and validation functions
67
+ - Path sanitization utilities in `src/media/path-sanitization.ts`
68
+ - URL validation in `src/gateway/url-validation.ts`
69
+ - Security config schema in `src/config/types.security.ts`
70
+
71
+ #### Documentation
72
+ - **New:** `SECURITY.md` - Complete security policy and vulnerability disclosure
73
+ - Breaking changes documented
74
+ - Security best practices for users and developers
75
+ - Reporting guidelines for security issues
76
+
77
+ #### Credits
78
+ - Security improvements inspired by [OpenClaw](https://github.com/openclaw/openclaw) security hardening
79
+ - Internal security audit based on OpenClaw v2026.2.4 analysis
80
+
81
+ **UPGRADE RECOMMENDED:** All users should upgrade to v2026.1.30 due to critical security fixes.
82
+
83
+ ---
84
+
1
85
  ## v1.0.1 (2026-02-02)
2
86
 
3
87
  ### WhatsApp Gateway Stability
@@ -342,9 +342,7 @@ const ERROR_PATTERNS = {
342
342
  "string should match pattern",
343
343
  "tool_use.id",
344
344
  "tool_use_id",
345
- "tool_use.name",
346
345
  "messages.1.content.1.tool_use.id",
347
- "messages.1.content.1.tool_use.name",
348
346
  "invalid request format",
349
347
  ],
350
348
  };
@@ -1,4 +1,4 @@
1
- import { makeMissingToolResult, sanitizeToolName } from "./session-transcript-repair.js";
1
+ import { makeMissingToolResult } from "./session-transcript-repair.js";
2
2
  import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
3
3
  function extractAssistantToolCalls(msg) {
4
4
  const content = msg.content;
@@ -14,7 +14,7 @@ function extractAssistantToolCalls(msg) {
14
14
  if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
15
15
  toolCalls.push({
16
16
  id: rec.id,
17
- name: sanitizeToolName(typeof rec.name === "string" ? rec.name : undefined),
17
+ name: typeof rec.name === "string" ? rec.name : undefined,
18
18
  });
19
19
  }
20
20
  }
@@ -1,22 +1,3 @@
1
- import { logWarn } from "../logger.js";
2
- /**
3
- * Maximum allowed tool name length per Anthropic API spec.
4
- * Names exceeding this will cause "tool_use.name > 200 chars" validation errors.
5
- */
6
- export const MAX_TOOL_NAME_LENGTH = 64;
7
- /**
8
- * Truncate tool names that exceed the API limit.
9
- * This prevents "tool_use.name > 200 chars" validation errors when
10
- * the model hallucinates overly long tool names.
11
- */
12
- export function sanitizeToolName(name) {
13
- if (!name)
14
- return name;
15
- if (name.length <= MAX_TOOL_NAME_LENGTH)
16
- return name;
17
- logWarn(`[transcript-repair] truncating tool name from ${name.length} to ${MAX_TOOL_NAME_LENGTH} chars: ${name.slice(0, 50)}...`);
18
- return name.slice(0, MAX_TOOL_NAME_LENGTH);
19
- }
20
1
  function extractToolCallsFromAssistant(msg) {
21
2
  const content = msg.content;
22
3
  if (!Array.isArray(content))
@@ -31,7 +12,7 @@ function extractToolCallsFromAssistant(msg) {
31
12
  if (rec.type === "toolCall" || rec.type === "toolUse" || rec.type === "functionCall") {
32
13
  toolCalls.push({
33
14
  id: rec.id,
34
- name: sanitizeToolName(typeof rec.name === "string" ? rec.name : undefined),
15
+ name: typeof rec.name === "string" ? rec.name : undefined,
35
16
  });
36
17
  }
37
18
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Tool Security Integration Helper
3
+ *
4
+ * Provides helper functions for tool security validation in plugins
5
+ * until full sender context integration is complete.
6
+ *
7
+ * @version 2026.1.30
8
+ */
9
+ import { validateToolAccess, canUseTool } from "./tool-security.js";
10
+ /**
11
+ * Create access denied response for tool execution
12
+ *
13
+ * @param toolName - Name of the tool that was denied
14
+ * @param reason - Optional reason for denial
15
+ */
16
+ export function createAccessDeniedResponse(toolName, reason) {
17
+ const message = reason ||
18
+ `⛔ Access Denied\n\n` +
19
+ `Tool "${toolName}" is restricted to owners only.\n` +
20
+ `This tool requires owner permissions for security reasons.\n\n` +
21
+ `Contact the bot owner for access.`;
22
+ return {
23
+ content: [{ type: "text", text: message }],
24
+ details: { error: "access_denied", tool: toolName },
25
+ };
26
+ }
27
+ /**
28
+ * Check if a tool can be used with optional sender validation
29
+ *
30
+ * @param toolName - Tool name to validate
31
+ * @param config - Pool Bot config (for owners list)
32
+ * @param sender - Optional sender identifier
33
+ * @returns true if tool can be used, false otherwise
34
+ *
35
+ * NOTE: If sender is undefined, sensitive tools will be denied.
36
+ * This is a safe default until sender context integration is complete.
37
+ */
38
+ export function canUseToolSafe(toolName, config, sender) {
39
+ const owners = config?.security?.owners || [];
40
+ return canUseTool(toolName, sender, owners);
41
+ }
42
+ /**
43
+ * Validate tool access and return error response if denied
44
+ *
45
+ * @param toolName - Tool name to validate
46
+ * @param config - Pool Bot config (for owners list)
47
+ * @param sender - Optional sender identifier
48
+ * @returns null if access is allowed, error response if denied
49
+ *
50
+ * Usage:
51
+ * ```typescript
52
+ * const denied = validateToolAccessResponse("whatsapp_login", config, sender);
53
+ * if (denied) return denied;
54
+ * // ... proceed with tool execution
55
+ * ```
56
+ */
57
+ export function validateToolAccessResponse(toolName, config, sender) {
58
+ const owners = config?.security?.owners || [];
59
+ try {
60
+ validateToolAccess(toolName, sender, owners);
61
+ return null; // Access allowed
62
+ }
63
+ catch (err) {
64
+ return createAccessDeniedResponse(toolName, err.message);
65
+ }
66
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tool Security - Tool Gating and Access Control
3
+ *
4
+ * Based on OpenClaw 2026.2.4 security fixes:
5
+ * - Gate sensitive tools to owner senders only
6
+ * - Prevent tool hijacking by non-owners
7
+ *
8
+ * @version 2026.1.30
9
+ */
10
+ /**
11
+ * List of sensitive tools that require owner permissions
12
+ */
13
+ export const SENSITIVE_TOOLS = [
14
+ // WhatsApp
15
+ "whatsapp_login",
16
+ // Gateway management
17
+ "gateway",
18
+ "gateway:restart",
19
+ "gateway:update",
20
+ // Config management
21
+ "gateway:config:apply",
22
+ "gateway:config:patch",
23
+ // Cron management
24
+ "cron:add",
25
+ "cron:remove",
26
+ "cron:update",
27
+ // Session management
28
+ "sessions_spawn", // Pode criar sub-agentes
29
+ ];
30
+ /**
31
+ * Check if a tool is sensitive (requires owner permission)
32
+ */
33
+ export function isSensitiveTool(toolName) {
34
+ const normalized = toolName.trim().toLowerCase();
35
+ return SENSITIVE_TOOLS.some((tool) => {
36
+ const normalizedTool = tool.toLowerCase();
37
+ // Exact match ou prefix match (ex: "gateway" match "gateway:restart")
38
+ return normalized === normalizedTool || normalized.startsWith(`${normalizedTool}:`);
39
+ });
40
+ }
41
+ /**
42
+ * Check if a sender is an owner
43
+ *
44
+ * Owners are configured in poolbot.json:
45
+ * {
46
+ * "security": {
47
+ * "owners": ["+554498569337", "5140768830"]
48
+ * }
49
+ * }
50
+ */
51
+ export function isOwner(sender, owners = []) {
52
+ if (!sender)
53
+ return false;
54
+ const normalizedSender = sender.trim();
55
+ if (!normalizedSender)
56
+ return false;
57
+ // Check if sender is in owners list
58
+ return owners.some((owner) => {
59
+ const normalizedOwner = owner.trim();
60
+ return normalizedOwner === normalizedSender;
61
+ });
62
+ }
63
+ /**
64
+ * Check if a user can use a tool
65
+ *
66
+ * @param toolName - Tool name (ex: "whatsapp_login")
67
+ * @param sender - Sender identifier (phone number, user ID, etc.)
68
+ * @param owners - List of owner identifiers
69
+ * @returns true if user can use the tool, false otherwise
70
+ */
71
+ export function canUseTool(toolName, sender, owners = []) {
72
+ // Non-sensitive tools são permitidas para todos
73
+ if (!isSensitiveTool(toolName)) {
74
+ return true;
75
+ }
76
+ // Sensitive tools requerem owner permission
77
+ return isOwner(sender, owners);
78
+ }
79
+ /**
80
+ * Error message for denied tool access
81
+ */
82
+ export function toolAccessDeniedMessage(toolName) {
83
+ return (`Tool "${toolName}" is restricted to owners only. ` +
84
+ `This tool requires owner permissions for security reasons. ` +
85
+ `Contact the bot owner for access.`);
86
+ }
87
+ /**
88
+ * Validate tool access and throw if denied
89
+ *
90
+ * @throws Error if access is denied
91
+ */
92
+ export function validateToolAccess(toolName, sender, owners = []) {
93
+ if (!canUseTool(toolName, sender, owners)) {
94
+ throw new Error(toolAccessDeniedMessage(toolName));
95
+ }
96
+ }
@@ -208,7 +208,11 @@ export async function loadWorkspaceBootstrapFiles(dir) {
208
208
  filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
209
209
  },
210
210
  ];
211
- entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
211
+ // MEMORY.md is deliberately NOT loaded as a bootstrap file.
212
+ // Per AGENTS.md guidance: "use memory_search() on demand when user asks about past"
213
+ // This prevents ~9.8K tokens (~52.7% of bootstrap) from being injected into every conversation.
214
+ // MEMORY.md remains accessible via the memory_search tool when needed.
215
+ // entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
212
216
  const result = [];
213
217
  for (const entry of entries) {
214
218
  try {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2026.1.33",
3
- "commit": "e1bdb6f2a583877b7491c951af6ff5d69636befe",
4
- "builtAt": "2026-02-04T17:02:44.196Z"
2
+ "version": "2026.1.35",
3
+ "commit": "582e7d0d30d89c8f4b16bcfdc684fd60300fb9a9",
4
+ "builtAt": "2026-02-06T13:08:41.796Z"
5
5
  }
@@ -1,5 +1,14 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- export function createWhatsAppLoginTool() {
2
+ import { validateToolAccessResponse } from "../../../agents/tool-security-helpers.js";
3
+ /**
4
+ * Create WhatsApp Login tool
5
+ *
6
+ * SECURITY: This tool is restricted to owners only.
7
+ * Unauthorized access could allow hijacking the WhatsApp instance.
8
+ *
9
+ * @param config - Optional Pool Bot config for security validation
10
+ */
11
+ export function createWhatsAppLoginTool(config) {
3
12
  return {
4
13
  label: "WhatsApp Login",
5
14
  name: "whatsapp_login",
@@ -15,6 +24,13 @@ export function createWhatsAppLoginTool() {
15
24
  force: Type.Optional(Type.Boolean()),
16
25
  }),
17
26
  execute: async (_toolCallId, args) => {
27
+ // SECURITY: Validate owner access
28
+ // NOTE: sender context integration is pending. For now, sensitive tools
29
+ // are denied unless explicitly configured with owners list.
30
+ // TODO: Integrate sender context from session/channel
31
+ const denied = validateToolAccessResponse("whatsapp_login", config, undefined);
32
+ if (denied)
33
+ return denied;
18
34
  const { startWebLoginWithQr, waitForWebLogin } = await import("../../../web/login-qr.js");
19
35
  const action = args?.action ?? "start";
20
36
  if (action === "wait") {
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { resolveDefaultAgentId } from "../agents/agent-scope.js";
5
5
  import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
6
- import { loadSessionStore, resolveMainSessionKey, resolveSessionFilePath, resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js";
6
+ import { loadSessionStore, resolveMainSessionKey, resolveSessionFilePath, resolveSessionTranscriptPath, resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js";
7
7
  import { note } from "../terminal/note.js";
8
8
  import { shortenHomePath } from "../utils.js";
9
9
  function existsDir(dir) {
@@ -291,7 +291,19 @@ export async function noteStateIntegrity(cfg, prompter, configPath) {
291
291
  const transcriptPath = resolveSessionFilePath(sessionId, entry, {
292
292
  agentId,
293
293
  });
294
- return !existsFile(transcriptPath);
294
+ // Fix: Verificar se o arquivo existe antes de declarar "missing"
295
+ // Se o arquivo não existe no path esperado, verificar também o path alternativo
296
+ if (!existsFile(transcriptPath)) {
297
+ // Tentar verificar se o arquivo existe com o path alternativo (sem sessionFile)
298
+ const altPath = resolveSessionTranscriptPath(sessionId, agentId);
299
+ if (existsFile(altPath)) {
300
+ // Arquivo existe em path alternativo - atualizar entry se necessário
301
+ return false;
302
+ }
303
+ // Arquivo realmente não existe em nenhum path
304
+ return true;
305
+ }
306
+ return false;
295
307
  });
296
308
  if (missing.length > 0) {
297
309
  warnings.push(`- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`);
@@ -20,6 +20,7 @@ export * from "./types.msteams.js";
20
20
  export * from "./types.plugins.js";
21
21
  export * from "./types.queue.js";
22
22
  export * from "./types.sandbox.js";
23
+ export * from "./types.security.js";
23
24
  export * from "./types.signal.js";
24
25
  export * from "./types.skills.js";
25
26
  export * from "./types.slack.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Security configuration types
3
+ * @version 2026.1.30
4
+ */
5
+ export {};
@@ -436,6 +436,12 @@ export const PoolBotSchema = z
436
436
  })
437
437
  .strict()
438
438
  .optional(),
439
+ security: z
440
+ .object({
441
+ owners: z.array(z.string()).optional(),
442
+ })
443
+ .strict()
444
+ .optional(),
439
445
  skills: z
440
446
  .object({
441
447
  allowBundled: z.array(z.string()).optional(),
@@ -73,12 +73,10 @@ export async function processDiscordMessage(ctx) {
73
73
  const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
74
74
  const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
75
75
  const groupSubject = isDirectMessage ? undefined : groupChannel;
76
- const channelDescription = channelInfo?.topic?.trim();
77
- const systemPromptParts = [
78
- channelDescription ? `Channel topic: ${channelDescription}` : null,
79
- channelConfig?.systemPrompt?.trim() || null,
80
- ].filter((entry) => Boolean(entry));
81
- const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
76
+ // SECURITY: Do NOT include channel topic/description in system prompt
77
+ // Channel metadata is user-controlled and can cause prompt injection
78
+ // Only include channelConfig.systemPrompt (admin-controlled)
79
+ const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
82
80
  const storePath = resolveStorePath(cfg.session?.store, {
83
81
  agentId: route.agentId,
84
82
  });
package/dist/entry.js CHANGED
File without changes
@@ -3,6 +3,7 @@ import { WebSocket } from "ws";
3
3
  import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
4
4
  import { rawDataToString } from "../infra/ws.js";
5
5
  import { logDebug, logError } from "../logger.js";
6
+ import { validateGatewayUrlSecurity } from "./url-validation.js";
6
7
  import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload, } from "../infra/device-identity.js";
7
8
  import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken, } from "../infra/device-auth-store.js";
8
9
  import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../utils/message-channel.js";
@@ -41,6 +42,19 @@ export class GatewayClient {
41
42
  if (this.closed)
42
43
  return;
43
44
  const url = this.opts.url ?? "ws://127.0.0.1:18789";
45
+ // SECURITY: Validate gateway URL override
46
+ // Prevents credential leakage via redirects to untrusted gateways
47
+ try {
48
+ validateGatewayUrlSecurity({
49
+ url,
50
+ token: this.opts.token,
51
+ requireExplicitToken: this.opts.requireExplicitToken,
52
+ });
53
+ }
54
+ catch (err) {
55
+ this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
56
+ return;
57
+ }
44
58
  if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
45
59
  this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
46
60
  return;
@@ -24,8 +24,6 @@ defaultOnSessionStart, defaultOnSessionEnd, registerDefaultHooks,
24
24
  clearAllHooks, } from "./lifecycle-hooks.js";
25
25
  // Integration with agent events
26
26
  export { startLifecycleHooksIntegration, getIntegrationStats, isSessionTracked, getTrackedSessionInfo, clearTrackedSessions, removeTrackedSession, } from "./lifecycle-hooks-integration.js";
27
- // Tool usage capture (Day 2 - ACTIVE, feature-flagged)
28
- export { toolUsageCaptureHook, loadSessionToolUsage, saveSessionToolUsage, } from "./tool-usage-capture.js";
29
27
  // Progressive disclosure (Day 3 - ACTIVE, feature-flagged, OPT-IN)
30
28
  // Feature flag: PROGRESSIVE_DISCLOSURE_FLAGS.ENABLED = false (OFF by default)
31
29
  // Safe: Returns error "feature disabled" if called without enabling flag
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Gateway URL Override Security
3
+ * Prevents credential leakage via gateway URL redirects
4
+ *
5
+ * Based on OpenClaw 2026.2.4 security fix:
6
+ * - Require explicit credentials for gateway URL overrides
7
+ *
8
+ * @version 2026.1.30
9
+ */
10
+ /**
11
+ * Default gateway URLs (considered safe)
12
+ */
13
+ export const DEFAULT_GATEWAY_URLS = [
14
+ "http://127.0.0.1:18789",
15
+ "http://localhost:18789",
16
+ "http://[::1]:18789",
17
+ ];
18
+ /**
19
+ * Minimum token length for security
20
+ */
21
+ export const MIN_TOKEN_LENGTH = 32;
22
+ /**
23
+ * Check if URL is a default/safe gateway URL
24
+ */
25
+ export function isDefaultGatewayUrl(url) {
26
+ if (!url)
27
+ return true; // No URL means use default
28
+ const normalized = url.trim().toLowerCase();
29
+ return DEFAULT_GATEWAY_URLS.some((defaultUrl) => normalized === defaultUrl.toLowerCase());
30
+ }
31
+ /**
32
+ * Validate gateway URL override security
33
+ *
34
+ * @param config - Gateway configuration
35
+ * @throws Error if security requirements are not met
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // Safe: Using default URL
40
+ * validateGatewayUrlSecurity({ url: "http://localhost:18789" });
41
+ *
42
+ * // Safe: Custom URL with explicit token
43
+ * validateGatewayUrlSecurity({
44
+ * url: "http://custom.gateway.com",
45
+ * token: "very-long-secure-token-32chars+",
46
+ * requireExplicitToken: true
47
+ * });
48
+ *
49
+ * // Unsafe: Custom URL without explicit flag
50
+ * validateGatewayUrlSecurity({
51
+ * url: "http://malicious.com",
52
+ * token: "token"
53
+ * });
54
+ * // Throws: "Gateway URL override requires explicit token flag"
55
+ * ```
56
+ */
57
+ export function validateGatewayUrlSecurity(config) {
58
+ // If URL is default/safe, no additional validation needed
59
+ if (isDefaultGatewayUrl(config.url)) {
60
+ return;
61
+ }
62
+ // Custom URL detected - require explicit confirmation
63
+ if (!config.requireExplicitToken) {
64
+ throw new Error("Gateway URL override requires explicit token flag. " +
65
+ "Set 'requireExplicitToken: true' in your gateway config to confirm " +
66
+ "you want to use a custom gateway URL. This prevents accidental " +
67
+ "credential leakage to untrusted gateways.");
68
+ }
69
+ // Require token when using custom URL
70
+ if (!config.token) {
71
+ throw new Error("Gateway URL override requires a token. " +
72
+ "Provide a secure token when using a custom gateway URL.");
73
+ }
74
+ // Require strong token (32+ characters)
75
+ if (config.token.length < MIN_TOKEN_LENGTH) {
76
+ throw new Error(`Gateway URL override requires a strong token (${MIN_TOKEN_LENGTH}+ characters). ` +
77
+ `Current token length: ${config.token.length} characters.`);
78
+ }
79
+ }
80
+ /**
81
+ * Check if gateway config is safe without throwing
82
+ *
83
+ * @param config - Gateway configuration
84
+ * @returns true if config is safe, false otherwise
85
+ */
86
+ export function isGatewayConfigSafe(config) {
87
+ try {
88
+ validateGatewayUrlSecurity(config);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Media Path Sanitization
3
+ * Prevents path traversal attacks in media attachments
4
+ *
5
+ * Based on OpenClaw 2026.2.4 security fix:
6
+ * - Enforce sandboxed media paths for message tool attachments
7
+ *
8
+ * @version 2026.1.30
9
+ */
10
+ import path from "node:path";
11
+ import fs from "node:fs";
12
+ /**
13
+ * Sanitize media path to prevent path traversal
14
+ *
15
+ * @param inputPath - User-provided path (may contain ../ or absolute paths)
16
+ * @param baseDir - Base directory for media files (sandbox root)
17
+ * @returns Sanitized absolute path within baseDir
18
+ * @throws Error if path is outside baseDir or file doesn't exist
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * // Safe usage:
23
+ * const safe = sanitizeMediaPath("photo.jpg", "/root/.poolbot/media/");
24
+ * // Returns: "/root/.poolbot/media/photo.jpg"
25
+ *
26
+ * // Blocked (path traversal):
27
+ * sanitizeMediaPath("../../etc/passwd", "/root/.poolbot/media/");
28
+ * // Throws: "Path traversal detected"
29
+ * ```
30
+ */
31
+ export function sanitizeMediaPath(inputPath, baseDir) {
32
+ // 1. Normalize base directory (ensure trailing slash)
33
+ const normalizedBase = path.resolve(baseDir);
34
+ // 2. Resolve absolute path (follows ../ and resolves relative paths)
35
+ const resolved = path.resolve(normalizedBase, inputPath);
36
+ // 3. Security check: must be within baseDir
37
+ if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
38
+ throw new Error(`Path traversal detected: "${inputPath}" resolves to "${resolved}" ` +
39
+ `which is outside the allowed directory "${normalizedBase}"`);
40
+ }
41
+ // 4. Check if file exists
42
+ if (!fs.existsSync(resolved)) {
43
+ throw new Error(`File not found: ${inputPath}`);
44
+ }
45
+ // 5. Check if it's a file (not directory)
46
+ const stats = fs.statSync(resolved);
47
+ if (!stats.isFile()) {
48
+ throw new Error(`Path is not a file: ${inputPath}`);
49
+ }
50
+ return resolved;
51
+ }
52
+ /**
53
+ * Sanitize multiple media paths
54
+ *
55
+ * @param inputPaths - Array of user-provided paths
56
+ * @param baseDir - Base directory for media files
57
+ * @returns Array of sanitized paths
58
+ * @throws Error if any path is invalid
59
+ */
60
+ export function sanitizeMediaPaths(inputPaths, baseDir) {
61
+ return inputPaths.map((p) => sanitizeMediaPath(p, baseDir));
62
+ }
63
+ /**
64
+ * Check if path is safe without throwing
65
+ *
66
+ * @param inputPath - User-provided path
67
+ * @param baseDir - Base directory
68
+ * @returns true if path is safe, false otherwise
69
+ */
70
+ export function isMediaPathSafe(inputPath, baseDir) {
71
+ try {
72
+ sanitizeMediaPath(inputPath, baseDir);
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
@@ -350,16 +350,10 @@ export async function prepareSlackMessage(params) {
350
350
  });
351
351
  }
352
352
  const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
353
- const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
354
- .map((entry) => entry?.trim())
355
- .filter((entry) => Boolean(entry))
356
- .filter((entry, index, list) => list.indexOf(entry) === index)
357
- .join("\n");
358
- const systemPromptParts = [
359
- channelDescription ? `Channel description: ${channelDescription}` : null,
360
- channelConfig?.systemPrompt?.trim() || null,
361
- ].filter((entry) => Boolean(entry));
362
- const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
353
+ // SECURITY: Do NOT include channel topic/purpose in system prompt
354
+ // Channel metadata is user-controlled and can cause prompt injection
355
+ // Only include channelConfig.systemPrompt (admin-controlled)
356
+ const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
363
357
  let threadStarterBody;
364
358
  let threadLabel;
365
359
  let threadStarterMedia = null;
@@ -288,16 +288,10 @@ export function registerSlackMonitorSlashCommands(params) {
288
288
  id: isDirectMessage ? command.user_id : command.channel_id,
289
289
  },
290
290
  });
291
- const channelDescription = [channelInfo?.topic, channelInfo?.purpose]
292
- .map((entry) => entry?.trim())
293
- .filter((entry) => Boolean(entry))
294
- .filter((entry, index, list) => list.indexOf(entry) === index)
295
- .join("\n");
296
- const systemPromptParts = [
297
- channelDescription ? `Channel description: ${channelDescription}` : null,
298
- channelConfig?.systemPrompt?.trim() || null,
299
- ].filter((entry) => Boolean(entry));
300
- const groupSystemPrompt = systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
291
+ // SECURITY: Do NOT include channel topic/purpose in system prompt
292
+ // Channel metadata is user-controlled and can cause prompt injection
293
+ // Only include channelConfig.systemPrompt (admin-controlled)
294
+ const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
301
295
  const ctxPayload = finalizeInboundContext({
302
296
  Body: prompt,
303
297
  RawBody: prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolzin/pool-bot",
3
- "version": "2026.1.33",
3
+ "version": "2026.1.35",
4
4
  "description": "🎱 Pool Bot - AI assistant with PLCODE integrations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,253 +0,0 @@
1
- /**
2
- * Tool Usage Capture Hook
3
- *
4
- * Captures tool usage automatically via lifecycle hooks.
5
- * Safe: never throws, async operations, feature-flagged.
6
- *
7
- * Integration:
8
- * - Registers as onToolUse hook
9
- * - Captures all tool calls automatically
10
- * - Stores compressed data in SessionEntry
11
- */
12
- import { TOOL_USAGE_ENABLED, TOOL_USAGE_MAX_RECORDS, createToolUsageRecord, updateToolStats, addToHistory, } from "./tool-usage-storage.js";
13
- // ============================================================================
14
- // Configuration
15
- // ============================================================================
16
- const TOOL_USAGE_BUFFER_FLUSH_INTERVAL = 30000; // 30 seconds
17
- const TOOL_USAGE_BUFFER_MAX_SIZE = 50; // Flush after 50 tool calls
18
- const toolUsageBuffer = new Map();
19
- let bufferFlushScheduled = false;
20
- // ============================================================================
21
- // SessionEntry Extension (Type-safe, backward compatible)
22
- // ============================================================================
23
- /**
24
- * Load tool usage from SessionEntry
25
- * Safe: handles missing data, validates structure
26
- */
27
- export async function loadSessionToolUsage(sessionKey) {
28
- try {
29
- // TODO: Integrate with actual SessionEntry in Phase 4
30
- // For now, return in-memory placeholder
31
- const existing = toolUsageBuffer.get(sessionKey);
32
- if (existing && existing.length > 0) {
33
- // Return computed stats from buffer
34
- const tools = {};
35
- for (const record of existing) {
36
- if (!tools[record.toolName]) {
37
- tools[record.toolName] = {
38
- count: 0,
39
- totalMs: 0,
40
- avgMs: 0,
41
- lastUsed: record.timestamp,
42
- };
43
- }
44
- tools[record.toolName] = updateToolStats(tools[record.toolName], record.executionTimeMs);
45
- }
46
- return {
47
- version: 2,
48
- sessionKey,
49
- sessionId: sessionKey, // Placeholder
50
- tools,
51
- history: existing,
52
- lastCaptured: Date.now(),
53
- };
54
- }
55
- // Return empty usage
56
- return {
57
- version: 2,
58
- sessionKey,
59
- sessionId: sessionKey,
60
- tools: {},
61
- history: [],
62
- lastCaptured: Date.now(),
63
- };
64
- }
65
- catch (err) {
66
- console.error("[tool-usage-capture] Failed to load tool usage:", err);
67
- // Return empty usage on error
68
- return {
69
- version: 2,
70
- sessionKey,
71
- sessionId: sessionKey,
72
- tools: {},
73
- history: [],
74
- lastCaptured: Date.now(),
75
- };
76
- }
77
- }
78
- /**
79
- * Save tool usage to SessionEntry
80
- * Safe: validates data, handles errors gracefully
81
- */
82
- export async function saveSessionToolUsage(sessionKey, usage) {
83
- try {
84
- // TODO: Integrate with actual SessionEntry in Phase 4
85
- // For now, just update in-memory buffer
86
- const existing = toolUsageBuffer.get(sessionKey) || [];
87
- const newRecords = usage.history.slice(existing.length);
88
- if (newRecords.length > 0) {
89
- toolUsageBuffer.set(sessionKey, [...existing, ...newRecords]);
90
- }
91
- if (TOOL_USAGE_ENABLED) {
92
- console.log(`[tool-usage-capture] Saved ${newRecords.length} records for ${sessionKey}`);
93
- }
94
- }
95
- catch (err) {
96
- console.error("[tool-usage-capture] Failed to save tool usage:", err);
97
- // Never throw - errors are logged only
98
- }
99
- }
100
- // ============================================================================
101
- // Hook Implementation
102
- // ============================================================================
103
- /**
104
- * Main hook: captures tool usage automatically
105
- * Safe: validates all inputs, never throws, handles all edge cases
106
- */
107
- export const toolUsageCaptureHook = async (ctx) => {
108
- // Feature flag check (safe exit)
109
- if (!TOOL_USAGE_ENABLED) {
110
- return;
111
- }
112
- try {
113
- // Validate inputs (safe defaults)
114
- const toolName = ctx.toolName || "unknown_tool";
115
- const executionTimeMs = ctx.executionTimeMs || 0;
116
- const success = ctx.success !== undefined ? ctx.success : true;
117
- // Create compressed record
118
- const record = createToolUsageRecord(toolName, ctx.toolInput, ctx.toolOutput, executionTimeMs, success, ctx.error);
119
- // Load existing usage
120
- const usage = await loadSessionToolUsage(ctx.sessionKey);
121
- // Update stats
122
- if (!usage.tools[toolName]) {
123
- usage.tools[toolName] = {
124
- count: 0,
125
- totalMs: 0,
126
- avgMs: 0,
127
- lastUsed: record.timestamp,
128
- };
129
- }
130
- usage.tools[toolName] = updateToolStats(usage.tools[toolName], executionTimeMs);
131
- // Add to history (respects max limit)
132
- usage.history = addToHistory(usage.history, record, TOOL_USAGE_MAX_RECORDS);
133
- usage.lastCaptured = Date.now();
134
- // Save (async, non-blocking)
135
- await saveSessionToolUsage(ctx.sessionKey, usage).catch((err) => {
136
- // Save errors are logged in saveSessionToolUsage
137
- // This catch is just for extra safety
138
- console.error("[tool-usage-capture] Unhandled save error:", err);
139
- });
140
- // Debug logging (can be disabled)
141
- if (success && TOOL_USAGE_ENABLED) {
142
- console.log(`[tool-usage-capture] ${toolName} (${executionTimeMs}ms) - ${ctx.sessionKey}`);
143
- }
144
- }
145
- catch (err) {
146
- // NEVER throw from hook - would break the tool call
147
- console.error("[tool-usage-capture] Hook error:", err);
148
- }
149
- };
150
- // ============================================================================
151
- // Buffer Management (performance optimization)
152
- // ============================================================================
153
- /**
154
- * Flush in-memory buffer to storage
155
- * Safe: handles errors, never throws
156
- */
157
- export async function flushToolUsageBuffer() {
158
- try {
159
- if (toolUsageBuffer.size === 0) {
160
- return; // Nothing to flush
161
- }
162
- let flushed = 0;
163
- for (const [sessionKey, records] of toolUsageBuffer.entries()) {
164
- try {
165
- // TODO: Actually persist to SessionEntry in Phase 4
166
- // For now, buffer stays in-memory
167
- flushed += records.length;
168
- }
169
- catch (err) {
170
- console.error(`[tool-usage-capture] Failed to flush buffer for ${sessionKey}:`, err);
171
- }
172
- }
173
- if (flushed > 0 && TOOL_USAGE_ENABLED) {
174
- console.log(`[tool-usage-capture] Flushed ${flushed} records from buffer`);
175
- }
176
- }
177
- catch (err) {
178
- console.error("[tool-usage-capture] Buffer flush error:", err);
179
- }
180
- }
181
- /**
182
- * Schedule buffer flush
183
- * Safe: debounced, only schedules once
184
- */
185
- function scheduleBufferFlush() {
186
- if (bufferFlushScheduled) {
187
- return; // Already scheduled
188
- }
189
- bufferFlushScheduled = true;
190
- // Flush after 30 seconds
191
- setTimeout(async () => {
192
- await flushToolUsageBuffer();
193
- bufferFlushScheduled = false;
194
- }, TOOL_USAGE_BUFFER_FLUSH_INTERVAL);
195
- }
196
- // ============================================================================
197
- // Query API (for future use)
198
- // ============================================================================
199
- /**
200
- * Get tool usage statistics for a session
201
- * Safe: returns empty object if no data
202
- */
203
- export async function getSessionToolUsage(sessionKey) {
204
- try {
205
- const usage = await loadSessionToolUsage(sessionKey);
206
- return usage;
207
- }
208
- catch (err) {
209
- console.error(`[tool-usage-capture] Failed to get usage for ${sessionKey}:`, err);
210
- return undefined;
211
- }
212
- }
213
- /**
214
- * Get recent tool usage (last N records)
215
- * Safe: returns empty array if no data
216
- */
217
- export async function getRecentToolUsage(sessionKey, count = 10) {
218
- try {
219
- const usage = await loadSessionToolUsage(sessionKey);
220
- return usage.history.slice(-count);
221
- }
222
- catch (err) {
223
- console.error(`[tool-usage-capture] Failed to get recent usage for ${sessionKey}:`, err);
224
- return [];
225
- }
226
- }
227
- // ============================================================================
228
- // Cleanup
229
- // ============================================================================
230
- /**
231
- * Clear tool usage buffer (for testing)
232
- * Safe: never throws
233
- */
234
- export function clearToolUsageBuffer() {
235
- try {
236
- toolUsageBuffer.clear();
237
- }
238
- catch (err) {
239
- console.error("[tool-usage-capture] Buffer clear error:", err);
240
- }
241
- }
242
- /**
243
- * Clear tool usage for a specific session
244
- * Safe: never throws
245
- */
246
- export function clearSessionToolUsage(sessionKey) {
247
- try {
248
- toolUsageBuffer.delete(sessionKey);
249
- }
250
- catch (err) {
251
- console.error(`[tool-usage-capture] Failed to clear usage for ${sessionKey}:`, err);
252
- }
253
- }
@@ -1,144 +0,0 @@
1
- /**
2
- * Tool Usage Capture - Storage Layer
3
- *
4
- * Safe, efficient storage for tool usage data.
5
- * Phase 2a: Type definitions and interfaces (NO breaking changes)
6
- *
7
- * Safety measures:
8
- * - All fields optional (backward compatible)
9
- * - Version field for future migrations
10
- * - No modifications to existing SessionEntry fields
11
- */
12
- // ============================================================================
13
- // Constants
14
- // ============================================================================
15
- export const TOOL_USAGE_MAX_RECORDS = 100; // Keep only last 100 records
16
- export const TOOL_USAGE_MAX_SUMMARY_LEN = 100; // Max 100 chars per summary
17
- export const TOOL_USAGE_ENABLED = true; // Feature flag (master switch)
18
- // ============================================================================
19
- // Compression Functions (safe, no side effects)
20
- // ============================================================================
21
- /**
22
- * Compress a tool input/output value to a short summary
23
- * Safe: never throws, handles all types gracefully
24
- */
25
- export function compressToolValue(value, maxLen = TOOL_USAGE_MAX_SUMMARY_LEN) {
26
- try {
27
- // Handle primitives
28
- if (value === null)
29
- return "null";
30
- if (value === undefined)
31
- return "undefined";
32
- if (typeof value === "string") {
33
- return value.length > maxLen ? value.slice(0, maxLen) + "..." : value;
34
- }
35
- if (typeof value === "number")
36
- return String(value);
37
- if (typeof value === "boolean")
38
- return String(value);
39
- // Handle objects and arrays
40
- if (typeof value === "object") {
41
- const json = JSON.stringify(value);
42
- return json.length > maxLen ? json.slice(0, maxLen) + "..." : json;
43
- }
44
- // Fallback
45
- return String(value);
46
- }
47
- catch (err) {
48
- // If compression fails, return safe fallback
49
- return "[compression_error]";
50
- }
51
- }
52
- /**
53
- * Create a tool usage record from hook context
54
- * Safe: validates all inputs, never throws
55
- */
56
- export function createToolUsageRecord(toolName, toolInput, toolOutput, executionTimeMs, success, error) {
57
- // Validate inputs (safe defaults)
58
- const safeToolName = typeof toolName === "string" && toolName.length > 0 ? toolName : "unknown_tool";
59
- const safeInput = typeof toolInput === "object" && toolInput !== null ? toolInput : {};
60
- const safeTime = typeof executionTimeMs === "number" && executionTimeMs >= 0 ? executionTimeMs : 0;
61
- const safeSuccess = Boolean(success);
62
- return {
63
- toolName: safeToolName,
64
- timestamp: Date.now(),
65
- executionTimeMs: safeTime,
66
- success: safeSuccess,
67
- inputSummary: compressToolValue(safeInput),
68
- outputSummary: compressToolValue(toolOutput),
69
- error: error ? String(error).slice(0, 200) : undefined,
70
- };
71
- }
72
- // ============================================================================
73
- // Statistics Update (pure function, no side effects)
74
- // ============================================================================
75
- /**
76
- * Update tool statistics with a new record
77
- * Safe: never mutates input, returns new object
78
- */
79
- export function updateToolStats(existingStats, executionTimeMs) {
80
- const count = (existingStats?.count ?? 0) + 1;
81
- const totalMs = (existingStats?.totalMs ?? 0) + executionTimeMs;
82
- const avgMs = count > 0 ? Math.round((totalMs / count) * 100) / 100 : 0;
83
- return {
84
- count,
85
- totalMs,
86
- avgMs,
87
- lastUsed: Date.now(),
88
- };
89
- }
90
- // ============================================================================
91
- // History Management (pure functions)
92
- // ============================================================================
93
- /**
94
- * Add record to history, respecting max limit
95
- * Safe: never exceeds max records, returns new array
96
- */
97
- export function addToHistory(history, record, maxRecords = TOOL_USAGE_MAX_RECORDS) {
98
- const newHistory = [...history, record];
99
- // Keep only last N records
100
- if (newHistory.length > maxRecords) {
101
- return newHistory.slice(-maxRecords);
102
- }
103
- return newHistory;
104
- }
105
- /**
106
- * Get recent tool usage (last N records)
107
- * Safe: never mutates input, returns slice
108
- */
109
- export function getRecentToolUsage(history, count = 10) {
110
- return history.slice(-count);
111
- }
112
- // ============================================================================
113
- // Query Functions (for future use)
114
- // ============================================================================
115
- /**
116
- * Get statistics for a specific tool
117
- * Safe: returns undefined if tool not found
118
- */
119
- export function getToolStats(usage, toolName) {
120
- return usage?.tools[toolName];
121
- }
122
- /**
123
- * Get all tool names sorted by usage frequency
124
- * Safe: returns empty array if no data
125
- */
126
- export function getToolsByFrequency(usage) {
127
- if (!usage?.tools)
128
- return [];
129
- return Object.entries(usage.tools)
130
- .map(([toolName, stats]) => ({ toolName, count: stats.count }))
131
- .sort((a, b) => b.count - a.count);
132
- }
133
- /**
134
- * Get slowest tools (by average execution time)
135
- * Safe: returns empty array if no data
136
- */
137
- export function getSlowestTools(usage, limit = 5) {
138
- if (!usage?.tools)
139
- return [];
140
- return Object.entries(usage.tools)
141
- .map(([toolName, stats]) => ({ toolName, avgMs: stats.avgMs }))
142
- .sort((a, b) => b.avgMs - a.avgMs)
143
- .slice(0, limit);
144
- }