@lobu/worker 3.0.5 → 3.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/USAGE.md +120 -0
  2. package/docs/custom-base-image.md +88 -0
  3. package/package.json +2 -2
  4. package/scripts/worker-entrypoint.sh +184 -0
  5. package/src/__tests__/audio-provider-suggestions.test.ts +198 -0
  6. package/src/__tests__/embedded-just-bash-bootstrap.test.ts +39 -0
  7. package/src/__tests__/embedded-tools.test.ts +558 -0
  8. package/src/__tests__/instructions.test.ts +59 -0
  9. package/src/__tests__/memory-flush-runtime.test.ts +138 -0
  10. package/src/__tests__/memory-flush.test.ts +64 -0
  11. package/src/__tests__/model-resolver.test.ts +156 -0
  12. package/src/__tests__/processor.test.ts +225 -0
  13. package/src/__tests__/setup.ts +109 -0
  14. package/src/__tests__/sse-client.test.ts +48 -0
  15. package/src/__tests__/tool-policy.test.ts +269 -0
  16. package/src/__tests__/worker.test.ts +89 -0
  17. package/src/core/error-handler.ts +70 -0
  18. package/src/core/project-scanner.ts +65 -0
  19. package/src/core/types.ts +125 -0
  20. package/src/core/url-utils.ts +9 -0
  21. package/src/core/workspace.ts +138 -0
  22. package/src/embedded/just-bash-bootstrap.ts +228 -0
  23. package/src/gateway/gateway-integration.ts +287 -0
  24. package/src/gateway/message-batcher.ts +128 -0
  25. package/src/gateway/sse-client.ts +955 -0
  26. package/src/gateway/types.ts +68 -0
  27. package/src/index.ts +146 -0
  28. package/src/instructions/builder.ts +80 -0
  29. package/src/instructions/providers.ts +27 -0
  30. package/src/modules/lifecycle.ts +92 -0
  31. package/src/openclaw/custom-tools.ts +290 -0
  32. package/src/openclaw/instructions.ts +38 -0
  33. package/src/openclaw/model-resolver.ts +150 -0
  34. package/src/openclaw/plugin-loader.ts +427 -0
  35. package/src/openclaw/processor.ts +216 -0
  36. package/src/openclaw/session-context.ts +277 -0
  37. package/src/openclaw/tool-policy.ts +212 -0
  38. package/src/openclaw/tools.ts +208 -0
  39. package/src/openclaw/worker.ts +1792 -0
  40. package/src/server.ts +329 -0
  41. package/src/shared/audio-provider-suggestions.ts +132 -0
  42. package/src/shared/processor-utils.ts +33 -0
  43. package/src/shared/provider-auth-hints.ts +64 -0
  44. package/src/shared/tool-display-config.ts +75 -0
  45. package/src/shared/tool-implementations.ts +768 -0
  46. package/tsconfig.json +21 -0
