@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 +84 -0
- package/dist/agents/pi-embedded-helpers/errors.js +0 -2
- package/dist/agents/session-tool-result-guard.js +2 -2
- package/dist/agents/session-transcript-repair.js +1 -20
- package/dist/agents/tool-security-helpers.js +66 -0
- package/dist/agents/tool-security.js +96 -0
- package/dist/agents/workspace.js +5 -1
- package/dist/build-info.json +3 -3
- package/dist/channels/plugins/agent-tools/whatsapp-login.js +17 -1
- package/dist/commands/doctor-state-integrity.js +14 -2
- package/dist/config/types.js +1 -0
- package/dist/config/types.security.js +5 -0
- package/dist/config/zod-schema.js +6 -0
- package/dist/discord/monitor/message-handler.process.js +4 -6
- package/dist/entry.js +0 -0
- package/dist/gateway/client.js +14 -0
- package/dist/gateway/hooks/index.js +0 -2
- package/dist/gateway/url-validation.js +94 -0
- package/dist/media/path-sanitization.js +78 -0
- package/dist/slack/monitor/message-handler/prepare.js +4 -10
- package/dist/slack/monitor/slash.js +4 -10
- package/package.json +1 -1
- package/dist/gateway/hooks/tool-usage-capture.js +0 -253
- package/dist/gateway/hooks/tool-usage-storage.js +0 -144
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { makeMissingToolResult
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/dist/agents/workspace.js
CHANGED
|
@@ -208,7 +208,11 @@ export async function loadWorkspaceBootstrapFiles(dir) {
|
|
|
208
208
|
filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
|
|
209
209
|
},
|
|
210
210
|
];
|
|
211
|
-
|
|
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 {
|
package/dist/build-info.json
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
-
|
|
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
|
-
|
|
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.`);
|
package/dist/config/types.js
CHANGED
|
@@ -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";
|
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
package/dist/gateway/client.js
CHANGED
|
@@ -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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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,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
|
-
}
|