@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.
- package/CHANGELOG.md +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/tools/search.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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 =
|
|
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
|
-
|
|
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) {
|