@nanhara/hara 0.121.1 → 0.122.1

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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -1,39 +1,472 @@
1
1
  // Search/listing tools — grep (regex across files), glob (path patterns), ls (one directory).
2
2
  // All read-only (kind: "read"), so they never hit the approval gate and run in parallel.
3
- import { readFileSync, readdirSync, statSync } from "node:fs";
4
- import { isAbsolute, resolve, join, relative, sep } from "node:path";
3
+ import { lstatSync, readdirSync, statSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
5
+ import { basename, dirname, isAbsolute, resolve, join, relative, sep } from "node:path";
5
6
  import { registerTool } from "./registry.js";
6
- import { walkFiles, isProbablyBinary } from "../fs-walk.js";
7
+ import { IGNORE_DIRS, walkFiles } from "../fs-walk.js";
8
+ import { isSensitiveFilePath, sensitiveFileError, sensitiveFilesAllowed, SENSITIVE_SEARCH_GLOBS, } from "../security/sensitive-files.js";
9
+ import { toolSubprocessEnv } from "../security/subprocess-env.js";
7
10
  const MAX_OUT = 60_000;
8
11
  const MAX_MATCHES = 300;
9
12
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
13
+ const GREP_TIMEOUT_MS = 5_000;
14
+ const MAX_PATTERN_CHARS = 4_096;
15
+ const MAX_GLOB_CHARS = 256;
16
+ const GLOB_MATCH_BUDGET_MS = 250;
10
17
  const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
11
18
  const absOf = (p, cwd) => (p ? (isAbsolute(p) ? p : resolve(cwd, p)) : cwd);
12
- /** Convert a glob (supports **, *, ?) to an anchored RegExp over POSIX paths. */
13
- function globToRegExp(glob) {
14
- let re = "";
15
- for (let i = 0; i < glob.length; i++) {
16
- const ch = glob[i];
17
- if (ch === "*") {
18
- if (glob[i + 1] === "*") {
19
- // ** matches across path separators (optionally swallow a trailing slash)
20
- i++;
21
- if (glob[i + 1] === "/")
22
- i++;
23
- re += "(?:.*/)?";
19
+ /** Match one path segment with *, ? using the classic greedy wildcard algorithm. No RegExp is built
20
+ * from model input, so repeated wildcard constructs cannot trigger regex backtracking. */
21
+ function matchGlobSegment(pattern, value) {
22
+ let patternAt = 0;
23
+ let valueAt = 0;
24
+ let starAt = -1;
25
+ let starValueAt = -1;
26
+ while (valueAt < value.length) {
27
+ if (patternAt < pattern.length && (pattern[patternAt] === "?" || pattern[patternAt] === value[valueAt])) {
28
+ patternAt++;
29
+ valueAt++;
30
+ }
31
+ else if (patternAt < pattern.length && pattern[patternAt] === "*") {
32
+ while (pattern[patternAt] === "*")
33
+ patternAt++;
34
+ starAt = patternAt;
35
+ starValueAt = valueAt;
36
+ }
37
+ else if (starAt >= 0) {
38
+ patternAt = starAt;
39
+ valueAt = ++starValueAt;
40
+ }
41
+ else {
42
+ return false;
43
+ }
44
+ }
45
+ while (pattern[patternAt] === "*")
46
+ patternAt++;
47
+ return patternAt === pattern.length;
48
+ }
49
+ /** Standard path glob semantics: ** as a whole segment crosses directories, while * and ? stay within
50
+ * one segment. The greedy ** checkpoint consumes path segments monotonically and remains bounded. */
51
+ function matchesGlob(pattern, value) {
52
+ const patternParts = pattern.split("/");
53
+ const valueParts = value.split("/");
54
+ let patternAt = 0;
55
+ let valueAt = 0;
56
+ let globstarAt = -1;
57
+ let globstarValueAt = -1;
58
+ while (valueAt < valueParts.length) {
59
+ if (patternAt < patternParts.length && patternParts[patternAt] !== "**" && matchGlobSegment(patternParts[patternAt], valueParts[valueAt])) {
60
+ patternAt++;
61
+ valueAt++;
62
+ }
63
+ else if (patternParts[patternAt] === "**") {
64
+ while (patternParts[patternAt] === "**")
65
+ patternAt++;
66
+ globstarAt = patternAt;
67
+ globstarValueAt = valueAt;
68
+ }
69
+ else if (globstarAt >= 0) {
70
+ patternAt = globstarAt;
71
+ valueAt = ++globstarValueAt;
72
+ }
73
+ else {
74
+ return false;
75
+ }
76
+ }
77
+ while (patternParts[patternAt] === "**")
78
+ patternAt++;
79
+ return patternAt === patternParts.length;
80
+ }
81
+ /** Run regex matching outside the agent process in ripgrep's linear-time regex engine. The subprocess is
82
+ * killed at a hard deadline and once enough output has arrived, so neither a pathological pattern nor a
83
+ * huge repository can wedge the CLI/Serve event loop. */
84
+ function runRipgrep(pattern, root, isFile, cwd, glob, ignoreCase) {
85
+ return new Promise((resolveResult) => {
86
+ const runCwd = isFile ? dirname(root) : root;
87
+ const target = isFile ? basename(root) : ".";
88
+ const args = [
89
+ "--json",
90
+ "--line-number",
91
+ "--color", "never",
92
+ "--no-ignore",
93
+ "--hidden",
94
+ "--threads", "1",
95
+ "--max-filesize", String(MAX_FILE_BYTES),
96
+ // Never stream a multi-megabyte minified line through JSON merely to slice it to 300 chars below.
97
+ "--max-columns", "1000",
98
+ "--max-columns-preview",
99
+ ];
100
+ for (const ignored of IGNORE_DIRS)
101
+ args.push("--glob", `!**/${ignored}/**`);
102
+ if (glob)
103
+ args.push("--glob", glob);
104
+ // Keep protected exclusions last: ripgrep resolves overlapping globs in argument order, so a caller's
105
+ // positive glob must not re-include a safe-looking .env template during a broad directory search.
106
+ if (!isFile && !sensitiveFilesAllowed()) {
107
+ for (const protectedGlob of SENSITIVE_SEARCH_GLOBS)
108
+ args.push("--glob", `!${protectedGlob}`);
109
+ }
110
+ if (ignoreCase)
111
+ args.push("--ignore-case");
112
+ args.push("--", pattern, target);
113
+ const child = spawn("rg", args, { cwd: runCwd, stdio: ["ignore", "pipe", "pipe"], env: toolSubprocessEnv() });
114
+ const lines = [];
115
+ let matches = 0;
116
+ let scanned = 0;
117
+ let outputChars = 0;
118
+ let pending = "";
119
+ let stderr = "";
120
+ let settled = false;
121
+ let timedOut = false;
122
+ let truncated = false;
123
+ let timer;
124
+ const finish = (result) => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ if (timer)
129
+ clearTimeout(timer);
130
+ resolveResult(result);
131
+ };
132
+ const stopForLimit = () => {
133
+ if (truncated)
134
+ return;
135
+ truncated = true;
136
+ child.kill("SIGKILL");
137
+ };
138
+ const consume = (line) => {
139
+ if (!line)
140
+ return;
141
+ let event;
142
+ try {
143
+ event = JSON.parse(line);
24
144
  }
25
- else {
26
- re += "[^/]*";
145
+ catch {
146
+ return;
147
+ }
148
+ if (event?.type === "summary") {
149
+ const searches = Number(event?.data?.stats?.searches);
150
+ if (Number.isFinite(searches))
151
+ scanned = searches;
152
+ return;
27
153
  }
154
+ if (event?.type !== "match" || matches >= MAX_MATCHES)
155
+ return;
156
+ const pathText = event?.data?.path?.text;
157
+ const lineNo = Number(event?.data?.line_number);
158
+ const content = event?.data?.lines?.text;
159
+ if (typeof pathText !== "string" || !Number.isFinite(lineNo) || typeof content !== "string")
160
+ return;
161
+ const absolute = resolve(runCwd, pathText);
162
+ if (isSensitiveFilePath(absolute))
163
+ return; // defense if a user glob overrides rg's exclusion ordering
164
+ const shown = toPosix(relative(cwd, absolute)) || toPosix(relative(root, absolute)) || basename(absolute);
165
+ const rendered = `${shown}:${lineNo}: ${content.replace(/\r?\n$/, "").trim().slice(0, 300)}`;
166
+ lines.push(rendered);
167
+ matches++;
168
+ outputChars += rendered.length + 1;
169
+ if (matches >= MAX_MATCHES || outputChars >= MAX_OUT)
170
+ stopForLimit();
171
+ };
172
+ child.stdout.setEncoding("utf8");
173
+ child.stdout.on("data", (chunk) => {
174
+ pending += chunk;
175
+ // JSON events are newline-delimited. max-columns keeps normal match events small; fail boundedly if
176
+ // a future rg version emits an unexpectedly giant unterminated event.
177
+ if (pending.length > 2 * 1024 * 1024 && !pending.includes("\n"))
178
+ return stopForLimit();
179
+ let newline;
180
+ while ((newline = pending.indexOf("\n")) >= 0) {
181
+ consume(pending.slice(0, newline));
182
+ pending = pending.slice(newline + 1);
183
+ if (truncated)
184
+ break;
185
+ }
186
+ });
187
+ child.stderr.setEncoding("utf8");
188
+ child.stderr.on("data", (chunk) => {
189
+ stderr = (stderr + chunk).slice(-4_000);
190
+ });
191
+ child.once("error", (error) => {
192
+ finish({
193
+ kind: error.code === "ENOENT" ? "missing" : "error",
194
+ lines,
195
+ matches,
196
+ scanned,
197
+ truncated,
198
+ error: error.message,
199
+ });
200
+ });
201
+ child.once("close", (code) => {
202
+ if (timedOut)
203
+ return finish({ kind: "timeout", lines: [], matches: 0, scanned, truncated: false });
204
+ if (truncated)
205
+ return finish({ kind: "ok", lines, matches, scanned, truncated: true });
206
+ if (code === 0 || code === 1)
207
+ return finish({ kind: "ok", lines, matches, scanned, truncated: false });
208
+ finish({ kind: "error", lines: [], matches: 0, scanned, truncated: false, error: stderr.trim() || `ripgrep exited ${code}` });
209
+ });
210
+ timer = setTimeout(() => {
211
+ timedOut = true;
212
+ child.kill("SIGKILL");
213
+ }, GREP_TIMEOUT_MS);
214
+ timer.unref();
215
+ });
216
+ }
217
+ // Dependency-free grep worker for npm/Windows installations that do not have rg. Untrusted JavaScript
218
+ // RegExp evaluation happens only in this disposable process; the parent applies a hard wall-clock kill.
219
+ // The worker opens each candidate O_NONBLOCK, fstats that SAME fd, and performs a positional bounded read,
220
+ // so a path exchanged for a FIFO/device cannot wedge either process.
221
+ const NODE_GREP_SOURCE = String.raw `
222
+ "use strict";
223
+ const fs = require("node:fs");
224
+ const path = require("node:path");
225
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
226
+ let input = "";
227
+ let inputRejected = false;
228
+ function execute() {
229
+ function emit(value) { process.stdout.write(JSON.stringify(value)); }
230
+ if (inputRejected) return emit({ kind: "error", error: "grep worker input exceeded its safety limit" });
231
+ let cfg;
232
+ try { cfg = JSON.parse(input); }
233
+ catch (error) { return emit({ kind: "error", error: "invalid grep worker input" }); }
234
+
235
+ let expression;
236
+ try { expression = new RegExp(cfg.pattern, cfg.ignoreCase ? "i" : ""); }
237
+ catch (error) { return emit({ kind: "invalid", error: String(error && error.message || error) }); }
238
+
239
+ function matchGlobSegment(pattern, value) {
240
+ let patternAt = 0;
241
+ let valueAt = 0;
242
+ let starAt = -1;
243
+ let starValueAt = -1;
244
+ while (valueAt < value.length) {
245
+ if (patternAt < pattern.length && (pattern[patternAt] === "?" || pattern[patternAt] === value[valueAt])) {
246
+ patternAt++;
247
+ valueAt++;
248
+ } else if (patternAt < pattern.length && pattern[patternAt] === "*") {
249
+ while (pattern[patternAt] === "*") patternAt++;
250
+ starAt = patternAt;
251
+ starValueAt = valueAt;
252
+ } else if (starAt >= 0) {
253
+ patternAt = starAt;
254
+ valueAt = ++starValueAt;
255
+ } else return false;
256
+ }
257
+ while (pattern[patternAt] === "*") patternAt++;
258
+ return patternAt === pattern.length;
259
+ }
260
+
261
+ function matchesGlob(pattern, value) {
262
+ const patternParts = pattern.split("/");
263
+ const valueParts = value.split("/");
264
+ let patternAt = 0;
265
+ let valueAt = 0;
266
+ let globstarAt = -1;
267
+ let globstarValueAt = -1;
268
+ while (valueAt < valueParts.length) {
269
+ if (patternAt < patternParts.length && patternParts[patternAt] !== "**" && matchGlobSegment(patternParts[patternAt], valueParts[valueAt])) {
270
+ patternAt++;
271
+ valueAt++;
272
+ } else if (patternParts[patternAt] === "**") {
273
+ while (patternParts[patternAt] === "**") patternAt++;
274
+ globstarAt = patternAt;
275
+ globstarValueAt = valueAt;
276
+ } else if (globstarAt >= 0) {
277
+ patternAt = globstarAt;
278
+ valueAt = ++globstarValueAt;
279
+ } else return false;
280
+ }
281
+ while (patternParts[patternAt] === "**") patternAt++;
282
+ return patternAt === patternParts.length;
283
+ }
284
+
285
+ // Candidate discovery and the complete protected-file policy run in the parent process. The worker gets
286
+ // only paths whose exact identity was already approved, then verifies that identity again on the opened fd.
287
+ const files = Array.isArray(cfg.files) ? cfg.files : [];
288
+
289
+ const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
290
+ function sameIdentity(info, candidate) {
291
+ return String(info.dev) === candidate.dev && String(info.ino) === candidate.ino;
292
+ }
293
+ function safeReadText(candidate) {
294
+ const absolute = candidate.path;
295
+ let fd;
296
+ try {
297
+ const before = fs.lstatSync(absolute, { bigint: true });
298
+ if (!before.isFile() || before.nlink !== 1n || !sameIdentity(before, candidate)) return null;
299
+ fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
300
+ const info = fs.fstatSync(fd, { bigint: true });
301
+ if (!info.isFile() || info.nlink !== 1n || !sameIdentity(info, candidate) || info.size > BigInt(cfg.maxFileBytes)) return null;
302
+ let total = 0;
303
+ while (total <= cfg.maxFileBytes) {
304
+ const count = fs.readSync(fd, readBuffer, total, cfg.maxFileBytes + 1 - total, total);
305
+ if (count === 0) break;
306
+ total += count;
307
+ }
308
+ if (total > cfg.maxFileBytes) return null;
309
+ const latest = fs.fstatSync(fd, { bigint: true });
310
+ const current = fs.lstatSync(absolute, { bigint: true });
311
+ if (!latest.isFile() || latest.nlink !== 1n || !sameIdentity(latest, candidate) || !sameIdentity(current, candidate)) return null;
312
+ if (latest.size !== info.size || latest.mtimeNs !== info.mtimeNs || latest.ctimeNs !== info.ctimeNs) return null;
313
+ const bytes = readBuffer.subarray(0, total);
314
+ if (bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0)) return null;
315
+ return bytes.toString("utf8");
316
+ } catch (error) {
317
+ return null;
318
+ } finally {
319
+ if (fd !== undefined) try { fs.closeSync(fd); } catch (error) {}
320
+ }
321
+ }
322
+
323
+ const lines = [];
324
+ let matches = 0;
325
+ let scanned = 0;
326
+ let outputChars = 0;
327
+ let truncated = cfg.candidatesTruncated === true;
328
+ for (const candidate of files) {
329
+ const absolute = candidate.path;
330
+ if (matches >= cfg.maxMatches || outputChars >= cfg.maxOutputChars) { truncated = true; break; }
331
+ const relativeForGlob = path.relative(cfg.isFile ? cfg.cwd : cfg.root, absolute).split(path.sep).join("/");
332
+ if (cfg.glob && !matchesGlob(cfg.glob, relativeForGlob)) continue;
333
+ const text = safeReadText(candidate);
334
+ if (text === null) continue;
335
+ scanned++;
336
+ const fileLines = text.split("\n");
337
+ for (let lineNumber = 0; lineNumber < fileLines.length; lineNumber++) {
338
+ if (!expression.test(fileLines[lineNumber])) continue;
339
+ let shown = path.relative(cfg.cwd, absolute).split(path.sep).join("/") || path.basename(absolute);
340
+ if (shown.length > 1000) shown = "…/" + shown.slice(-996);
341
+ const rendered = shown + ":" + (lineNumber + 1) + ": " + fileLines[lineNumber].trim().slice(0, 300);
342
+ if (outputChars + rendered.length + 1 > cfg.maxOutputChars) { truncated = true; break; }
343
+ lines.push(rendered);
344
+ matches++;
345
+ outputChars += rendered.length + 1;
346
+ if (matches >= cfg.maxMatches) { truncated = true; break; }
347
+ }
348
+ }
349
+ emit({ kind: "ok", lines: lines, matches: matches, scanned: scanned, truncated: truncated });
350
+ }
351
+ process.stdin.setEncoding("utf8");
352
+ process.stdin.on("data", function (chunk) {
353
+ if (inputRejected) return;
354
+ input += chunk;
355
+ if (Buffer.byteLength(input, "utf8") > MAX_INPUT_BYTES) inputRejected = true;
356
+ });
357
+ process.stdin.on("end", execute);
358
+ `;
359
+ function normalizeGrepWorkerResult(parsed) {
360
+ if (!parsed || !["ok", "invalid", "error"].includes(String(parsed.kind)))
361
+ throw new Error("invalid worker response");
362
+ return {
363
+ kind: parsed.kind,
364
+ lines: Array.isArray(parsed.lines) ? parsed.lines.slice(0, MAX_MATCHES).map(String) : [],
365
+ matches: Number.isFinite(parsed.matches) ? Number(parsed.matches) : 0,
366
+ scanned: Number.isFinite(parsed.scanned) ? Number(parsed.scanned) : 0,
367
+ truncated: parsed.truncated === true,
368
+ error: typeof parsed.error === "string" ? parsed.error : undefined,
369
+ };
370
+ }
371
+ function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
372
+ const allowSensitive = sensitiveFilesAllowed();
373
+ const discovered = isFile ? [root] : walkFiles(root, 8_001).map((path) => join(root, path));
374
+ const candidatesTruncated = !isFile && discovered.length > 8_000;
375
+ const files = [];
376
+ for (const path of discovered.slice(0, 8_000)) {
377
+ // Broad searches intentionally omit every .env variant, including safe templates. Explicitly searching
378
+ // one safe template remains supported, matching the ripgrep branch.
379
+ const securityBase = basename(path).replace(/:.*$/u, "").replace(/[. ]+$/u, "").toLowerCase();
380
+ if (!allowSensitive && !isFile && (securityBase === ".env" || securityBase.startsWith(".env.")))
381
+ continue;
382
+ if (sensitiveFileError(path, "search"))
383
+ continue;
384
+ try {
385
+ const info = lstatSync(path, { bigint: true });
386
+ // A hard-linked safe alias cannot prove where its other name lives. Reject every multi-link candidate
387
+ // before it can cross into the isolated regex worker.
388
+ if (!info.isFile() || info.nlink !== 1n || info.size > BigInt(MAX_FILE_BYTES))
389
+ continue;
390
+ files.push({ path, dev: String(info.dev), ino: String(info.ino) });
391
+ }
392
+ catch {
393
+ // Raced, unreadable, symlink, FIFO, or device: omit it rather than weakening the read boundary.
28
394
  }
29
- else if (ch === "?")
30
- re += "[^/]";
31
- else if (".+^${}()|[]\\".includes(ch))
32
- re += "\\" + ch;
33
- else
34
- re += ch;
35
395
  }
36
- return new RegExp("^" + re + "$");
396
+ const payload = JSON.stringify({
397
+ pattern,
398
+ root,
399
+ isFile,
400
+ cwd,
401
+ glob,
402
+ ignoreCase,
403
+ files,
404
+ candidatesTruncated,
405
+ maxFiles: 8_000,
406
+ maxFileBytes: MAX_FILE_BYTES,
407
+ maxMatches: MAX_MATCHES,
408
+ maxOutputChars: MAX_OUT,
409
+ });
410
+ if (Buffer.byteLength(payload, "utf8") > 16 * 1024 * 1024) {
411
+ return Promise.resolve({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep input exceeds its safety limit" });
412
+ }
413
+ return new Promise((resolveResult) => {
414
+ const isBun = typeof process.versions.bun === "string";
415
+ const args = isBun ? ["-e", NODE_GREP_SOURCE] : ["--max-old-space-size=128", "-e", NODE_GREP_SOURCE];
416
+ // In a Bun --compile build process.execPath is the hara executable. BUN_BE_BUN makes that embedded
417
+ // runtime act as the Bun CLI for this isolated eval subprocess instead of recursively launching hara.
418
+ const env = toolSubprocessEnv(process.env, isBun ? { BUN_BE_BUN: "1" } : {});
419
+ const child = spawn(process.execPath, args, { stdio: ["pipe", "pipe", "pipe"], env });
420
+ let stdout = "";
421
+ let stderr = "";
422
+ let settled = false;
423
+ let timedOut = false;
424
+ let timer;
425
+ const finish = (result) => {
426
+ if (settled)
427
+ return;
428
+ settled = true;
429
+ if (timer)
430
+ clearTimeout(timer);
431
+ resolveResult(result);
432
+ };
433
+ child.stdout.setEncoding("utf8");
434
+ child.stdout.on("data", (chunk) => {
435
+ stdout += chunk;
436
+ if (stdout.length > 256 * 1024) {
437
+ child.kill("SIGKILL");
438
+ finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep worker output exceeded its safety limit" });
439
+ }
440
+ });
441
+ child.stderr.setEncoding("utf8");
442
+ child.stderr.on("data", (chunk) => {
443
+ stderr = (stderr + chunk).slice(-4_000);
444
+ });
445
+ child.stdin.on("error", () => { });
446
+ child.once("error", (error) => {
447
+ finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: error.message });
448
+ });
449
+ child.once("close", (code) => {
450
+ if (timedOut)
451
+ return finish({ kind: "timeout", lines: [], matches: 0, scanned: 0, truncated: false });
452
+ if (settled)
453
+ return;
454
+ if (code !== 0)
455
+ return finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: stderr.trim() || `grep worker exited ${code}` });
456
+ try {
457
+ finish(normalizeGrepWorkerResult(JSON.parse(stdout)));
458
+ }
459
+ catch (error) {
460
+ finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: error?.message ?? "invalid grep worker response" });
461
+ }
462
+ });
463
+ timer = setTimeout(() => {
464
+ timedOut = true;
465
+ child.kill("SIGKILL");
466
+ }, GREP_TIMEOUT_MS);
467
+ timer.unref();
468
+ child.stdin.end(payload);
469
+ });
37
470
  }
