@lobu/worker 7.0.0 → 7.2.0

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 (84) hide show
  1. package/dist/core/error-handler.d.ts +0 -4
  2. package/dist/core/error-handler.d.ts.map +1 -1
  3. package/dist/core/error-handler.js +4 -15
  4. package/dist/core/error-handler.js.map +1 -1
  5. package/dist/core/types.d.ts +19 -19
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/core/types.js +0 -4
  8. package/dist/core/types.js.map +1 -1
  9. package/dist/core/workspace.d.ts +2 -11
  10. package/dist/core/workspace.d.ts.map +1 -1
  11. package/dist/core/workspace.js +14 -36
  12. package/dist/core/workspace.js.map +1 -1
  13. package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
  14. package/dist/embedded/just-bash-bootstrap.js +34 -4
  15. package/dist/embedded/just-bash-bootstrap.js.map +1 -1
  16. package/dist/embedded/mcp-cli-commands.d.ts.map +1 -1
  17. package/dist/embedded/mcp-cli-commands.js +3 -38
  18. package/dist/embedded/mcp-cli-commands.js.map +1 -1
  19. package/dist/gateway/sse-client.d.ts.map +1 -1
  20. package/dist/gateway/sse-client.js +72 -10
  21. package/dist/gateway/sse-client.js.map +1 -1
  22. package/dist/gateway/types.d.ts +2 -0
  23. package/dist/gateway/types.d.ts.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -24
  26. package/dist/index.js.map +1 -1
  27. package/dist/instructions/builder.d.ts.map +1 -1
  28. package/dist/instructions/builder.js +2 -1
  29. package/dist/instructions/builder.js.map +1 -1
  30. package/dist/openclaw/plugin-loader.d.ts.map +1 -1
  31. package/dist/openclaw/plugin-loader.js +8 -19
  32. package/dist/openclaw/plugin-loader.js.map +1 -1
  33. package/dist/openclaw/processor.d.ts.map +1 -1
  34. package/dist/openclaw/processor.js +2 -0
  35. package/dist/openclaw/processor.js.map +1 -1
  36. package/dist/openclaw/sandbox-leak.d.ts.map +1 -1
  37. package/dist/openclaw/sandbox-leak.js +1 -6
  38. package/dist/openclaw/sandbox-leak.js.map +1 -1
  39. package/dist/openclaw/session-context.d.ts.map +1 -1
  40. package/dist/openclaw/session-context.js +3 -0
  41. package/dist/openclaw/session-context.js.map +1 -1
  42. package/dist/openclaw/tool-policy.d.ts.map +1 -1
  43. package/dist/openclaw/tool-policy.js +5 -11
  44. package/dist/openclaw/tool-policy.js.map +1 -1
  45. package/dist/openclaw/transcript-snapshot.d.ts +88 -0
  46. package/dist/openclaw/transcript-snapshot.d.ts.map +1 -0
  47. package/dist/openclaw/transcript-snapshot.js +223 -0
  48. package/dist/openclaw/transcript-snapshot.js.map +1 -0
  49. package/dist/openclaw/worker.d.ts +14 -0
  50. package/dist/openclaw/worker.d.ts.map +1 -1
  51. package/dist/openclaw/worker.js +147 -10
  52. package/dist/openclaw/worker.js.map +1 -1
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +3 -40
  55. package/dist/server.js.map +1 -1
  56. package/dist/shared/audio-provider-suggestions.d.ts.map +1 -1
  57. package/dist/shared/audio-provider-suggestions.js +4 -6
  58. package/dist/shared/audio-provider-suggestions.js.map +1 -1
  59. package/dist/shared/tool-implementations.d.ts.map +1 -1
  60. package/dist/shared/tool-implementations.js +62 -24
  61. package/dist/shared/tool-implementations.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/__tests__/processor-harden.test.ts +6 -16
  64. package/src/__tests__/sse-client.test.ts +99 -0
  65. package/src/__tests__/transcript-snapshot.test.ts +275 -0
  66. package/src/core/error-handler.ts +5 -20
  67. package/src/core/types.ts +19 -35
  68. package/src/core/workspace.ts +22 -45
  69. package/src/embedded/just-bash-bootstrap.ts +36 -4
  70. package/src/embedded/mcp-cli-commands.ts +9 -6
  71. package/src/gateway/sse-client.ts +87 -22
  72. package/src/gateway/types.ts +15 -0
  73. package/src/index.ts +8 -26
  74. package/src/instructions/builder.ts +2 -3
  75. package/src/openclaw/plugin-loader.ts +15 -19
  76. package/src/openclaw/processor.ts +1 -0
  77. package/src/openclaw/sandbox-leak.ts +1 -6
  78. package/src/openclaw/session-context.ts +3 -0
  79. package/src/openclaw/tool-policy.ts +5 -12
  80. package/src/openclaw/transcript-snapshot.ts +238 -0
  81. package/src/openclaw/worker.ts +167 -13
  82. package/src/server.ts +1 -5
  83. package/src/shared/audio-provider-suggestions.ts +4 -6
  84. package/src/shared/tool-implementations.ts +57 -16
