@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
package/dist/fs-read.js CHANGED
@@ -1,20 +1,394 @@
1
1
  // Bounded streaming line reader for files too large to load as one string. It stores only the requested
2
2
  // window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
3
- import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, } from "node:fs";
4
+ import { open } from "node:fs/promises";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import { sensitiveFileError } from "./security/sensitive-files.js";
4
7
  export class BinaryFileError extends Error {
5
8
  constructor(path) {
6
9
  super(`${path} appears to be binary (NUL byte detected)`);
7
10
  this.name = "BinaryFileError";
8
11
  }
9
12
  }
13
+ export class NonRegularFileError extends Error {
14
+ code = "HARA_NOT_REGULAR_FILE";
15
+ constructor(path) {
16
+ super(`${path} is not a regular file`);
17
+ this.name = "NonRegularFileError";
18
+ }
19
+ }
20
+ export class FileReadLimitError extends Error {
21
+ limit;
22
+ code = "HARA_FILE_TOO_LARGE";
23
+ constructor(path, limit) {
24
+ super(`${path} exceeds the ${limit}-byte safe edit/read limit`);
25
+ this.limit = limit;
26
+ this.name = "FileReadLimitError";
27
+ }
28
+ }
10
29
  const DEFAULT_LINE_CAP = 2000;
11
30
  const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