38
471
  registerTool({
39
472
  name: "grep",
@@ -42,7 +475,7 @@ registerTool({
42
475
  input_schema: {
43
476
  type: "object",
44
477
  properties: {
45
- pattern: { type: "string", description: "JavaScript regular expression" },
478
+ pattern: { type: "string", description: "regular expression (executed in a bounded search subprocess)" },
46
479
  path: { type: "string", description: "directory or file to search (default: cwd)" },
47
480
  glob: { type: "string", description: "only search files whose path matches this glob (e.g. **/*.ts)" },
48
481
  ignore_case: { type: "boolean" },
@@ -51,61 +484,70 @@ registerTool({
51
484
  },
52
485
  kind: "read",
53
486
  async run(input, ctx) {
54
- let re;
55
- try {
56
- re = new RegExp(input.pattern, input.ignore_case ? "i" : "");
57
- }
58
- catch (e) {
59
- return `Error: invalid regex: ${e.message}`;
60
- }
487
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
488
+ if (pattern.length > MAX_PATTERN_CHARS)
489
+ return `Error: grep pattern exceeds ${MAX_PATTERN_CHARS} characters.`;
490
+ if (typeof input.glob === "string" && input.glob.length > MAX_GLOB_CHARS)
491
+ return `Error: grep glob exceeds ${MAX_GLOB_CHARS} characters.`;
61
492
  const root = absOf(input.path, ctx.cwd);
493
+ const denied = sensitiveFileError(root, "search");
494
+ if (denied)
495
+ return denied;
62
496
  let isFile = false;
63
497
  try {
64
- isFile = statSync(root).isFile();
498
+ const info = statSync(root);
499
+ if (!info.isFile() && !info.isDirectory())
500
+ return `Error: grep path is not a regular file or directory: ${input.path ?? "."}`;
501
+ isFile = info.isFile();
65
502
  }
66
503
  catch {
67
504
  return `Error: no such path: ${input.path ?? "."}`;
68
505
  }
69
- const rel = (abs) => toPosix(relative(ctx.cwd, abs)) || toPosix(relative(root, abs));
70
- const files = isFile ? [root] : walkFiles(root).map((f) => join(root, f));
71
- const globRe = input.glob ? globToRegExp(input.glob) : null;
72
- const lines = [];
73
- let matches = 0;
74
- let scanned = 0;
75
- for (const abs of files) {
76
- if (matches >= MAX_MATCHES)
77
- break;
78
- const r = toPosix(relative(ctx.cwd, abs));
79
- if (globRe && !globRe.test(toPosix(relative(isFile ? ctx.cwd : root, abs))))
80
- continue;
81
- let buf;
82
- try {
83
- if (statSync(abs).size > MAX_FILE_BYTES)
84
- continue;
85
- buf = readFileSync(abs);
86
- }
87
- catch {
88
- continue;
89
- }
90
- if (isProbablyBinary(buf))
91
- continue;
92
- scanned++;
93
- const text = buf.toString("utf8");
94
- const fileLines = text.split("\n");
95
- for (let i = 0; i < fileLines.length; i++) {
96
- if (re.test(fileLines[i])) {
97
- lines.push(`${r}:${i + 1}: ${fileLines[i].trim().slice(0, 300)}`);
98
- if (++matches >= MAX_MATCHES)
99
- break;
100
- }
506
+ const searchArgs = [
507
+ pattern,
508
+ root,
509
+ isFile,
510
+ ctx.cwd,
511
+ typeof input.glob === "string" ? input.glob : undefined,
512
+ input.ignore_case === true,
513
+ ];
514
+ // ripgrep opens pathnames itself, so a cooperating background process could exchange a candidate after
515
+ // discovery and restore it before the post-filter. While the protected-file policy is active, use the
516
+ // hardened worker that binds device+inode and reads through O_NOFOLLOW fds. The faster rg path is safe to
517
+ // opt back into only with the explicit one-process sensitive-file waiver.
518
+ const primary = sensitiveFilesAllowed()
519
+ ? await runRipgrep(...searchArgs)
520
+ : await runNodeGrep(...searchArgs);
521
+ if (primary.kind === "timeout")
522
+ return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
523
+ if (primary.kind === "invalid")
524
+ return `Error: invalid regex: ${primary.error ?? "invalid pattern"}`;
525
+ let { lines, matches, scanned } = primary;
526
+ let truncated = primary.truncated;
527
+ if (sensitiveFilesAllowed() && (primary.kind === "missing" || primary.kind === "error")) {
528
+ const fallback = await runNodeGrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
529
+ if (fallback.kind === "timeout")
530
+ return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
531
+ if (fallback.kind === "invalid")
532
+ return `Error: invalid regex: ${fallback.error ?? "invalid pattern"}`;
533
+ if (fallback.kind !== "ok") {
534
+ const rgDetail = primary.kind === "error" && primary.error ? `; ripgrep: ${primary.error}` : "";
535
+ return `Error: grep regex failed safely: ${fallback.error ?? "unknown worker error"}${rgDetail}`;
101
536
  }
537
+ lines = fallback.lines;
538
+ matches = fallback.matches;
539
+ scanned = fallback.scanned;
540
+ truncated = fallback.truncated;
541
+ }
542
+ else if (primary.kind !== "ok") {
543
+ return `Error: grep regex failed safely: ${primary.error ?? "unknown worker error"}`;
102
544
  }
103
545
  if (!lines.length)
104
546
  return `No matches for /${input.pattern}/ (scanned ${scanned} files).`;
105
547
  let body = lines.join("\n");
106
548
  if (body.length > MAX_OUT)
107
549
  body = body.slice(0, MAX_OUT) + "\n…[truncated]";
108
- const head = matches >= MAX_MATCHES ? `(showing first ${MAX_MATCHES} matches)\n` : "";
550
+ const head = truncated || matches >= MAX_MATCHES ? `(showing first ${Math.min(matches, MAX_MATCHES)} matches)\n` : "";
109
551
  return head + body;
110
552
  },
111
553
  });
@@ -123,12 +565,35 @@ registerTool({
123
565
  kind: "read",
124
566
  async run(input, ctx) {
125
567
  const root = absOf(input.path, ctx.cwd);
126
- const re = globToRegExp(input.pattern);
127
- const hits = walkFiles(root).filter((f) => re.test(f));
128
- if (!hits.length)
129
- return `No files match ${input.pattern}.`;
568
+ const denied = sensitiveFileError(root, "search");
569
+ if (denied)
570
+ return denied;
571
+ const pattern = typeof input.pattern === "string" ? input.pattern : "";
572
+ if (pattern.length > MAX_GLOB_CHARS)
573
+ return `Error: glob pattern exceeds ${MAX_GLOB_CHARS} characters.`;
574
+ const files = walkFiles(root);
575
+ const hits = [];
576
+ const started = Date.now();
577
+ let examined = 0;
578
+ let budgetReached = false;
579
+ for (const file of files) {
580
+ if ((examined & 31) === 0 && Date.now() - started >= GLOB_MATCH_BUDGET_MS) {
581
+ budgetReached = true;
582
+ break;
583
+ }
584
+ examined++;
585
+ if (matchesGlob(pattern, file))
586
+ hits.push(file);
587
+ }
588
+ if (!hits.length) {
589
+ return budgetReached
590
+ ? `No files matched ${pattern} before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; narrow \`path\` or simplify the glob.`
591
+ : `No files match ${pattern}.`;
592
+ }
130
593
  const shown = hits.slice(0, 400);
131
- const head = hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
594
+ const head = budgetReached
595
+ ? `(showing ${shown.length} matches found before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; examined ${examined}/${files.length} files)\n`
596
+ : hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
132
597
  return head + shown.join("\n");
133
598
  },
134
599
  });
@@ -142,6 +607,9 @@ registerTool({
142
607
  kind: "read",
143
608
  async run(input, ctx) {
144
609
  const dir = absOf(input.path, ctx.cwd);
610
+ const denied = sensitiveFileError(dir, "list");
611
+ if (denied)
612
+ return denied;
145
613
  let entries;
146
614
  try {
147
615
  entries = readdirSync(dir, { withFileTypes: true });
@@ -149,8 +617,9 @@ registerTool({
149
617
  catch (e) {
150
618
  return `Error: cannot list ${input.path ?? "."}: ${e.message}`;
151
619
  }
620
+ const protectedCount = entries.filter((entry) => !entry.isDirectory() && isSensitiveFilePath(join(dir, entry.name))).length;
152
621
  const rows = entries
153
- .filter((e) => !(e.isDirectory() && e.name === ".git"))
622
+ .filter((e) => !(e.isDirectory() && e.name === ".git") && (e.isDirectory() || !isSensitiveFilePath(join(dir, e.name))))
154
623
  .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
155
624
  .map((e) => {
156
625
  if (e.isDirectory())
@@ -164,7 +633,8 @@ registerTool({
164
633
  }
165
634
  return ` ${e.name} ${c_size(size)}`;
166
635
  });
167
- return rows.length ? rows.join("\n") : "(empty directory)";
636
+ const note = protectedCount ? `(${protectedCount} protected file${protectedCount === 1 ? "" : "s"} hidden)` : "";
637
+ return rows.length ? rows.join("\n") + (note ? `\n${note}` : "") : note || "(empty directory)";
168
638
  },
169
639
  });
170
640
  function c_size(n) {