@oyasmi/pipiclaw 0.5.7 → 0.5.9

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 (45) hide show
  1. package/README.md +52 -3
  2. package/dist/agent/prompt-builder.js +6 -0
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +2 -1
  5. package/dist/paths.d.ts +1 -0
  6. package/dist/paths.js +1 -0
  7. package/dist/runtime/bootstrap.d.ts +1 -1
  8. package/dist/runtime/bootstrap.js +25 -13
  9. package/dist/runtime/dingtalk.js +0 -3
  10. package/dist/sandbox.js +63 -5
  11. package/dist/security/config.js +19 -0
  12. package/dist/security/network.d.ts +28 -0
  13. package/dist/security/network.js +246 -0
  14. package/dist/security/types.d.ts +16 -1
  15. package/dist/shared/shell-escape.d.ts +7 -0
  16. package/dist/shared/shell-escape.js +11 -0
  17. package/dist/subagents/discovery.d.ts +1 -1
  18. package/dist/subagents/discovery.js +1 -1
  19. package/dist/subagents/tool.d.ts +2 -0
  20. package/dist/subagents/tool.js +24 -2
  21. package/dist/tools/config.d.ts +30 -0
  22. package/dist/tools/config.js +114 -0
  23. package/dist/tools/edit.js +2 -2
  24. package/dist/tools/index.js +22 -0
  25. package/dist/tools/read.js +6 -6
  26. package/dist/tools/web-fetch.d.ts +17 -0
  27. package/dist/tools/web-fetch.js +29 -0
  28. package/dist/tools/web-search.d.ts +16 -0
  29. package/dist/tools/web-search.js +29 -0
  30. package/dist/tools/write-content.js +5 -4
  31. package/dist/web/client.d.ts +40 -0
  32. package/dist/web/client.js +181 -0
  33. package/dist/web/config.d.ts +18 -0
  34. package/dist/web/config.js +34 -0
  35. package/dist/web/extract.d.ts +7 -0
  36. package/dist/web/extract.js +122 -0
  37. package/dist/web/fetch.d.ts +22 -0
  38. package/dist/web/fetch.js +148 -0
  39. package/dist/web/format.d.ts +21 -0
  40. package/dist/web/format.js +38 -0
  41. package/dist/web/search-providers.d.ts +15 -0
  42. package/dist/web/search-providers.js +196 -0
  43. package/dist/web/search.d.ts +19 -0
  44. package/dist/web/search.js +52 -0
  45. package/package.json +9 -2