@@ -9,9 +9,11 @@ import {
9
9
  extractTraceId,
10
10
  flushTracing,
11
11
  SpanStatusCode,
12
+ stripEnv,
12
13
  } from "@lobu/core";
13
14
  import { z } from "zod";
14
15
  import type { WorkerConfig, WorkerExecutor } from "../core/types";
16
+ import { SENSITIVE_WORKER_ENV_KEYS } from "../shared/worker-env-keys";
15
17
  import { HttpWorkerTransport } from "./gateway-integration";
16
18
  import { MessageBatcher } from "./message-batcher";
17
19
  import type { MessagePayload, QueuedMessage } from "./types";
@@ -79,20 +81,33 @@ const AgentOptionsSchema = z
79
81
  .passthrough();
80
82
 
81
83
  const JobEventSchema = z.object({
82
- payload: z.object({
83
- botId: z.string(),
84
- userId: z.string(),
85
- agentId: z.string(),
86
- conversationId: z.string(),
87
- platform: z.string(),
88
- channelId: z.string(),
89
- messageId: z.string(),
90
- messageText: z.string(),
91
- platformMetadata: PlatformMetadataSchema,
92
- agentOptions: AgentOptionsSchema,
93
- jobId: z.string().optional(),
94
- teamId: z.string().optional(), // Optional for WhatsApp (top-level) and Slack (in platformMetadata)
95
- }),
84
+ payload: z
85
+ .object({
86
+ botId: z.string(),
87
+ userId: z.string(),
88
+ agentId: z.string(),
89
+ conversationId: z.string(),
90
+ platform: z.string(),
91
+ channelId: z.string(),
92
+ messageId: z.string(),
93
+ messageText: z.string(),
94
+ platformMetadata: PlatformMetadataSchema,
95
+ agentOptions: AgentOptionsSchema,
96
+ jobId: z.string().optional(),
97
+ teamId: z.string().optional(), // Optional for WhatsApp (top-level) and Slack (in platformMetadata)
98
+ // Threaded through from MessageConsumer's runs-queue claim. The worker
99
+ // asserts these in snapshot mode (LOBU_SESSION_STORE != "file") — see
100
+ // worker.ts:353-360. The default zod object mode strips unknown keys,
101
+ // which silently dropped these fields and broke every Telegram chat
102
+ // when snapshot mode became the default in PR #871. Declare them
103
+ // explicitly so they survive parsing, and `.passthrough()` keeps any
104
+ // future MessagePayload field (mcpConfig, nixConfig, egressConfig,
105
+ // preApprovedTools, exec* fields, organizationId, networkConfig...)
106
+ // from regressing the same way.
107
+ runId: z.number().optional(),
108
+ runJobToken: z.string().optional(),
109
+ })
110
+ .passthrough(),
96
111
  processedIds: z.array(z.string()).optional(),
97
112
  });
98
113
 
@@ -574,16 +589,27 @@ export class GatewayClient {
574
589
  });
575
590
 
576
591
  let completed = false;
592
+ let sigkillTimer: NodeJS.Timeout | null = null;
577
593
 