@@ -0,0 +1,216 @@
1
+ import { createLogger } from "@lobu/core";
2
+ import type { AgentSessionEvent } from "@mariozechner/pi-coding-agent";
3
+ import { formatToolExecution } from "../shared/processor-utils";
4
+
5
+ const logger = createLogger("openclaw-processor");
6
+
7
+ /**
8
+ * Processes Pi agent streaming events and extracts user-friendly content.
9
+ * Implements chronological display with tool progress and mixed text/tool output.
10
+ */
11
+ export class OpenClawProgressProcessor {
12
+ private chronologicalOutput = "";
13
+ private lastSentContent = "";
14
+ private currentThinking = "";
15
+ private verboseLogging = false;
16
+ private finalResult: { text: string; isFinal: boolean } | null = null;
17
+ private hasStreamedText = false;
18
+ private fatalErrorMessage: string | null = null;
19
+
20
+ setVerboseLogging(enabled: boolean): void {
21
+ this.verboseLogging = enabled;
22
+ logger.info(`Verbose logging ${enabled ? "enabled" : "disabled"}`);
23
+ }
24
+
25
+ /**
26
+ * Process a Pi agent session event and append to chronological output.
27
+ * Returns true if new content was appended.
28
+ */
29
+ processEvent(event: AgentSessionEvent): boolean {
30
+ switch (event.type) {
31
+ case "message_update": {
32
+ if (event.message.role !== "assistant") {
33
+ return false;
34
+ }
35
+ const assistantEvent = event.assistantMessageEvent;
36
+
37
+ if (assistantEvent.type === "text_delta") {
38
+ this.hasStreamedText = true;
39
+ this.chronologicalOutput += assistantEvent.delta;
40
+ return true;
41
+ }
42
+
43
+ if (assistantEvent.type === "thinking_delta") {
44
+ this.currentThinking += assistantEvent.delta;
45
+ if (this.verboseLogging) {
46
+ this.chronologicalOutput += assistantEvent.delta;
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ if (assistantEvent.type === "thinking_start" && this.verboseLogging) {
53
+ this.chronologicalOutput += "\nšŸ’­ *Reasoning:*\n";
54
+ return true;
55
+ }
56
+
57
+ if (assistantEvent.type === "thinking_end" && this.verboseLogging) {
58
+ this.chronologicalOutput += "\n\n";
59
+ return true;
60
+ }
61
+
62
+ return false;
63
+ }
64
+
65
+ case "message_end": {
66
+ if (event.message.role !== "assistant") {
67
+ return false;
68
+ }
69
+ const assistantMessage = event.message as {
70
+ stopReason?: string;
71
+ errorMessage?: string;
72
+ };
73
+ if (
74
+ assistantMessage.stopReason === "error" &&
75
+ typeof assistantMessage.errorMessage === "string" &&
76
+ assistantMessage.errorMessage.trim()
77
+ ) {
78
+ this.fatalErrorMessage = assistantMessage.errorMessage.trim();
79
+ return false;
80
+ }
81
+ // If text was already streamed via deltas, skip extraction
82
+ if (this.hasStreamedText) {
83
+ return false;
84
+ }
85
+ // Fallback: extract text from final message content
86
+ const content = event.message.content;
87
+ if (!Array.isArray(content)) {
88
+ return false;
89
+ }
90
+ let extracted = false;
91
+ for (const block of content) {
92
+ if (
93
+ block &&
94
+ typeof block === "object" &&
95
+ "type" in block &&
96
+ (block as { type: string }).type === "text" &&
97
+ "text" in block &&
98
+ typeof (block as { text: unknown }).text === "string"
99
+ ) {
100
+ const text = (block as { text: string }).text;
101
+ if (text.trim()) {
102
+ this.chronologicalOutput += text;
103
+ extracted = true;
104
+ }
105
+ }
106
+ }
107
+ return extracted;
108
+ }
109
+
110
+ case "tool_execution_start": {
111
+ const params =
112
+ event.args && typeof event.args === "object"
113
+ ? (event.args as Record<string, unknown>)
114
+ : {};
115
+ const formatted = formatToolExecution(
116
+ event.toolName,
117
+ params,
118
+ this.verboseLogging
119
+ );
120
+ if (formatted) {
121
+ this.chronologicalOutput += `${formatted}\n`;
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ case "auto_compaction_start": {
128
+ this.chronologicalOutput += "šŸ—œļø *Compacting context...*\n";
129
+ return true;
130
+ }
131
+
132
+ case "auto_compaction_end": {
133
+ if (event.aborted) {
134
+ this.chronologicalOutput += "šŸ—œļø *Compaction aborted*\n";
135
+ } else if (event.result) {
136
+ this.chronologicalOutput += "šŸ—œļø *Context compacted*\n";
137
+ }
138
+ return true;
139
+ }
140
+
141
+ case "auto_retry_start": {
142
+ this.chronologicalOutput += `šŸ”„ *Retrying (attempt ${event.attempt}/${event.maxAttempts})...*\n`;
143
+ return true;
144
+ }
145
+
146
+ case "auto_retry_end": {
147
+ if (!event.success && event.finalError) {
148
+ this.chronologicalOutput += `šŸ”„ *Retry failed: ${event.finalError}*\n`;
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ default:
155
+ return false;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Get delta since last sent content.
161
+ * Returns null if no new content.
162
+ */
163
+ getDelta(): string | null {
164
+ const fullContent = this.chronologicalOutput.trim();
165
+
166
+ if (!fullContent) {
167
+ return null;
168
+ }
169
+
170
+ if (fullContent === this.lastSentContent) {
171
+ return null;
172
+ }
173
+
174
+ if (this.lastSentContent && fullContent.startsWith(this.lastSentContent)) {
175
+ const delta = fullContent.slice(this.lastSentContent.length);
176
+ this.lastSentContent = fullContent;
177
+ return delta;
178
+ }
179
+
180
+ this.lastSentContent = fullContent;
181
+ return fullContent;
182
+ }
183
+
184
+ setFinalResult(result: { text: string; isFinal: boolean }): void {
185
+ this.finalResult = result;
186
+ }
187
+
188
+ getFinalResult(): { text: string; isFinal: boolean } | null {
189
+ const result = this.finalResult;
190
+ this.finalResult = null;
191
+ return result;
192
+ }
193
+
194
+ consumeFatalErrorMessage(): string | null {
195
+ const result = this.fatalErrorMessage;
196
+ this.fatalErrorMessage = null;
197
+ return result;
198
+ }
199
+
200
+ getCurrentThinking(): string | null {
201
+ return this.currentThinking || null;
202
+ }
203
+
204
+ getOutputSnapshot(): string {
205
+ return this.chronologicalOutput.trim();
206
+ }
207
+
208
+ reset(): void {
209
+ this.lastSentContent = "";
210
+ this.chronologicalOutput = "";
211
+ this.currentThinking = "";
212
+ this.finalResult = null;
213
+ this.hasStreamedText = false;
214
+ this.fatalErrorMessage = null;
215
+ }
216
+ }
@@ -0,0 +1,277 @@
1
+ import {
2
+ type ConfigProviderMeta,
3
+ createLogger,
4
+ type McpToolDef,
5
+ } from "@lobu/core";
6
+ import { ensureBaseUrl } from "../core/url-utils";
7
+
8
+ const logger = createLogger("openclaw-session-context");
9
+
10
+ interface McpStatus {
11
+ id: string;
12
+ name: string;
13
+ requiresAuth: boolean;
14
+ requiresInput: boolean;
15
+ authenticated: boolean;
16
+ configured: boolean;
17
+ }
18
+
19
+ export interface ProviderConfig {
20
+ credentialEnvVarName?: string;
21
+ defaultProvider?: string;
22
+ defaultModel?: string;
23
+ cliBackends?: Array<{
24
+ providerId: string;
25
+ name: string;
26
+ command: string;
27
+ args?: string[];
28
+ env?: Record<string, string>;
29
+ modelArg?: string;
30
+ sessionArg?: string;
31
+ }>;
32
+ providerBaseUrlMappings?: Record<string, string>;
33
+ /** Dynamic provider metadata from config-driven providers */
34
+ configProviders?: Record<string, ConfigProviderMeta>;
35
+ /** Credential env var placeholders for proxy mode (e.g. Z_AI_API_KEY → "lobu-proxy") */
36
+ credentialPlaceholders?: Record<string, string>;
37
+ }
38
+
39
+ interface SkillContent {
40
+ name: string;
41
+ content: string;
42
+ }
43
+
44
+ interface SessionContextResponse {
45
+ agentInstructions: string;
46
+ platformInstructions: string;
47
+ networkInstructions: string;
48
+ skillsInstructions: string;
49
+ mcpStatus: McpStatus[];
50
+ mcpTools?: Record<string, McpToolDef[]>;
51
+ mcpInstructions?: Record<string, string>;
52
+ mcpContext?: Record<string, string>;
53
+ providerConfig?: ProviderConfig;
54
+ skillsConfig?: SkillContent[];
55
+ }
56
+
57
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
58
+
59
+ const DEFAULT_SESSION_CONTEXT = {
60
+ gatewayInstructions: "",
61
+ providerConfig: {} as ProviderConfig,
62
+ skillsConfig: [] as SkillContent[],
63
+ mcpTools: {} as Record<string, McpToolDef[]>,
64
+ mcpContext: {} as Record<string, string>,
65
+ } as const;
66
+
67
+ // Module-level cache for session context
68
+ let cachedResult: {
69
+ gatewayInstructions: string;
70
+ providerConfig: ProviderConfig;
71
+ skillsConfig: SkillContent[];
72
+ mcpTools: Record<string, McpToolDef[]>;
73
+ mcpContext: Record<string, string>;
74
+ cachedAt: number;
75
+ } | null = null;
76
+
77
+ /**
78
+ * Invalidate the session context cache.
79
+ * Called by the SSE client when a config_changed event is received.
80
+ */
81
+ export function invalidateSessionContextCache(): void {
82
+ cachedResult = null;
83
+ logger.info("Session context cache invalidated");
84
+ }
85
+
86
+ function buildMcpInstructions(
87
+ mcpStatus: McpStatus[],
88
+ mcpToolIds: Set<string>
89
+ ): string {
90
+ if (!mcpStatus || mcpStatus.length === 0) {
91
+ return "";
92
+ }
93
+
94
+ // MCPs with no tools at all that need setup (explicit auth/input requirements)
95
+ const undiscoveredMcps = mcpStatus.filter(
96
+ (mcp) =>
97
+ !mcpToolIds.has(mcp.id) &&
98
+ ((mcp.requiresAuth && !mcp.authenticated) ||
99
+ (mcp.requiresInput && !mcp.configured))
100
+ );
101
+
102
+ // MCPs with tools visible but still needing auth to use them
103
+ const unauthenticatedMcps = mcpStatus.filter(
104
+ (mcp) => mcpToolIds.has(mcp.id) && mcp.requiresAuth && !mcp.authenticated
105
+ );
106
+
107
+ // MCPs with no tools and no explicit auth requirement — may need plugin-level
108
+ // authentication (e.g. owletto device auth flow via owletto_login tool)
109
+ const pluginAuthMcps = mcpStatus.filter(
110
+ (mcp) => !mcpToolIds.has(mcp.id) && !mcp.requiresAuth && !mcp.requiresInput
111
+ );
112
+
113
+ if (
114
+ undiscoveredMcps.length === 0 &&
115
+ unauthenticatedMcps.length === 0 &&
116
+ pluginAuthMcps.length === 0
117
+ ) {
118
+ return "";
119
+ }
120
+
121
+ const lines: string[] = ["## MCP Tools Requiring Setup"];
122
+
123
+ for (const mcp of undiscoveredMcps) {
124
+ const reasons: string[] = [];
125
+ if (mcp.requiresAuth && !mcp.authenticated) {
126
+ reasons.push("OAuth authentication");
127
+ }
128
+ if (mcp.requiresInput && !mcp.configured) {
129
+ reasons.push("configuration");
130
+ }
131
+
132
+ lines.push(
133
+ `- āš ļø **${mcp.name}** (id: ${mcp.id}): Requires ${reasons.join(" and ")}. Start the relevant login flow if a login tool exists; otherwise tell the user an admin must connect it.`
134
+ );
135
+ }
136
+
137
+ for (const mcp of unauthenticatedMcps) {
138
+ lines.push(
139
+ `- āš ļø **${mcp.name}** (id: ${mcp.id}): Tools are visible but require authentication to use. Start the relevant login flow if available; otherwise tell the user an admin must connect it.`
140
+ );
141
+ }
142
+
143
+ for (const mcp of pluginAuthMcps) {
144
+ lines.push(
145
+ `- **IMPORTANT** — **${mcp.name}** (id: ${mcp.id}): Memory tools (save_knowledge, search_knowledge) are NOT available because authentication is required. You MUST call the \`${mcp.id}_login\` tool NOW to start the login flow. After the user completes login in their browser, call \`${mcp.id}_login_check\` to finish authentication. Do NOT tell the user that memory is unavailable — instead, initiate login immediately.`
146
+ );
147
+ }
148
+
149
+ return lines.join("\n");
150
+ }
151
+
152
+ function buildMcpServerInstructions(
153
+ mcpInstructions: Record<string, string>
154
+ ): string {
155
+ const entries = Object.entries(mcpInstructions).filter(([, v]) => v);
156
+ if (entries.length === 0) return "";
157
+
158
+ const lines: string[] = ["## MCP Server Instructions", ""];
159
+ for (const [mcpId, instructions] of entries) {
160
+ lines.push(`### ${mcpId}`, "", instructions, "");
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+
165
+ /**
166
+ * Fetch session context from gateway for OpenClaw worker.
167
+ * Returns gateway instructions and dynamic provider configuration.
168
+ * Caches the result until invalidated by a config_changed SSE event.
169
+ * Skips MCP server config (OpenClaw doesn't use Claude SDK's MCP format).
170
+ */
171
+ export async function getOpenClawSessionContext(): Promise<{
172
+ gatewayInstructions: string;
173
+ providerConfig: ProviderConfig;
174
+ skillsConfig: SkillContent[];
175
+ mcpTools: Record<string, McpToolDef[]>;
176
+ mcpContext: Record<string, string>;
177
+ }> {
178
+ if (cachedResult && Date.now() - cachedResult.cachedAt < CACHE_TTL_MS) {
179
+ logger.debug("Returning cached session context");
180
+ return cachedResult;
181
+ }
182
+
183
+ const dispatcherUrl = process.env.DISPATCHER_URL;
184
+ const workerToken = process.env.WORKER_TOKEN;
185
+
186
+ if (!dispatcherUrl || !workerToken) {
187
+ logger.warn("Missing dispatcher URL or worker token for session context");
188
+ return { ...DEFAULT_SESSION_CONTEXT };
189
+ }
190
+
191
+ try {
192
+ const url = new URL(
193
+ "/worker/session-context",
194
+ ensureBaseUrl(dispatcherUrl)
195
+ );
196
+ const response = await fetch(url, {
197
+ headers: {
198
+ Authorization: `Bearer ${workerToken}`,
199
+ },
200
+ });
201
+
202
+ if (!response.ok) {
203
+ logger.warn("Gateway returned non-success status for session context", {
204
+ status: response.status,
205
+ });
206
+ return { ...DEFAULT_SESSION_CONTEXT };
207
+ }
208
+
209
+ const data = (await response.json()) as SessionContextResponse;
210
+
211
+ logger.info(
212
+ `Received session context: ${data.platformInstructions.length} chars platform instructions, ${data.mcpStatus.length} MCP status entries, provider: ${data.providerConfig?.defaultProvider || "none"}, cliBackends: ${data.providerConfig?.cliBackends?.map((b) => b.name).join(", ") || "none"}`
213
+ );
214
+
215
+ const toolMcpIds = new Set(Object.keys(data.mcpTools || {}));
216
+ const mcpSetupInstructions = buildMcpInstructions(
217
+ data.mcpStatus,
218
+ toolMcpIds
219
+ );
220
+ // Include MCP server instructions for all servers (with or without tools).
221
+ // These provide workspace context (available connectors, entity schemas, etc.)
222
+ // that helps the agent use the tools effectively.
223
+ const mcpServerInstructions = buildMcpServerInstructions(
224
+ data.mcpInstructions || {}
225
+ );
226
+
227
+ const gatewayInstructions = [
228
+ data.agentInstructions,
229
+ data.platformInstructions,
230
+ data.networkInstructions,
231
+ data.skillsInstructions,
232
+ mcpSetupInstructions,
233
+ mcpServerInstructions,
234
+ ]
235
+ .filter(Boolean)
236
+ .join("\n\n");
237
+
238
+ const mcpTools = data.mcpTools || {};
239
+
240
+ logger.info(
241
+ `Built gateway instructions: agent (${(data.agentInstructions || "").length} chars) + platform (${data.platformInstructions.length} chars) + network (${data.networkInstructions.length} chars) + skills (${(data.skillsInstructions || "").length} chars) + MCP setup (${mcpSetupInstructions.length} chars) + MCP server instructions (${mcpServerInstructions.length} chars), mcpTools: ${Object.keys(mcpTools).length} servers`
242
+ );
243
+
244
+ const mcpContext = data.mcpContext || {};
245
+
246
+ const result = {
247
+ gatewayInstructions,
248
+ providerConfig: data.providerConfig || {},
249
+ skillsConfig: data.skillsConfig || [],
250
+ mcpTools,
251
+ mcpContext,
252
+ };
253
+
254
+ // Don't cache if any authenticated MCP returned no tools — likely a
255
+ // transient upstream failure that should be retried on the next message.
256
+ const hasEmptyAuthenticatedMcp = data.mcpStatus.some(
257
+ (mcp) => mcp.authenticated && !toolMcpIds.has(mcp.id)
258
+ );
259
+ if (!hasEmptyAuthenticatedMcp) {
260
+ cachedResult = { ...result, cachedAt: Date.now() };
261
+ } else {
262
+ logger.warn(
263
+ "Skipping session context cache — authenticated MCP(s) returned no tools",
264
+ {
265
+ emptyMcps: data.mcpStatus
266
+ .filter((mcp) => mcp.authenticated && !toolMcpIds.has(mcp.id))
267
+ .map((mcp) => mcp.id),
268
+ }
269
+ );
270
+ }
271
+
272
+ return result;
273
+ } catch (error) {
274
+ logger.error("Failed to fetch session context from gateway", { error });
275
+ return { ...DEFAULT_SESSION_CONTEXT };
276
+ }
277
+ }
@@ -0,0 +1,212 @@
1
+ import type { ToolsConfig } from "@lobu/core";
2
+
3
+ export type BashCommandPolicy = {
4
+ allowAll: boolean;
5
+ allowPrefixes: string[];
6
+ denyPrefixes: string[];
7
+ };
8
+
9
+ export type ToolPolicy = {
10
+ toolsConfig?: ToolsConfig;
11
+ allowedPatterns: string[];
12
+ deniedPatterns: string[];
13
+ strictMode: boolean;
14
+ bashPolicy: BashCommandPolicy;
15
+ };
16
+
17
+ const DEFAULT_PACKAGE_MANAGER_DENY_PREFIXES = [
18
+ "apt ",
19
+ "apt-get ",
20
+ "yum ",
21
+ "dnf ",
22
+ "apk ",
23
+ "pacman ",
24
+ "zypper ",
25
+ "brew ",
26
+ "nix-shell ",
27
+ "nix-env ",
28
+ "nix profile ",
29
+ "sudo apt ",
30
+ "sudo apt-get ",
31
+ "sudo yum ",
32
+ "sudo dnf ",
33
+ "sudo apk ",
34
+ "sudo pacman ",
35
+ "sudo zypper ",
36
+ "sudo brew ",
37
+ "sudo nix-shell ",
38
+ "sudo nix-env ",
39
+ "sudo nix profile ",
40
+ ];
41
+
42
+ export function isDirectPackageInstallCommand(command: string): boolean {
43
+ const trimmed = command.trim().toLowerCase();
44
+ if (!trimmed) {
45
+ return false;
46
+ }
47
+
48
+ return DEFAULT_PACKAGE_MANAGER_DENY_PREFIXES.some((prefix) =>
49
+ trimmed.startsWith(prefix.toLowerCase())
50
+ );
51
+ }
52
+
53
+ function normalizePattern(pattern: string): string {
54
+ return pattern.trim();
55
+ }
56
+
57
+ function normalizeToolName(name: string): string {
58
+ return name.trim().toLowerCase();
59
+ }
60
+
61
+ export function normalizeToolList(value?: string | string[]): string[] {
62
+ if (!value) {
63
+ return [];
64
+ }
65
+ const rawList = Array.isArray(value) ? value : value.split(/[,\n]/);
66
+ return rawList
67
+ .map((entry) =>
68
+ typeof entry === "string" ? entry.trim() : String(entry).trim()
69
+ )
70
+ .filter((entry) => entry.length > 0);
71
+ }
72
+
73
+ function parseBashFilter(pattern: string): string | null {
74
+ const match = pattern.match(/^Bash\(([^:]+):\*\)$/i);
75
+ if (!match) {
76
+ return null;
77
+ }
78
+ const prefix = match[1]?.trim();
79
+ return prefix ? prefix : null;
80
+ }
81
+
82
+ function matchesToolPattern(toolName: string, pattern: string): boolean {
83
+ const normalizedTool = normalizeToolName(toolName);
84
+ const normalizedPattern = normalizePattern(pattern);
85
+ const normalizedPatternLower = normalizedPattern.toLowerCase();
86
+
87
+ if (normalizedPattern === "*") {
88
+ return true;
89
+ }
90
+
91
+ if (normalizedPatternLower.endsWith("*")) {
92
+ const prefix = normalizedPatternLower.slice(0, -1);
93
+ return normalizedTool.startsWith(prefix);
94
+ }
95
+
96
+ return normalizedTool === normalizedPatternLower;
97
+ }
98
+
99
+ export function buildToolPolicy(params: {
100
+ toolsConfig?: ToolsConfig;
101
+ allowedTools?: string | string[];
102
+ disallowedTools?: string | string[];
103
+ }): ToolPolicy {
104
+ const allowedPatterns = normalizeToolList(params.allowedTools);
105
+ const deniedPatterns = normalizeToolList(params.disallowedTools);
106
+ const toolsConfig = params.toolsConfig;
107
+ const strictMode = toolsConfig?.strictMode === true;
108
+
109
+ const mergedAllowed = [
110
+ ...(toolsConfig?.allowedTools ?? []),
111
+ ...allowedPatterns,
112
+ ].map(normalizePattern);
113
+ const mergedDenied = [
114
+ ...(toolsConfig?.deniedTools ?? []),
115
+ ...deniedPatterns,
116
+ ].map(normalizePattern);
117
+
118
+ const bashAllowPrefixes = mergedAllowed
119
+ .map((pattern) => parseBashFilter(pattern))
120
+ .filter((prefix): prefix is string => Boolean(prefix));
121
+
122
+ const bashDenyPrefixes = mergedDenied
123
+ .map((pattern) => parseBashFilter(pattern))
124
+ .filter((prefix): prefix is string => Boolean(prefix));
125
+
126
+ const bashAllowAll = mergedAllowed.some((pattern) =>
127
+ matchesToolPattern("bash", pattern)
128
+ );
129
+
130
+ return {
131
+ toolsConfig,
132
+ allowedPatterns: mergedAllowed,
133
+ deniedPatterns: mergedDenied,
134
+ strictMode,
135
+ bashPolicy: {
136
+ allowAll: bashAllowAll,
137
+ allowPrefixes: bashAllowPrefixes,
138
+ denyPrefixes: [
139
+ ...DEFAULT_PACKAGE_MANAGER_DENY_PREFIXES,
140
+ ...bashDenyPrefixes,
141
+ ],
142
+ },
143
+ };
144
+ }
145
+
146
+ export function isToolAllowedByPolicy(
147
+ toolName: string,
148
+ policy: ToolPolicy
149
+ ): boolean {
150
+ const normalizedName = normalizeToolName(toolName);
151
+ const { allowedPatterns, deniedPatterns, strictMode } = policy;
152
+
153
+ const explicitDenied = deniedPatterns.some(
154
+ (pattern) =>
155
+ !parseBashFilter(pattern) && matchesToolPattern(normalizedName, pattern)
156
+ );
157
+ if (explicitDenied) {
158
+ return false;
159
+ }
160
+
161
+ if (normalizedName === "bash") {
162
+ if (strictMode) {
163
+ const explicitlyAllowed = allowedPatterns.some((pattern) =>
164
+ matchesToolPattern(normalizedName, pattern)
165
+ );
166
+ const hasCommandAllowlist = policy.bashPolicy.allowPrefixes.length > 0;
167
+ return explicitlyAllowed || hasCommandAllowlist;
168
+ }
169
+ return true;
170
+ }
171
+
172
+ if (!strictMode) {
173
+ return true;
174
+ }
175
+
176
+ return allowedPatterns.some((pattern) =>
177
+ matchesToolPattern(normalizedName, pattern)
178
+ );
179
+ }
180
+
181
+ export function enforceBashCommandPolicy(
182
+ command: string,
183
+ policy: BashCommandPolicy
184
+ ): void {
185
+ const trimmed = command.trim();
186
+ if (!trimmed) {
187
+ return;
188
+ }
189
+
190
+ const normalizedCommand = trimmed.toLowerCase();
191
+ const denyMatchPrefix = policy.denyPrefixes.find((prefix) =>
192
+ normalizedCommand.startsWith(prefix.toLowerCase())
193
+ );
194
+ if (denyMatchPrefix) {
195
+ throw new Error("Bash command denied by policy");
196
+ }
197
+
198
+ if (policy.allowAll) {
199
+ return;
200
+ }
201
+
202
+ if (policy.allowPrefixes.length === 0) {
203
+ return;
204
+ }
205
+
206
+ const allowMatch = policy.allowPrefixes.some((prefix) =>
207
+ normalizedCommand.startsWith(prefix.toLowerCase())
208
+ );
209
+ if (!allowMatch) {
210
+ throw new Error("Bash command not allowed by policy");
211
+ }
212
+ }