@@ -0,0 +1,246 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ export class NetworkGuardError extends Error {
4
+ constructor(options) {
5
+ super(options.message);
6
+ this.name = "NetworkGuardError";
7
+ this.url = options.url;
8
+ this.stage = options.stage;
9
+ this.category = options.category;
10
+ this.resolvedHost = options.resolvedHost;
11
+ this.resolvedAddress = options.resolvedAddress;
12
+ }
13
+ }
14
+ const BLOCKED_HOSTS = new Set(["localhost", "metadata.google.internal", "metadata", "169.254.169.254"]);
15
+ const PRIVATE_IPV4_CIDRS = [
16
+ "0.0.0.0/8",
17
+ "10.0.0.0/8",
18
+ "100.64.0.0/10",
19
+ "127.0.0.0/8",
20
+ "169.254.0.0/16",
21
+ "172.16.0.0/12",
22
+ "192.168.0.0/16",
23
+ "198.18.0.0/15",
24
+ ];
25
+ const PRIVATE_IPV6_CIDRS = ["::1/128", "::/128", "fc00::/7", "fe80::/10"];
26
+ function normalizeHost(host) {
27
+ return host.trim().replace(/\.$/, "").toLowerCase();
28
+ }
29
+ function parseIpv4(ip) {
30
+ const parts = ip.split(".");
31
+ if (parts.length !== 4) {
32
+ return null;
33
+ }
34
+ let value = 0;
35
+ for (const part of parts) {
36
+ if (!/^\d+$/.test(part)) {
37
+ return null;
38
+ }
39
+ const octet = Number.parseInt(part, 10);
40
+ if (octet < 0 || octet > 255) {
41
+ return null;
42
+ }
43
+ value = (value << 8) | octet;
44
+ }
45
+ return value >>> 0;
46
+ }
47
+ function expandIpv6(ip) {
48
+ const normalized = ip.toLowerCase();
49
+ const hasEmbeddedIpv4 = normalized.includes(".");
50
+ let working = normalized;
51
+ if (hasEmbeddedIpv4) {
52
+ const lastColon = working.lastIndexOf(":");
53
+ if (lastColon === -1) {
54
+ return null;
55
+ }
56
+ const ipv4 = parseIpv4(working.slice(lastColon + 1));
57
+ if (ipv4 === null) {
58
+ return null;
59
+ }
60
+ const high = ((ipv4 >>> 16) & 0xffff).toString(16);
61
+ const low = (ipv4 & 0xffff).toString(16);
62
+ working = `${working.slice(0, lastColon)}:${high}:${low}`;
63
+ }
64
+ const pieces = working.split("::");
65
+ if (pieces.length > 2) {
66
+ return null;
67
+ }
68
+ const left = pieces[0] ? pieces[0].split(":").filter(Boolean) : [];
69
+ const right = pieces[1] ? pieces[1].split(":").filter(Boolean) : [];
70
+ if (left.length + right.length > 8) {
71
+ return null;
72
+ }
73
+ const fill = new Array(8 - left.length - right.length).fill("0");
74
+ const groups = pieces.length === 2 ? [...left, ...fill, ...right] : left;
75
+ return groups.length === 8 ? groups : null;
76
+ }
77
+ function parseIpv6(ip) {
78
+ const groups = expandIpv6(ip);
79
+ if (!groups) {
80
+ return null;
81
+ }
82
+ let value = 0n;
83
+ for (const group of groups) {
84
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) {
85
+ return null;
86
+ }
87
+ value = (value << 16n) | BigInt(Number.parseInt(group, 16));
88
+ }
89
+ return value;
90
+ }
91
+ function ipInCidr(ip, cidr) {
92
+ const [network, prefixText] = cidr.split("/");
93
+ const prefix = Number.parseInt(prefixText ?? "", 10);
94
+ if (!Number.isFinite(prefix)) {
95
+ return false;
96
+ }
97
+ const version = isIP(ip);
98
+ if (version === 4) {
99
+ const ipValue = parseIpv4(ip);
100
+ const networkValue = parseIpv4(network);
101
+ if (ipValue === null || networkValue === null || prefix < 0 || prefix > 32) {
102
+ return false;
103
+ }
104
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
105
+ return (ipValue & mask) === (networkValue & mask);
106
+ }
107
+ if (version === 6) {
108
+ const ipValue = parseIpv6(ip);
109
+ const networkValue = parseIpv6(network);
110
+ if (ipValue === null || networkValue === null || prefix < 0 || prefix > 128) {
111
+ return false;
112
+ }
113
+ const shift = 128 - prefix;
114
+ if (shift === 128) {
115
+ return true;
116
+ }
117
+ return ipValue >> BigInt(shift) === networkValue >> BigInt(shift);
118
+ }
119
+ return false;
120
+ }
121
+ function matchesAllowedHost(hostname, allowedHosts) {
122
+ const normalized = normalizeHost(hostname);
123
+ return allowedHosts.some((candidate) => normalizeHost(candidate) === normalized);
124
+ }
125
+ function matchesAllowedCidr(address, allowedCidrs) {
126
+ return allowedCidrs.some((cidr) => ipInCidr(address, cidr.trim()));
127
+ }
128
+ function isBlockedHost(hostname) {
129
+ const normalized = normalizeHost(hostname);
130
+ return normalized.endsWith(".localhost") || BLOCKED_HOSTS.has(normalized);
131
+ }
132
+ function isPrivateAddress(address) {
133
+ const version = isIP(address);
134
+ if (version === 4) {
135
+ return PRIVATE_IPV4_CIDRS.some((cidr) => ipInCidr(address, cidr));
136
+ }
137
+ if (version === 6) {
138
+ return PRIVATE_IPV6_CIDRS.some((cidr) => ipInCidr(address, cidr));
139
+ }
140
+ return false;
141
+ }
142
+ async function validateUrlTarget(rawUrl, context, stage) {
143
+ const url = (() => {
144
+ try {
145
+ return new URL(rawUrl);
146
+ }
147
+ catch {
148
+ throw new NetworkGuardError({
149
+ url: rawUrl,
150
+ stage,
151
+ category: "invalid-url",
152
+ message: `Invalid URL: ${rawUrl}`,
153
+ });
154
+ }
155
+ })();
156
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
157
+ throw new NetworkGuardError({
158
+ url: rawUrl,
159
+ stage,
160
+ category: "unsupported-scheme",
161
+ message: `Only http/https URLs are allowed, got ${url.protocol || "unknown"}`,
162
+ });
163
+ }
164
+ const hostname = normalizeHost(url.hostname);
165
+ if (!hostname) {
166
+ throw new NetworkGuardError({
167
+ url: rawUrl,
168
+ stage,
169
+ category: "missing-host",
170
+ message: `URL is missing a hostname: ${rawUrl}`,
171
+ });
172
+ }
173
+ const { networkGuard } = context.config;
174
+ if (!networkGuard.enabled) {
175
+ return { url: url.toString(), hostname };
176
+ }
177
+ if (matchesAllowedHost(hostname, networkGuard.allowedHosts)) {
178
+ return { url: url.toString(), hostname };
179
+ }
180
+ if (isBlockedHost(hostname)) {
181
+ throw new NetworkGuardError({
182
+ url: rawUrl,
183
+ stage,
184
+ category: "blocked-host",
185
+ message: `Blocked host: ${hostname}`,
186
+ resolvedHost: hostname,
187
+ });
188
+ }
189
+ if (isIP(hostname)) {
190
+ if (!matchesAllowedCidr(hostname, networkGuard.allowedCidrs) && isPrivateAddress(hostname)) {
191
+ throw new NetworkGuardError({
192
+ url: rawUrl,
193
+ stage,
194
+ category: "private-address",
195
+ message: `Blocked private network address: ${hostname}`,
196
+ resolvedHost: hostname,
197
+ resolvedAddress: hostname,
198
+ });
199
+ }
200
+ return { url: url.toString(), hostname, resolvedAddress: hostname };
201
+ }
202
+ let records;
203
+ try {
204
+ records = (await lookup(hostname, { all: true, verbatim: true }));
205
+ }
206
+ catch (error) {
207
+ throw new NetworkGuardError({
208
+ url: rawUrl,
209
+ stage,
210
+ category: "dns-failure",
211
+ message: `Failed to resolve host ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
212
+ resolvedHost: hostname,
213
+ });
214
+ }
215
+ if (records.length === 0) {
216
+ throw new NetworkGuardError({
217
+ url: rawUrl,
218
+ stage,
219
+ category: "dns-failure",
220
+ message: `Failed to resolve host ${hostname}`,
221
+ resolvedHost: hostname,
222
+ });
223
+ }
224
+ for (const record of records) {
225
+ if (matchesAllowedCidr(record.address, networkGuard.allowedCidrs)) {
226
+ return { url: url.toString(), hostname, resolvedAddress: record.address };
227
+ }
228
+ if (isPrivateAddress(record.address)) {
229
+ throw new NetworkGuardError({
230
+ url: rawUrl,
231
+ stage,
232
+ category: "private-address",
233
+ message: `Blocked private network address resolved from ${hostname}: ${record.address}`,
234
+ resolvedHost: hostname,
235
+ resolvedAddress: record.address,
236
+ });
237
+ }
238
+ }
239
+ return { url: url.toString(), hostname, resolvedAddress: records[0]?.address };
240
+ }
241
+ export async function validateNetworkTarget(url, context) {
242
+ return validateUrlTarget(url, context, "request");
243
+ }
244
+ export async function validateRedirectTarget(url, context) {
245
+ return validateUrlTarget(url, context, "redirect");
246
+ }
@@ -14,6 +14,12 @@ export interface SecurityConfig {
14
14
  writeDeny: string[];
15
15
  resolveSymlinks: boolean;
16
16
  };
17
+ networkGuard: {
18
+ enabled: boolean;
19
+ allowedCidrs: string[];
20
+ allowedHosts: string[];
21
+ maxRedirects: number;
22
+ };
17
23
  audit: {
18
24
  logBlocked: boolean;
19
25
  logFile?: string;
@@ -63,4 +69,13 @@ export interface BlockedCommandLogEvent extends SecurityLogEventBase {
63
69
  reason?: string;
64
70
  matchedText?: string;
65
71
  }
66
- export type SecurityLogEvent = BlockedPathLogEvent | BlockedCommandLogEvent;
72
+ export interface BlockedNetworkLogEvent extends SecurityLogEventBase {
73
+ type: "network";
74
+ url: string;
75
+ stage: "request" | "redirect";
76
+ resolvedHost?: string;
77
+ resolvedAddress?: string;
78
+ category?: string;
79
+ reason?: string;
80
+ }
81
+ export type SecurityLogEvent = BlockedPathLogEvent | BlockedCommandLogEvent | BlockedNetworkLogEvent;
@@ -3,3 +3,10 @@
3
3
  * Wraps in single quotes and escapes internal single quotes.
4
4
  */
5
5
  export declare function shellEscape(s: string): string;
6
+ /**
7
+ * Normalize filesystem paths for POSIX-style shells.
8
+ * On Windows we convert backslashes to forward slashes so Git Bash/MSYS tools
9
+ * can consume the path consistently.
10
+ */
11
+ export declare function toShellPath(path: string): string;
12
+ export declare function shellEscapePath(path: string): string;
@@ -5,3 +5,14 @@
5
5
  export function shellEscape(s) {
6
6
  return `'${s.replace(/'/g, "'\\''")}'`;
7
7
  }