31
+ const MAX_SLICE_LINES = 2_000;
32
+ /** Editing tools materialize the old text for diff/CAS. Keep that allocation explicitly bounded. */
33
+ export const MAX_EDIT_READ_BYTES = 64 * 1024 * 1024;
34
+ const READ_CHUNK_BYTES = 64 * 1024;
35
+ const MAX_PREFIX_CHARS = 1_000_000;
36
+ /** A context-loader rejected a protected path before any bytes could enter a model prompt. */
37
+ export class ProtectedContextFileError extends Error {
38
+ code = "HARA_PROTECTED_CONTEXT_FILE";
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "ProtectedContextFileError";
42
+ }
43
+ }
44
+ export class HardLinkedFileError extends Error {
45
+ code = "HARA_HARD_LINKED_FILE";
46
+ constructor(path) {
47
+ super(`${path} has multiple hard links; its protected identity cannot be established safely`);
48
+ this.name = "HardLinkedFileError";
49
+ }
50
+ }
51
+ /** Resolve a user-facing path once, checking both its lexical name and canonical target. Direct tools then
52
+ * open this canonical path with O_NOFOLLOW, so a safe symlink remains usable without a retarget race. */
53
+ export function resolveVerifiedModelPath(path, action = "read") {
54
+ const denied = sensitiveFileError(path, action);
55
+ if (denied)
56
+ throw new ProtectedContextFileError(denied);
57
+ const canonical = realpathSync.native(path);
58
+ const targetDenied = sensitiveFileError(canonical, action);
59
+ if (targetDenied)
60
+ throw new ProtectedContextFileError(targetDenied);
61
+ return canonical;
62
+ }
63
+ /**
64
+ * Common post-open identity check for security-sensitive readers. `opened` MUST be the fstat result for an
65
+ * O_NOFOLLOW descriptor. It verifies that the current lexical/canonical name still identifies that inode,
66
+ * rejects hard-link aliases by default, and applies the central protected-file policy to the actual target.
67
+ */
68
+ export function verifyOpenedRegularFileSync(path, opened, options = {}) {
69
+ if (!opened.isFile())
70
+ throw new NonRegularFileError(path);
71
+ if (options.rejectHardLinks !== false && opened.nlink > 1)
72
+ throw new HardLinkedFileError(path);
73
+ const canonical = realpathSync.native(path);
74
+ if (options.protectSensitive !== false) {
75
+ const targetDenied = sensitiveFileError(canonical, options.action ?? "read");
76
+ if (targetDenied)
77
+ throw new ProtectedContextFileError(targetDenied);
78
+ }
79
+ const currentLink = lstatSync(path);
80
+ const currentTarget = statSync(canonical);
81
+ if (currentLink.isSymbolicLink()
82
+ || currentLink.dev !== opened.dev
83
+ || currentLink.ino !== opened.ino
84
+ || currentTarget.dev !== opened.dev
85
+ || currentTarget.ino !== opened.ino) {
86
+ throw new Error(`refusing to access ${path}: path changed while opening it`);
87
+ }
88
+ return canonical;
89
+ }
90
+ /** Open + validate a regular file for direct tools that need to keep reading the verified descriptor. */
91
+ export async function openVerifiedRegularFileNoFollow(path, options = {}) {
92
+ if (options.protectSensitive !== false) {
93
+ const denied = sensitiveFileError(path, options.action ?? "read");
94
+ if (denied)
95
+ throw new ProtectedContextFileError(denied);
96
+ }
97
+ const before = lstatSync(path);
98
+ if (before.isSymbolicLink())
99
+ throw new NonRegularFileError(path);
100
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
101
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
102
+ try {
103
+ const info = await handle.stat();
104
+ const canonicalPath = verifyOpenedRegularFileSync(path, info, options);
105
+ return { handle, info, canonicalPath };
106
+ }
107
+ catch (error) {
108
+ await handle.close().catch(() => { });
109
+ throw error;
110
+ }
111
+ }
112
+ /** Materialize a direct-tool pre-read from a verified descriptor. Safe symlinks should be canonicalized by
113
+ * `resolveVerifiedModelPath` first; hard-linked aliases are rejected because their protected origin cannot be
114
+ * inferred from the opened pathname alone. */
115
+ export async function readVerifiedRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES, action = "read") {
116
+ const limit = checkedContextLimit(maxBytes);
117
+ const verified = await openVerifiedRegularFileNoFollow(path, { action, rejectHardLinks: true, protectSensitive: true });
118
+ try {
119
+ const { handle, info } = verified;
120
+ if (info.size > limit)
121
+ throw new FileReadLimitError(path, limit);
122
+ const chunks = [];
123
+ let total = 0;
124
+ let position = 0;
125
+ while (total <= limit) {
126
+ const want = Math.min(READ_CHUNK_BYTES, limit + 1 - total);
127
+ const buffer = Buffer.allocUnsafe(want);
128
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
129
+ if (!bytesRead)
130
+ break;
131
+ chunks.push(buffer.subarray(0, bytesRead));
132
+ total += bytesRead;
133
+ position += bytesRead;
134
+ }
135
+ if (total > limit)
136
+ throw new FileReadLimitError(path, limit);
137
+ const latest = await handle.stat();
138
+ verifyOpenedRegularFileSync(path, latest, { action, rejectHardLinks: true, protectSensitive: true });
139
+ if (latest.dev !== info.dev
140
+ || latest.ino !== info.ino
141
+ || latest.size !== info.size
142
+ || latest.mtimeMs !== info.mtimeMs
143
+ || latest.ctimeMs !== info.ctimeMs)
144
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
145
+ return {
146
+ text: Buffer.concat(chunks, total).toString("utf8"),
147
+ dev: info.dev,
148
+ ino: info.ino,
149
+ mode: info.mode & 0o777,
150
+ nlink: info.nlink,
151
+ };
152
+ }
153
+ finally {
154
+ await verified.handle.close().catch(() => { });
155
+ }
156
+ }
157
+ /** Synchronous counterpart for startup/configuration paths that cannot make their public API async. It
158
+ * validates and reads the same O_NOFOLLOW descriptor, rejects aliases with multiple hard links, bounds the
159
+ * allocation, and verifies identity/metadata again after the read. Internal state readers may explicitly
160
+ * disable the model-facing protected-file policy while retaining every filesystem identity check. */
161
+ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES, options = {}) {
162
+ const action = options.action ?? "read";
163
+ if (options.protectSensitive !== false) {
164
+ const denied = sensitiveFileError(path, action);
165
+ if (denied)
166
+ throw new ProtectedContextFileError(denied);
167
+ }
168
+ const before = lstatSync(path);
169
+ if (before.isSymbolicLink())
170
+ throw new NonRegularFileError(path);
171
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
172
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
173
+ try {
174
+ const info = fstatSync(fd);
175
+ verifyOpenedRegularFileSync(path, info, {
176
+ action,
177
+ rejectHardLinks: options.rejectHardLinks !== false,
178
+ protectSensitive: options.protectSensitive !== false,
179
+ });
180
+ const limit = checkedContextLimit(maxBytes);
181
+ if (info.size > limit)
182
+ throw new FileReadLimitError(path, limit);
183
+ const bytes = readFdBytesSync(fd, Math.min(limit + 1, info.size + 1));
184
+ if (bytes.length > limit)
185
+ throw new FileReadLimitError(path, limit);
186
+ const latest = fstatSync(fd);
187
+ verifyOpenedRegularFileSync(path, latest, {
188
+ action,
189
+ rejectHardLinks: options.rejectHardLinks !== false,
190
+ protectSensitive: options.protectSensitive !== false,
191
+ });
192
+ if (latest.dev !== info.dev
193
+ || latest.ino !== info.ino
194
+ || latest.size !== info.size
195
+ || latest.mtimeMs !== info.mtimeMs
196
+ || latest.ctimeMs !== info.ctimeMs)
197
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
198
+ return {
199
+ text: bytes.toString("utf8"),
200
+ dev: info.dev,
201
+ ino: info.ino,
202
+ mode: info.mode & 0o777,
203
+ nlink: info.nlink,
204
+ };
205
+ }
206
+ finally {
207
+ closeSync(fd);
208
+ }
209
+ }
210
+ /**
211
+ * Open a model-context source without following its final component, validate the SAME descriptor, and
212
+ * re-check the canonical target against the central protected-file policy. The identity comparison closes
213
+ * the ordinary validate-then-open race: if the pathname is exchanged while it is being inspected, callers
214
+ * get an error rather than bytes from an unverified inode.
215
+ *
216
+ * This is intentionally synchronous because AGENTS/skills/roles/memory are assembled synchronously before
217
+ * a provider turn. Keep the reader callback small and bounded.
218
+ */
219
+ function withVerifiedContextFdSync(path, read) {
220
+ const denied = sensitiveFileError(path, "load into model context");
221
+ if (denied)
222
+ throw new ProtectedContextFileError(denied);
223
+ // O_NOFOLLOW is not exposed on every platform. The before/after lstat checks retain fail-closed symlink
224
+ // behaviour there; on POSIX O_NOFOLLOW makes the critical open itself atomic.
225
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
226
+ const before = lstatSync(path);
227
+ if (before.isSymbolicLink())
228
+ throw new NonRegularFileError(path);
229
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
230
+ try {
231
+ const info = fstatSync(fd);
232
+ verifyOpenedRegularFileSync(path, info, {
233
+ action: "load into model context",
234
+ rejectHardLinks: true,
235
+ protectSensitive: true,
236
+ });
237
+ const result = read(fd, info.size);
238
+ const latest = fstatSync(fd);
239
+ verifyOpenedRegularFileSync(path, latest, {
240
+ action: "load into model context",
241
+ rejectHardLinks: true,
242
+ protectSensitive: true,
243
+ });
244
+ if (latest.dev !== info.dev
245
+ || latest.ino !== info.ino
246
+ || latest.size !== info.size
247
+ || latest.mtimeMs !== info.mtimeMs
248
+ || latest.ctimeMs !== info.ctimeMs)
249
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
250
+ return result;
251
+ }
252
+ finally {
253
+ closeSync(fd);
254
+ }
255
+ }
256
+ function checkedContextLimit(maxBytes) {
257
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
258
+ return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
259
+ }
260
+ function readFdBytesSync(fd, count) {
261
+ const out = Buffer.allocUnsafe(count);
262
+ let offset = 0;
263
+ while (offset < count) {
264
+ const n = readSync(fd, out, offset, count - offset, offset);
265
+ if (n === 0)
266
+ break;
267
+ offset += n;
268
+ }
269
+ return out.subarray(0, offset);
270
+ }
271
+ /** Safely materialize a bounded UTF-8 context file. Oversized or binary inputs fail closed. */
272
+ export function readModelContextFileSync(path, maxBytes) {
273
+ const limit = checkedContextLimit(maxBytes);
274
+ return withVerifiedContextFdSync(path, (fd, size) => {
275
+ if (size > limit)
276
+ throw new FileReadLimitError(path, limit);
277
+ // Read one byte past the stated size/limit so concurrent growth is never silently included or ignored.
278
+ const bytes = readFdBytesSync(fd, Math.min(limit + 1, size + 1));
279
+ if (bytes.length > limit)
280
+ throw new FileReadLimitError(path, limit);
281
+ if (bytes.includes(0))
282
+ throw new BinaryFileError(path);
283
+ return bytes.toString("utf8");
284
+ });
285
+ }
286
+ /** Safe bounded-prefix counterpart for @file expansion; it never materializes the remainder of a huge file. */
287
+ export function readModelContextPrefixSync(path, maxChars) {
288
+ const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
289
+ const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
290
+ return withVerifiedContextFdSync(path, (fd, size) => {
291
+ const byteLimit = Math.min(size, chars * 4 + 4);
292
+ const bytes = readFdBytesSync(fd, byteLimit);
293
+ const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
294
+ const decoded = binary ? "" : bytes.toString("utf8");
295
+ return {
296
+ text: decoded.slice(0, chars),
297
+ truncated: size > bytes.length || decoded.length > chars,
298
+ binary,
299
+ };
300
+ });
301
+ }
302
+ function utf8BytePrefix(value, maxBytes) {
303
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
304
+ return value;
305
+ let used = 0;
306
+ let end = 0;
307
+ for (const char of value) {
308
+ const bytes = Buffer.byteLength(char, "utf8");
309
+ if (used + bytes > maxBytes)
310
+ break;
311
+ used += bytes;
312
+ end += char.length;
313
+ }
314
+ return value.slice(0, end);
315
+ }
316
+ /**
317
+ * Verified byte-budgeted prefix reader for project instructions. Unlike `readModelContextFileSync`, an
318
+ * oversized text file keeps its useful beginning instead of disappearing from model context. The returned
319
+ * text is always at most `maxBytes` UTF-8 bytes and never ends with half of a multibyte code point.
320
+ */
321
+ export function readModelContextBytePrefixSync(path, maxBytes) {
322
+ const limit = checkedContextLimit(maxBytes);
323
+ return withVerifiedContextFdSync(path, (fd, size) => {
324
+ const bytes = readFdBytesSync(fd, Math.min(size, limit));
325
+ const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
326
+ if (binary)
327
+ return { text: "", truncated: size > bytes.length, binary: true };
328
+ // StringDecoder buffers an incomplete UTF-8 suffix when the file continues past this prefix. This
329
+ // avoids injecting U+FFFD merely because the byte budget happened to split a multibyte character.
330
+ const decoder = new StringDecoder("utf8");
331
+ const decoded = size > bytes.length ? decoder.write(bytes) : decoder.end(bytes);
332
+ const text = utf8BytePrefix(decoded, limit);
333
+ return { text, truncated: size > bytes.length || text.length < decoded.length, binary: false };
334
+ });
335
+ }
336
+ /** Open without blocking on a FIFO, validate the SAME descriptor, then read at most maxBytes from it.
337
+ * Path-level stat→readFile is unsafe because the path can be exchanged for a pipe/device between calls. */
338
+ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
339
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
340
+ const limit = Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
341
+ const handle = await open(path, flags);
342
+ try {
343
+ const info = await handle.stat();
344
+ if (!info.isFile())
345
+ throw new NonRegularFileError(path);
346
+ if (info.size > limit)
347
+ throw new FileReadLimitError(path, limit);
348
+ const chunks = [];
349
+ let total = 0;
350
+ let position = 0;
351
+ // Read one byte past the limit so concurrent growth cannot bypass the pre-read size check.
352
+ while (total <= limit) {
353
+ const want = Math.min(READ_CHUNK_BYTES, limit + 1 - total);
354
+ const buffer = Buffer.allocUnsafe(want);
355
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
356
+ if (bytesRead === 0)
357
+ break;
358
+ chunks.push(buffer.subarray(0, bytesRead));
359
+ total += bytesRead;
360
+ position += bytesRead;
361
+ }
362
+ if (total > limit)
363
+ throw new FileReadLimitError(path, limit);
364
+ return { text: Buffer.concat(chunks, total).toString("utf8"), dev: info.dev, ino: info.ino, mode: info.mode & 0o777, nlink: info.nlink };
365
+ }
366
+ finally {
367
+ await handle.close().catch(() => { });
368
+ }
369
+ }
370
+ export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
371
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK);
372
+ }
373
+ /** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
374
+ export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
375
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
376
+ return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
377
+ }
378
+ export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
379
+ return (await readRegularFileSnapshot(path, maxBytes)).text;
380
+ }
12
381
  /** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
13
382
  export function readTextPrefixSync(path, maxChars) {
14
- const chars = Math.max(0, Math.floor(maxChars));
15
- const fd = openSync(path, "r");
383
+ const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
384
+ const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
385
+ // O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
386
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
16
387
  try {
17
- const size = fstatSync(fd).size;
388
+ const info = fstatSync(fd);
389
+ if (!info.isFile())
390
+ throw new NonRegularFileError(path);
391
+ const size = info.size;
18
392
  // Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
19
393
  // `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
20
394
  const byteLimit = Math.min(size, chars * 4 + 4);
@@ -41,11 +415,19 @@ export function readTextPrefixSync(path, maxChars) {
41
415
  }
42
416
  /** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
43
417
  export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
44
- const start = Math.max(1, Math.floor(offset));
45
- const want = Math.max(1, Math.floor(limit));
418
+ const requestedStart = Number.isFinite(offset) ? Math.floor(offset) : 1;
419
+ const requestedLines = Number.isFinite(limit) ? Math.floor(limit) : 300;
420
+ const start = Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, requestedStart));
421
+ const want = Math.min(MAX_SLICE_LINES, Math.max(1, requestedLines));
46
422
  const requestedEnd = start + want - 1;
47
- const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
48
- const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
423
+ const requestedLineCap = Number.isFinite(options.lineCap)
424
+ ? Math.floor(options.lineCap)
425
+ : DEFAULT_LINE_CAP;
426
+ const lineCap = Math.min(DEFAULT_LINE_CAP, Math.max(1, requestedLineCap));
427
+ const requestedScan = Number.isFinite(options.maxScanChars)
428
+ ? Math.floor(options.maxScanChars)
429
+ : DEFAULT_MAX_SCAN;
430
+ const maxScan = Math.min(DEFAULT_MAX_SCAN, Math.max(lineCap, requestedScan));
49
431
  const rendered = [];
50
432
  let lineNo = 1;
51
433
  let linePrefix = "";
@@ -98,8 +480,20 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
98
480
  if (current > requestedEnd)
99
481
  hasMore = true;
100
482
  };
101
- const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
483
+ // Keep validation and streaming on the same non-blocking descriptor. This closes the stat→open race
484
+ // where an attacker/local generator exchanges a regular path for a FIFO after validation.
485
+ const verified = options.protectSensitive
486
+ ? await openVerifiedRegularFileNoFollow(path, { action: "read", rejectHardLinks: true, protectSensitive: true })
487
+ : null;
488
+ const handle = verified?.handle ?? await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
489
+ let stream;
102
490
  try {
491
+ const info = verified?.info ?? await handle.stat();
492
+ if (!info.isFile())
493
+ throw new NonRegularFileError(path);
494
+ // `end` is an inclusive byte offset. It makes the scan ceiling a true fd-level byte bound (not just
495
+ // a post-read character counter, which could overshoot badly on multi-byte text or a giant chunk).
496
+ stream = handle.createReadStream({ encoding: "utf8", highWaterMark: 64 * 1024, autoClose: false, end: maxScan - 1 });
103
497
  for await (const raw of stream) {
104
498
  const chunk = String(raw);
105
499
  if (chunk.length)
@@ -125,17 +519,32 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
125
519
  stoppedEarly = true;
126
520
  break;
127
521
  }
128
- if (scanned >= maxScan) {
522
+ }
523
+ if (!stoppedEarly && stream.bytesRead >= maxScan) {
524
+ // Re-fstat the same open file description so concurrent growth is also detected. An exact-size file
525
+ // is genuine EOF and should still receive the normal total-line rendering.
526
+ const latest = await handle.stat();
527
+ if (latest.size > stream.bytesRead) {
129
528
  scanLimited = true;
130
529
  stoppedEarly = true;
131
530
  if (lineNo >= start && lineNo <= requestedEnd)
132
531
  finishLine(true);
133
- break;
134
532
  }
135
533
  }
534
+ if (verified) {
535
+ const latest = await handle.stat();
536
+ verifyOpenedRegularFileSync(path, latest, { action: "read", rejectHardLinks: true, protectSensitive: true });
537
+ if (latest.dev !== info.dev
538
+ || latest.ino !== info.ino
539
+ || latest.size !== info.size
540
+ || latest.mtimeMs !== info.mtimeMs
541
+ || latest.ctimeMs !== info.ctimeMs)
542
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
543
+ }
136
544
  }
137
545
  finally {
138
- stream.destroy();
546
+ stream?.destroy();
547
+ await handle.close().catch(() => { });
139
548
  }
140
549
  if (!stoppedEarly) {
141
550
  // A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
package/dist/fs-walk.js CHANGED
@@ -4,6 +4,8 @@ import { readdirSync, statSync } from "node:fs";
4
4
  import { join, relative, sep } from "node:path";
5
5
  import { execSync } from "node:child_process";
6
6
  import { fuzzyRank } from "./fuzzy.js";
7
+ import { isSensitiveFilePath } from "./security/sensitive-files.js";
8
+ import { toolSubprocessEnv } from "./security/subprocess-env.js";
7
9
  export const IGNORE_DIRS = new Set([
8
10
  ".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
9
11
  "coverage", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
@@ -36,7 +38,10 @@ export function walkFiles(root, cap = 8000) {
36
38
  stack.push(join(dir, e.name));
37
39
  }
38
40
  else if (e.isFile()) {
39
- files.push(toPosix(relative(root, join(dir, e.name))));
41
+ const absolute = join(dir, e.name);
42
+ if (isSensitiveFilePath(absolute))
43
+ continue;
44
+ files.push(toPosix(relative(root, absolute)));
40
45
  }
41
46
  }
42
47
  }
@@ -50,6 +55,7 @@ export function listProjectFiles(root, cap = 8000) {
50
55
  try {
51
56
  const out = execSync("git ls-files --cached --others --exclude-standard", {
52
57
  cwd: root,
58
+ env: toolSubprocessEnv(),
53
59
  encoding: "utf8",
54
60
  maxBuffer: 32 * 1024 * 1024,
55
61
  stdio: ["ignore", "pipe", "ignore"],
@@ -59,7 +65,7 @@ export function listProjectFiles(root, cap = 8000) {
59
65
  })
60
66
  .split("\n")
61
67
  .map((s) => s.trim())
62
- .filter(Boolean);
68
+ .filter((path) => Boolean(path) && !isSensitiveFilePath(join(root, path)));
63
69
  if (out.length)
64
70
  return out.slice(0, cap);
65
71
  }