578
594
  try {
579
- // Spawn the command
595
+ // Strip the worker's own gateway credentials before handing the shell
596
+ // its env. An `exec` command is an arbitrary string from the gateway
597
+ // that ends up under `sh -c`; leaking WORKER_TOKEN / DISPATCHER_URL
598
+ // into that environment would let a malicious or buggy exec impersonate
599
+ // the worker against its own gateway. The bash-tool and just-bash
600
+ // spawners already apply the same filter (see openclaw/tools.ts and
601
+ // embedded/just-bash-bootstrap.ts) — keep parity here.
602
+ const baseEnv = stripEnv(process.env, SENSITIVE_WORKER_ENV_KEYS);
580
603
  const proc = spawn("sh", ["-c", execCommand], {
581
604
  cwd: workingDir,
582
- env: { ...process.env, ...execEnv },
605
+ env: { ...baseEnv, ...execEnv },
583
606
  stdio: ["ignore", "pipe", "pipe"],
584
607
  });
585
608
 
586
- // Setup timeout
609
+ // Setup timeout. The SIGKILL escalation timer is tracked so the `close`
610
+ // handler can clear it when the child exits between SIGTERM and SIGKILL;
611
+ // otherwise the timer pins the event loop for an extra 5s after every
612
+ // timed-out exec and (worse) leaks if `close`/`error` never fires.
587
613
  const timeoutId = setTimeout(() => {
588
614
  if (!completed) {
589
615
  logger.warn(
@@ -591,7 +617,8 @@ export class GatewayClient {
591
617
  "Exec timeout reached, killing process"
592
618
  );
593
619
  proc.kill("SIGTERM");
594
- setTimeout(() => {
620
+ sigkillTimer = setTimeout(() => {
621
+ sigkillTimer = null;
595
622
  if (!completed) {
596
623
  proc.kill("SIGKILL");
597
624
  }
@@ -600,7 +627,7 @@ export class GatewayClient {
600
627
  }, timeout);
601
628
 
602
629
  // Stream stdout
603
- proc.stdout?.on("data", (chunk: Buffer) => {
630
+ const onStdout = (chunk: Buffer) => {
604
631
  const content = chunk.toString();
605
632
  transport.sendExecOutput(execId, "stdout", content).catch((err) => {
606
633
  logger.error(
@@ -608,10 +635,11 @@ export class GatewayClient {
608
635
  "Failed to send stdout"
609
636
  );
610
637
  });
611
- });
638
+ };
639
+ proc.stdout?.on("data", onStdout);
612
640
 
613
641
  // Stream stderr
614
- proc.stderr?.on("data", (chunk: Buffer) => {
642
+ const onStderr = (chunk: Buffer) => {
615
643
  const content = chunk.toString();
616
644
  transport.sendExecOutput(execId, "stderr", content).catch((err) => {
617
645
  logger.error(
@@ -619,19 +647,34 @@ export class GatewayClient {
619
647
  "Failed to send stderr"
620
648
  );
621
649
  });
622
- });
650
+ };
651
+ proc.stderr?.on("data", onStderr);
623
652
 
624
653
  // Wait for process to complete
625
654
  const exitCode = await new Promise<number>((resolve, reject) => {
626
655
  proc.on("close", (code) => {
627
656
  completed = true;
628
657
  clearTimeout(timeoutId);
658
+ if (sigkillTimer) {
659
+ clearTimeout(sigkillTimer);
660
+ sigkillTimer = null;
661
+ }
662
+ // Stop accepting late `data` events so a chunk buffered after exit
663
+ // can't fire `sendExecOutput` AFTER we've signalled completion.
664
+ proc.stdout?.removeListener("data", onStdout);
665
+ proc.stderr?.removeListener("data", onStderr);
629
666
  resolve(code ?? 0);
630
667
  });
631
668
 
632
669
  proc.on("error", (error) => {
633
670
  completed = true;
634
671
  clearTimeout(timeoutId);
672
+ if (sigkillTimer) {
673
+ clearTimeout(sigkillTimer);
674
+ sigkillTimer = null;
675
+ }
676
+ proc.stdout?.removeListener("data", onStdout);
677
+ proc.stderr?.removeListener("data", onStderr);
635
678
  reject(error);
636
679
  });
637
680
  });
@@ -663,6 +706,13 @@ export class GatewayClient {
663
706
 
664
707
  logger.error({ traceId, execId, error: errorMessage }, "Exec failed");
665
708
  } finally {
709
+ // Defensive: if we threw before `close`/`error` fired (e.g. transport
710
+ // throwing during sendExecOutput on a long-running child), make sure
711
+ // the SIGKILL escalation timer doesn't outlive this exec.
712
+ if (sigkillTimer) {
713
+ clearTimeout(sigkillTimer);
714
+ sigkillTimer = null;
715
+ }
666
716
  this.currentJobId = undefined;
667
717
  }
668
718
  }
@@ -882,6 +932,21 @@ export class GatewayClient {
882
932
  workspace: {
883
933
  baseDirectory: process.env.WORKSPACE_DIR || "/workspace",
884
934
  },
935
+ // Threaded through from MessageConsumer (set from the runs-queue
936
+ // claim's job.id). Used by cleanup() to attribute the snapshot to
937
+ // the correct run; codex P1#1 on PR #865.
938
+ runId:
939
+ typeof payload.runId === "number" && Number.isFinite(payload.runId)
940
+ ? payload.runId
941
+ : undefined,
942
+ // Per-run JWT minted by MessageConsumer alongside runId. Worker
943
+ // uses this for the snapshot POST instead of the deployment-
944
+ // lifetime WORKER_TOKEN, so the gateway can enforce
945
+ // tokenData.runId === body.runId — codex round 2 finding A.
946
+ runJobToken:
947
+ typeof payload.runJobToken === "string" && payload.runJobToken
948
+ ? payload.runJobToken
949
+ : undefined,
885
950
  };
886
951
  }
887
952
 
@@ -41,6 +41,21 @@ export interface MessagePayload {
41
41
  jobId?: string; // Optional job ID from gateway
42
42
  teamId?: string; // Optional team ID (WhatsApp uses top-level, Slack uses platformMetadata)
43
43
 
44
+ // The runs.id of the row that dispatched this job. Set by the gateway
45
+ // MessageConsumer (stamped from the runs-queue claim's job.id) and
46
+ // threaded into WorkerConfig.runId. The worker's cleanup() uses it to
47
+ // attribute the agent_transcript_snapshot row to the correct run —
48
+ // see codex P1#1 on PR #865.
49
+ runId?: number;
50
+
51
+ // Per-run worker JWT bound to `runId` above. Minted by MessageConsumer
52
+ // and threaded into WorkerConfig.runJobToken. The worker uses THIS
53
+ // token (not the deployment-lifetime WORKER_TOKEN) when calling the
54
+ // snapshot endpoint, so the route's `tokenData.runId === body.runId`
55
+ // equality check can reject any cross-run impersonation — codex round
56
+ // 2 finding A on PR #865.
57
+ runJobToken?: string;
58
+
44
59
  // Job type (default: "message")
45
60
  jobType?: JobType;
46
61
 
package/src/index.ts CHANGED
@@ -16,7 +16,6 @@ import { startWorkerHttpServer, stopWorkerHttpServer } from "./server";
16
16
  * Main entry point for gateway-based persistent worker
17
17
  */
18
18
  async function main() {
19
- // Register global rejection/exception handlers early
20
19
  process.on("unhandledRejection", (reason) => {
21
20
  logger.error("Unhandled rejection:", reason);
22
21
  process.exit(1);
@@ -29,10 +28,8 @@ async function main() {
29
28
 
30
29
  logger.info("Starting worker...");
31
30
 
32
- // Initialize Sentry for error tracking
33
31
  await initSentry();
34
32
 
35
- // Initialize OpenTelemetry tracing for distributed tracing
36
33
  const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
37
34
  if (otlpEndpoint) {
38
35
  initTracing({
@@ -42,16 +39,12 @@ async function main() {
42
39
  logger.info(`Tracing initialized: lobu-worker -> ${otlpEndpoint}`);
43
40
  }
44
41
 
45
- // Discover and register available modules
46
42
  await moduleRegistry.registerAvailableModules();
47
-
48
- // Initialize all registered modules
49
43
  await moduleRegistry.initAll();
50
44
  logger.info("✅ Modules initialized");
51
45
 
52
46
  logger.info("🔄 Starting in gateway mode (SSE/HTTP-based persistent worker)");
53
47
 
54
- // Get user ID from environment
55
48
  const userId = process.env.USER_ID;
56
49
 
57
50
  if (!userId) {
@@ -62,7 +55,6 @@ async function main() {
62
55
  }
63
56
 
64
57
  try {
65
- // Get required environment variables
66
58
  const deploymentName = process.env.DEPLOYMENT_NAME;
67
59
  const dispatcherUrl = process.env.DISPATCHER_URL;
68
60
  const workerToken = process.env.WORKER_TOKEN;
@@ -80,11 +72,9 @@ async function main() {
80
72
  process.exit(1);
81
73
  }
82
74
 
83
- // Start HTTP server before connecting to gateway
84
75
  const httpPort = await startWorkerHttpServer();
85
76
  logger.info(`Worker HTTP server started on port ${httpPort}`);
86
77
 
87
- // Initialize gateway client directly
88
78
  logger.info(`🚀 Starting Gateway-based Persistent Worker`);
89
79
  logger.info(`- User ID: ${userId}`);
90
80
  logger.info(`- Deployment: ${deploymentName}`);
@@ -100,33 +90,25 @@ async function main() {
100
90
 
101
91
  // Register signal handlers before async operations
102
92
  let isShuttingDown = false;
103
-
104
- process.on("SIGTERM", async () => {
93
+ const shutdown = async (signal: NodeJS.Signals) => {
105
94
  if (isShuttingDown) return;
106
95
  isShuttingDown = true;
107
- logger.info("Received SIGTERM, shutting down gateway worker...");
96
+ logger.info(`Received ${signal}, shutting down gateway worker...`);
108
97
  await gatewayClient.stop();
109
98
  await stopWorkerHttpServer();
110
99
  process.exit(0);
111
- });
100
+ };
112
101
 
113
- process.on("SIGINT", async () => {
114
- if (isShuttingDown) return;
115
- isShuttingDown = true;
116
- logger.info("Received SIGINT, shutting down gateway worker...");
117
- await gatewayClient.stop();
118
- await stopWorkerHttpServer();
119
- process.exit(0);
120
- });
102
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
103
+ process.on("SIGINT", () => shutdown("SIGINT"));
121
104
 
122
105
  logger.info("🔌 Connecting to dispatcher...");
123
106
  await gatewayClient.start();
124
107
  logger.info("✅ Gateway worker started successfully");
125
108
 
126
- // Keep process alive
127
- await new Promise(() => {
128
- // Keep process running indefinitely so we can listen messages from the queue
129
- }); // Wait forever
109
+ await new Promise<never>(() => {
110
+ // Intentionally never resolves — block process until signal.
111
+ });
130
112
  } catch (error) {
131
113
  logger.error("❌ Gateway worker failed:", error);
132
114
  process.exit(1);
@@ -20,10 +20,9 @@ export async function generateCustomInstructions(
20
20
  context: InstructionContext
21
21
  ): Promise<string> {
22
22
  try {
23
+ const sorted = [...providers].sort((a, b) => a.priority - b.priority);
23
24
  const sections: string[] = [];
24
- for (const provider of [...providers].sort(
25
- (a, b) => a.priority - b.priority
26
- )) {
25
+ for (const provider of sorted) {
27
26
  const instructions = await provider.getInstructions(context);
28
27
  if (instructions?.trim()) {
29
28
  sections.push(instructions.trim());
@@ -99,16 +99,15 @@ async function loadSinglePlugin(
99
99
  ): Promise<LoadedPlugin | null> {
100
100
  const { source, slot, config: pluginConfig } = config;
101
101
 
102
- let mod: Record<string, unknown>;
103
- try {
104
- mod = (await import(source)) as Record<string, unknown>;
105
- } catch (err) {
102
+ const mod = await import(source).catch((err) => {
106
103
  throw new Error(
107
104
  `Cannot import "${source}": ${err instanceof Error ? err.message : String(err)}`
108
105
  );
109
- }
106
+ });
110
107
 
111
- const pluginEntrypoint = resolvePluginEntrypoint(mod);
108
+ const pluginEntrypoint = resolvePluginEntrypoint(
109
+ mod as Record<string, unknown>
110
+ );
112
111
  if (!pluginEntrypoint) {
113
112
  logger.warn(`Plugin "${source}" has no registerable entrypoint - skipping`);
114
113
  return null;
@@ -224,22 +223,19 @@ function createShimApi(params: {
224
223
  cwd,
225
224
  } = params;
226
225
  const noop = () => {
227
- /* intentional no-op */
226
+ // No-op stub for shim plugin APIs that this loader does not implement.
228
227
  };
229
228
 
229
+ const prefix = `[plugin:${extractPluginName(source)}]`;
230
230
  const shimLogger = {
231
- info(message: string, ...args: unknown[]) {
232
- logger.info(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
233
- },
234
- warn(message: string, ...args: unknown[]) {
235
- logger.warn(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
236
- },
237
- error(message: string, ...args: unknown[]) {
238
- logger.error(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
239
- },
240
- debug(message: string, ...args: unknown[]) {
241
- logger.debug(`[plugin:${extractPluginName(source)}] ${message}`, ...args);
242
- },
231
+ info: (message: string, ...args: unknown[]) =>
232
+ logger.info(`${prefix} ${message}`, ...args),
233
+ warn: (message: string, ...args: unknown[]) =>
234
+ logger.warn(`${prefix} ${message}`, ...args),
235
+ error: (message: string, ...args: unknown[]) =>
236
+ logger.error(`${prefix} ${message}`, ...args),
237
+ debug: (message: string, ...args: unknown[]) =>
238
+ logger.debug(`${prefix} ${message}`, ...args),
243
239
  };
244
240
 
245
241
  return {
@@ -33,6 +33,7 @@ export class OpenClawProgressProcessor {
33
33
  return false;
34
34
  }
35
35
  const assistantEvent = event.assistantMessageEvent;
36
+ if (!assistantEvent) return false;
36
37
 
37
38
  if (assistantEvent.type === "text_delta") {
38
39
  this.hasStreamedText = true;
@@ -89,12 +89,7 @@ export function checkSandboxLeak(
89
89
  );
90
90
  redacted = redacted.replace(
91
91
  DELIVERY_PHRASE_RE,
92
- (_match, _path: string, _offset: number, _full: string) => {
93
- // Reconstruct the phrase prefix (everything before the path) by
94
- // re-matching on the original substring. Simpler: replace the whole
95
- // match with a generic note.
96
- return "[file was created but not uploaded — use `UploadUserFile` to deliver it]";
97
- }
92
+ "[file was created but not uploaded use `UploadUserFile` to deliver it]"
98
93
  );
99
94
 
100
95
  const note =
@@ -231,6 +231,9 @@ export async function getOpenClawSessionContext(
231
231
  headers: {
232
232
  Authorization: `Bearer ${workerToken}`,
233
233
  },
234
+ // Session context is fetched once per turn; a stalled gateway here would
235
+ // otherwise hang the worker before the agent ever sees the prompt.
236
+ signal: AbortSignal.timeout(30_000),
234
237
  });
235
238
 
236
239
  if (!response.ok) {
@@ -86,10 +86,6 @@ export function isDirectPackageInstallCommand(command: string): boolean {
86
86
  );
87
87
  }
88
88
 
89
- function normalizePattern(pattern: string): string {
90
- return pattern.trim();
91
- }
92
-
93
89
  function normalizeToolName(name: string): string {
94
90
  return name.trim().toLowerCase();
95
91
  }
@@ -108,16 +104,13 @@ export function normalizeToolList(value?: string | string[]): string[] {
108
104
 
109
105
  function parseBashFilter(pattern: string): string | null {
110
106
  const match = pattern.match(/^Bash\(([^:]+):\*\)$/i);
111
- if (!match) {
112
- return null;
113
- }
114
- const prefix = match[1]?.trim();
115
- return prefix ? prefix : null;
107
+ const prefix = match?.[1]?.trim();
108
+ return prefix || null;
116
109
  }
117
110
 
118
111
  function matchesToolPattern(toolName: string, pattern: string): boolean {
119
112
  const normalizedTool = normalizeToolName(toolName);
120
- const normalizedPattern = normalizePattern(pattern);
113
+ const normalizedPattern = pattern.trim();
121
114
  const normalizedPatternLower = normalizedPattern.toLowerCase();
122
115
 
123
116
  if (normalizedPattern === "*") {
@@ -145,11 +138,11 @@ export function buildToolPolicy(params: {
145
138
  const mergedAllowed = [
146
139
  ...(toolsConfig?.allowedTools ?? []),
147
140
  ...allowedPatterns,
148
- ].map(normalizePattern);
141
+ ].map((p) => p.trim());
149
142
  const mergedDenied = [
150
143
  ...(toolsConfig?.deniedTools ?? []),
151
144
  ...deniedPatterns,
152
- ].map(normalizePattern);
145
+ ].map((p) => p.trim());
153
146
 
154
147
  const bashAllowPrefixes = mergedAllowed
155
148
  .map((pattern) => parseBashFilter(pattern))