8
+ /**
9
+ * Normalize filesystem paths for POSIX-style shells.
10
+ * On Windows we convert backslashes to forward slashes so Git Bash/MSYS tools
11
+ * can consume the path consistently.
12
+ */
13
+ export function toShellPath(path) {
14
+ return process.platform === "win32" ? path.replace(/\\/g, "/") : path;
15
+ }
16
+ export function shellEscapePath(path) {
17
+ return shellEscape(toShellPath(path));
18
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Api, Model } from "@mariozechner/pi-ai";
2
- declare const ALLOWED_SUB_AGENT_TOOLS: readonly ["read", "bash", "edit", "write"];
2
+ declare const ALLOWED_SUB_AGENT_TOOLS: readonly ["read", "bash", "edit", "write", "web_search", "web_fetch"];
3
3
  declare const ALLOWED_CONTEXT_MODES: readonly ["isolated", "contextual"];
4
4
  declare const ALLOWED_MEMORY_MODES: readonly ["none", "session", "relevant"];
5
5
  export type SubAgentToolName = (typeof ALLOWED_SUB_AGENT_TOOLS)[number];
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { findExactModelReferenceMatch, formatModelReference } from "../models/utils.js";
5
5
  import { SUB_AGENTS_DIR_NAME } from "../paths.js";
6
- const ALLOWED_SUB_AGENT_TOOLS = ["read", "bash", "edit", "write"];
6
+ const ALLOWED_SUB_AGENT_TOOLS = ["read", "bash", "edit", "write", "web_search", "web_fetch"];
7
7
  const DEFAULT_SUB_AGENT_TOOLS = ["read", "bash"];
8
8
  const DEFAULT_MAX_TURNS = 24;
9
9
  const DEFAULT_MAX_TOOL_CALLS = 48;
@@ -4,6 +4,7 @@ import type { Executor } from "../sandbox.js";
4
4
  import type { SecurityConfig } from "../security/types.js";
5
5
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
6
6
  import type { UsageTotals } from "../shared/types.js";
7
+ import type { PipiclawWebToolsConfig } from "../tools/config.js";
7
8
  import { type ResolvedSubAgentConfig, type SubAgentDiscoveryResult } from "./discovery.js";
8
9
  declare const subagentSchema: import("@sinclair/typebox").TObject<{
9
10
  label: import("@sinclair/typebox").TString;
@@ -44,6 +45,7 @@ export interface SubAgentToolOptions {
44
45
  getSubAgentDiscovery?: () => SubAgentDiscoveryResult;
45
46
  getMemoryRecallSettings?: () => PipiclawMemoryRecallSettings;
46
47
  securityConfig?: SecurityConfig;
48
+ webConfig?: PipiclawWebToolsConfig;
47
49
  runtimeContext: {
48
50
  workspacePath: string;
49
51
  channelId: string;
@@ -11,6 +11,8 @@ import { clipText, extractAssistantText, extractLabelFromArgs, HAN_REGEX } from
11
11
  import { createBashTool } from "../tools/bash.js";
12
12
  import { createEditTool } from "../tools/edit.js";
13
13
  import { createReadTool } from "../tools/read.js";
14
+ import { createWebFetchTool } from "../tools/web-fetch.js";
15
+ import { createWebSearchTool } from "../tools/web-search.js";
14
16
  import { createWriteTool } from "../tools/write.js";
15
17
  import { formatSubAgentList, resolveSubAgentConfig, validateSubAgentTask, } from "./discovery.js";
16
18
  const subagentSchema = Type.Object({
@@ -118,6 +120,25 @@ function createToolSet(executor, bashTimeoutSec, options) {
118
120
  }),
119
121
  ];
120
122
  }
123
+ function createNamedToolSet(executor, bashTimeoutSec, options) {
124
+ const tools = createToolSet(executor, bashTimeoutSec, options);
125
+ const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
126
+ if (options.webConfig && options.webConfig.enable !== false) {
127
+ byName.web_search = createWebSearchTool({
128
+ webConfig: options.webConfig,
129
+ securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
130
+ workspaceDir: options.workspaceDir,
131
+ channelId: options.runtimeContext.channelId,
132
+ });
133
+ byName.web_fetch = createWebFetchTool({
134
+ webConfig: options.webConfig,
135
+ securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
136
+ workspaceDir: options.workspaceDir,
137
+ channelId: options.runtimeContext.channelId,
138
+ });
139
+ }
140
+ return byName;
141
+ }
121
142
  function buildSubAgentTask(task, config, runtimeContext, contextBlocks) {
122
143
  const taskText = task.trim();
123
144
  const lines = [
@@ -288,17 +309,18 @@ export function createSubAgentTool(options) {
288
309
  details: createDetails(config, usage, assistantTurns, toolCalls, Date.now() - startedAt, Boolean(failureReason), failureReason),
289
310
  });
290
311
  };
312
+ const availableTools = Object.values(createNamedToolSet(options.executor, config.bashTimeoutSec, options));
291
313
  const worker = options.createWorker?.({
292
314
  subAgent: config,
293
315
  apiKey,
294
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
316
+ tools: filterToolsByName(availableTools, config.tools),
295
317
  }) ??
296
318
  new Agent({
297
319
  initialState: {
298
320
  systemPrompt: config.systemPrompt,
299
321
  model: config.model,
300
322
  thinkingLevel: "off",
301
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
323
+ tools: filterToolsByName(availableTools, config.tools),
302
324
  },
303
325
  convertToLlm,
304
326
  getApiKey: async () => apiKey,
@@ -0,0 +1,30 @@
1
+ export type WebSearchProvider = "brave" | "tavily" | "jina" | "searxng" | "duckduckgo";
2
+ export interface PipiclawWebSearchConfig {
3
+ provider: WebSearchProvider;
4
+ apiKey: string;
5
+ baseUrl: string;
6
+ maxResults: number;
7
+ timeoutMs: number;
8
+ }
9
+ export interface PipiclawWebFetchConfig {
10
+ maxChars: number;
11
+ timeoutMs: number;
12
+ maxImageBytes: number;
13
+ preferJina: boolean;
14
+ enableJinaFallback: boolean;
15
+ defaultExtractMode: "markdown" | "text";
16
+ }
17
+ export interface PipiclawWebToolsConfig {
18
+ enable: boolean;
19
+ proxy: string | null;
20
+ search: PipiclawWebSearchConfig;
21
+ fetch: PipiclawWebFetchConfig;
22
+ }
23
+ export interface PipiclawToolsConfig {
24
+ tools: {
25
+ web: PipiclawWebToolsConfig;
26
+ };
27
+ }
28
+ export declare const DEFAULT_TOOLS_CONFIG: PipiclawToolsConfig;
29
+ export declare function getToolsConfigPath(appHomeDir?: string): string;
30
+ export declare function loadToolsConfig(appHomeDir?: string): PipiclawToolsConfig;
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { APP_HOME_DIR, TOOLS_CONFIG_PATH } from "../paths.js";
4
+ import { isRecord } from "../shared/type-guards.js";
5
+ const WEB_SEARCH_PROVIDERS = ["brave", "tavily", "jina", "searxng", "duckduckgo"];
6
+ export const DEFAULT_TOOLS_CONFIG = {
7
+ tools: {
8
+ web: {
9
+ enable: false,
10
+ proxy: null,
11
+ search: {
12
+ provider: "brave",
13
+ apiKey: "",
14
+ baseUrl: "",
15
+ maxResults: 5,
16
+ timeoutMs: 30_000,
17
+ },
18
+ fetch: {
19
+ maxChars: 50_000,
20
+ timeoutMs: 30_000,
21
+ maxImageBytes: 10 * 1024 * 1024,
22
+ preferJina: false,
23
+ enableJinaFallback: false,
24
+ defaultExtractMode: "markdown",
25
+ },
26
+ },
27
+ },
28
+ };
29
+ function clampInteger(value, fallback, minimum, maximum) {
30
+ if (typeof value !== "number" || !Number.isFinite(value)) {
31
+ return fallback;
32
+ }
33
+ const normalized = Math.floor(value);
34
+ if (normalized < minimum) {
35
+ return fallback;
36
+ }
37
+ if (maximum !== undefined && normalized > maximum) {
38
+ return fallback;
39
+ }
40
+ return normalized;
41
+ }
42
+ function asTrimmedString(value, fallback = "") {
43
+ return typeof value === "string" ? value.trim() : fallback;
44
+ }
45
+ function asOptionalProxy(value) {
46
+ if (value === null || value === undefined) {
47
+ return null;
48
+ }
49
+ if (typeof value !== "string") {
50
+ return null;
51
+ }
52
+ const trimmed = value.trim();
53
+ return trimmed.length > 0 ? trimmed : null;
54
+ }
55
+ function mergeToolsConfig(source) {
56
+ if (!isRecord(source)) {
57
+ return DEFAULT_TOOLS_CONFIG;
58
+ }
59
+ const tools = isRecord(source.tools) ? source.tools : {};
60
+ const web = isRecord(tools.web) ? tools.web : {};
61
+ const search = isRecord(web.search) ? web.search : {};
62
+ const fetch = isRecord(web.fetch) ? web.fetch : {};
63
+ const providerValue = asTrimmedString(search.provider, DEFAULT_TOOLS_CONFIG.tools.web.search.provider).toLowerCase();
64
+ const provider = WEB_SEARCH_PROVIDERS.includes(providerValue)
65
+ ? providerValue
66
+ : DEFAULT_TOOLS_CONFIG.tools.web.search.provider;
67
+ const defaultExtractMode = asTrimmedString(fetch.defaultExtractMode, DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode);
68
+ return {
69
+ tools: {
70
+ web: {
71
+ enable: typeof web.enable === "boolean" ? web.enable : DEFAULT_TOOLS_CONFIG.tools.web.enable,
72
+ proxy: asOptionalProxy(web.proxy),
73
+ search: {
74
+ provider,
75
+ apiKey: asTrimmedString(search.apiKey),
76
+ baseUrl: asTrimmedString(search.baseUrl),
77
+ maxResults: clampInteger(search.maxResults, DEFAULT_TOOLS_CONFIG.tools.web.search.maxResults, 1, 10),
78
+ timeoutMs: clampInteger(search.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.search.timeoutMs, 1),
79
+ },
80
+ fetch: {
81
+ maxChars: clampInteger(fetch.maxChars, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxChars, 100),
82
+ timeoutMs: clampInteger(fetch.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.fetch.timeoutMs, 1),
83
+ maxImageBytes: clampInteger(fetch.maxImageBytes, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxImageBytes, 1),
84
+ preferJina: typeof fetch.preferJina === "boolean"
85
+ ? fetch.preferJina
86
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.preferJina,
87
+ enableJinaFallback: typeof fetch.enableJinaFallback === "boolean"
88
+ ? fetch.enableJinaFallback
89
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.enableJinaFallback,
90
+ defaultExtractMode: defaultExtractMode === "text" || defaultExtractMode === "markdown"
91
+ ? defaultExtractMode
92
+ : DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode,
93
+ },
94
+ },
95
+ },
96
+ };
97
+ }
98
+ export function getToolsConfigPath(appHomeDir = APP_HOME_DIR) {
99
+ return appHomeDir === APP_HOME_DIR ? TOOLS_CONFIG_PATH : join(appHomeDir, "tools.json");
100
+ }
101
+ export function loadToolsConfig(appHomeDir = APP_HOME_DIR) {
102
+ const configPath = getToolsConfigPath(appHomeDir);
103
+ if (!existsSync(configPath)) {
104
+ return DEFAULT_TOOLS_CONFIG;
105
+ }
106
+ try {
107
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
108
+ return mergeToolsConfig(raw);
109
+ }
110
+ catch (error) {
111
+ console.warn(`Failed to load tools config from ${configPath}: ${error}`);
112
+ return DEFAULT_TOOLS_CONFIG;
113
+ }
114
+ }
@@ -3,7 +3,7 @@ import * as Diff from "diff";
3
3
  import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
4
4
  import { logSecurityEvent } from "../security/logger.js";
5
5
  import { guardPath } from "../security/path-guard.js";
6
- import { shellEscape } from "../shared/shell-escape.js";
6
+ import { shellEscapePath } from "../shared/shell-escape.js";
7
7
  import { writeContent } from "./write-content.js";
8
8
  /**
9
9
  * Generate a unified diff string with line numbers and context
@@ -123,7 +123,7 @@ export function createEditTool(executor, options = {}) {
123
123
  }
124
124
  }
125
125
  // Read the file
126
- const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
126
+ const readResult = await executor.exec(`cat ${shellEscapePath(path)}`, { signal });
127
127
  if (readResult.code !== 0) {
128
128
  throw new Error(readResult.stderr || `File not found: ${path}`);
129
129
  }
@@ -2,8 +2,11 @@ import { APP_HOME_DIR } from "../paths.js";
2
2
  import { loadSecurityConfig } from "../security/config.js";
3
3
  import { createSubAgentTool } from "../subagents/tool.js";
4
4
  import { createBashTool } from "./bash.js";
5
+ import { loadToolsConfig } from "./config.js";
5
6
  import { createEditTool } from "./edit.js";
6
7
  import { createReadTool } from "./read.js";
8
+ import { createWebFetchTool } from "./web-fetch.js";
9
+ import { createWebSearchTool } from "./web-search.js";
7
10
  import { createWriteTool } from "./write.js";
8
11
  export function createPipiclawBaseTools(executor, options = {}) {
9
12
  const hasSecurityOptions = options.securityConfig || options.securityContext || options.channelId;
@@ -23,6 +26,7 @@ export function createPipiclawBaseTools(executor, options = {}) {
23
26
  }
24
27
  export function createPipiclawTools(options) {
25
28
  const securityConfig = loadSecurityConfig(APP_HOME_DIR);
29
+ const toolsConfig = loadToolsConfig(APP_HOME_DIR);
26
30
  const securityContext = {
27
31
  workspaceDir: options.workspaceDir,
28
32
  workspacePath: options.workspacePath,
@@ -33,8 +37,25 @@ export function createPipiclawTools(options) {
33
37
  securityContext,
34
38
  channelId: options.channelId,
35
39
  });
40
+ const webTools = toolsConfig.tools.web.enable === false
41
+ ? []
42
+ : [
43
+ createWebSearchTool({
44
+ webConfig: toolsConfig.tools.web,
45
+ securityConfig,
46
+ workspaceDir: options.workspaceDir,
47
+ channelId: options.channelId,
48
+ }),
49
+ createWebFetchTool({
50
+ webConfig: toolsConfig.tools.web,
51
+ securityConfig,
52
+ workspaceDir: options.workspaceDir,
53
+ channelId: options.channelId,
54
+ }),
55
+ ];
36
56
  return [
37
57
  ...baseTools,
58
+ ...webTools,
38
59
  createSubAgentTool({
39
60
  executor: options.executor,
40
61
  getCurrentModel: options.getCurrentModel,
@@ -45,6 +66,7 @@ export function createPipiclawTools(options) {
45
66
  getSubAgentDiscovery: options.getSubAgentDiscovery,
46
67
  getMemoryRecallSettings: options.getMemoryRecallSettings,
47
68
  securityConfig,
69
+ webConfig: toolsConfig.tools.web,
48
70
  runtimeContext: {
49
71
  workspacePath: options.workspacePath,
50
72
  channelId: options.channelId,
@@ -3,7 +3,7 @@ import { extname } from "path";
3
3
  import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
4
4
  import { logSecurityEvent } from "../security/logger.js";
5
5
  import { guardPath } from "../security/path-guard.js";
6
- import { shellEscape } from "../shared/shell-escape.js";
6
+ import { shellEscapePath, toShellPath } from "../shared/shell-escape.js";
7
7
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "./truncate.js";
8
8
  /**
9
9
  * Map of file extensions to MIME types for common image formats
@@ -70,7 +70,7 @@ export function createReadTool(executor, options = {}) {
70
70
  const mimeType = isImageFile(path);
71
71
  if (mimeType) {
72
72
  // Read as image (binary) - use base64
73
- const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
73
+ const result = await executor.exec(`base64 < ${shellEscapePath(path)}`, { signal });
74
74
  if (result.code !== 0) {
75
75
  throw new Error(result.stderr || `Failed to read file: ${path}`);
76
76
  }
@@ -84,7 +84,7 @@ export function createReadTool(executor, options = {}) {
84
84
  };
85
85
  }
86
86
  // Get total line count first
87
- const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
87
+ const countResult = await executor.exec(`wc -l < ${shellEscapePath(path)}`, { signal });
88
88
  if (countResult.code !== 0) {
89
89
  throw new Error(countResult.stderr || `Failed to read file: ${path}`);
90
90
  }
@@ -99,10 +99,10 @@ export function createReadTool(executor, options = {}) {
99
99
  // Read content with offset
100
100
  let cmd;
101
101
  if (startLine === 1) {
102
- cmd = `cat ${shellEscape(path)}`;
102
+ cmd = `cat ${shellEscapePath(path)}`;
103
103
  }
104
104
  else {
105
- cmd = `tail -n +${startLine} ${shellEscape(path)}`;
105
+ cmd = `tail -n +${startLine} ${shellEscapePath(path)}`;
106
106
  }
107
107
  const result = await executor.exec(cmd, { signal });
108
108
  if (result.code !== 0) {
@@ -124,7 +124,7 @@ export function createReadTool(executor, options = {}) {
124
124
  if (truncation.firstLineExceedsLimit) {
125
125
  // First line at offset exceeds 50KB - tell model to use bash
126
126
  const firstLineSize = formatSize(Buffer.byteLength(selectedContent.split("\n")[0], "utf-8"));
127
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
127
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${toShellPath(path)} | head -c ${DEFAULT_MAX_BYTES}]`;
128
128
  details = { truncation };
129
129
  }
130
130
  else if (truncation.truncated) {
@@ -0,0 +1,17 @@
1
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type { SecurityConfig } from "../security/types.js";
3
+ import type { PipiclawWebToolsConfig } from "./config.js";
4
+ declare const webFetchSchema: import("@sinclair/typebox").TObject<{
5
+ label: import("@sinclair/typebox").TString;
6
+ url: import("@sinclair/typebox").TString;
7
+ extractMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"markdown">, import("@sinclair/typebox").TLiteral<"text">]>>;
8
+ maxChars: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
9
+ }>;
10
+ export interface WebFetchToolOptions {
11
+ webConfig: PipiclawWebToolsConfig;
12
+ securityConfig: SecurityConfig;
13
+ workspaceDir: string;
14
+ channelId?: string;
15
+ }
16
+ export declare function createWebFetchTool(options: WebFetchToolOptions): AgentTool<typeof webFetchSchema>;
17
+ export {};