@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
@@ -0,0 +1,379 @@
1
+ // Immutable, owner-only snapshots for files leaving the machine through a gateway adapter. `send_file`
2
+ // runs in a child process and the adapter sends later in the daemon; queueing an original pathname would
3
+ // leave a TOCTOU window where that pathname could be exchanged for a symlink or a different file.
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmodSync, constants, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, } from "node:fs";
6
+ import { open } from "node:fs/promises";
7
+ import { basename, dirname, extname, join, resolve } from "node:path";
8
+ import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync } from "../fs-read.js";
9
+ export const OUTBOUND_FILE_MAX_BYTES = 20 * 1024 * 1024;
10
+ export const OUTBOUND_BATCH_MAX_BYTES = 20 * 1024 * 1024;
11
+ export const OUTBOUND_BATCH_MAX_FILES = 4;
12
+ const OUTBOX_MAX_BYTES = 256 * 1024;
13
+ const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
14
+ const OWNED_SNAPSHOT = new RegExp(`^(?:\\.${UUID}\\.part|[a-z0-9][a-z0-9_-]{0,48}-${UUID}(?:\\.[a-z0-9]{1,12})?)$`, "i");
15
+ const SNAPSHOT_SUFFIX = new RegExp(`-${UUID}(?=\\.|$)`, "i");
16
+ // Cleanup receives only the cleanup-only path from serve.ts. Remember the identity that produced its payload so
17
+ // a later path replacement is never mistaken for the consumed snapshot.
18
+ const consumedSnapshotIdentities = new Map();
19
+ const outboundQueueLocks = new Map();
20
+ export function outboundSnapshotDir(outbox) {
21
+ return `${resolve(outbox)}.files`;
22
+ }
23
+ function ownedByProcess(path) {
24
+ if (typeof process.getuid !== "function")
25
+ return true;
26
+ return lstatSync(path).uid === process.getuid();
27
+ }
28
+ function ensureSnapshotDir(outbox) {
29
+ const dir = outboundSnapshotDir(outbox);
30
+ try {
31
+ mkdirSync(dir, { mode: 0o700 });
32
+ }
33
+ catch (error) {
34
+ if (error?.code !== "EEXIST")
35
+ throw error;
36
+ }
37
+ const info = lstatSync(dir);
38
+ if (!info.isDirectory() || info.isSymbolicLink() || !ownedByProcess(dir)) {
39
+ throw new Error(`unsafe gateway snapshot directory: ${dir}`);
40
+ }
41
+ chmodSync(dir, 0o700);
42
+ return dir;
43
+ }
44
+ function safeExtension(source) {
45
+ const extension = extname(basename(source)).toLowerCase();
46
+ return /^\.[a-z0-9]{1,12}$/.test(extension) ? extension : "";
47
+ }
48
+ function safeStem(source) {
49
+ const extension = extname(basename(source));
50
+ const raw = basename(source, extension)
51
+ .normalize("NFKD")
52
+ .replace(/[^a-z0-9_-]+/gi, "-")
53
+ .replace(/^-+|-+$/g, "")
54
+ .slice(0, 48);
55
+ return raw || "attachment";
56
+ }
57
+ function existingBatchUsage(dir) {
58
+ let bytes = 0;
59
+ let files = 0;
60
+ for (const name of readdirSync(dir)) {
61
+ if (!OWNED_SNAPSHOT.test(name))
62
+ continue;
63
+ const path = join(dir, name);
64
+ const info = lstatSync(path);
65
+ if (!info.isFile() || info.isSymbolicLink() || info.nlink > 1 || !ownedByProcess(path)) {
66
+ throw new Error(`unsafe gateway snapshot entry: ${path}`);
67
+ }
68
+ files++;
69
+ bytes += info.size;
70
+ if (!Number.isSafeInteger(bytes))
71
+ throw new Error("gateway send batch size is invalid");
72
+ }
73
+ return { bytes, files };
74
+ }
75
+ async function appendOutbox(outbox, snapshot) {
76
+ const path = resolve(outbox);
77
+ let before;
78
+ try {
79
+ before = lstatSync(path);
80
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink > 1 || !ownedByProcess(path)) {
81
+ throw new Error(`unsafe gateway outbox: ${outbox}`);
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error?.code !== "ENOENT")
86
+ throw error;
87
+ }
88
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
89
+ const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | noFollow, 0o600);
90
+ try {
91
+ const info = await handle.stat();
92
+ const current = lstatSync(path);
93
+ if (!info.isFile()
94
+ || info.nlink > 1
95
+ || current.isSymbolicLink()
96
+ || current.dev !== info.dev
97
+ || current.ino !== info.ino
98
+ || (before && (before.dev !== info.dev || before.ino !== info.ino))
99
+ || !ownedByProcess(path))
100
+ throw new Error(`unsafe gateway outbox: ${outbox}`);
101
+ await handle.chmod(0o600);
102
+ await handle.writeFile(snapshot + "\n", "utf8");
103
+ await handle.sync();
104
+ }
105
+ finally {
106
+ await handle.close().catch(() => { });
107
+ }
108
+ }
109
+ /** Copy a verified source fd to a private immutable snapshot, then append only that snapshot to the queue. */
110
+ async function queueOutboundSnapshotLocked(sourcePath, outbox) {
111
+ const source = resolve(sourcePath);
112
+ const verified = await openVerifiedRegularFileNoFollow(source, {
113
+ action: "send",
114
+ rejectHardLinks: true,
115
+ protectSensitive: true,
116
+ });
117
+ let destination;
118
+ let temporary;
119
+ let snapshot;
120
+ let published = false;
121
+ try {
122
+ if (verified.info.size > OUTBOUND_FILE_MAX_BYTES) {
123
+ throw new Error(`file exceeds the ${OUTBOUND_FILE_MAX_BYTES}-byte gateway send limit`);
124
+ }
125
+ const dir = ensureSnapshotDir(outbox);
126
+ const usage = existingBatchUsage(dir);
127
+ if (usage.files >= OUTBOUND_BATCH_MAX_FILES) {
128
+ throw new Error(`gateway send batch exceeds the ${OUTBOUND_BATCH_MAX_FILES}-file limit`);
129
+ }
130
+ if (verified.info.size > OUTBOUND_BATCH_MAX_BYTES - usage.bytes) {
131
+ throw new Error(`gateway send batch exceeds the ${OUTBOUND_BATCH_MAX_BYTES}-byte limit`);
132
+ }
133
+ const id = randomUUID();
134
+ temporary = join(dir, `.${id}.part`);
135
+ // Keep a recognizable, sanitized stem for a friendlier attachment name while the UUID preserves uniqueness.
136
+ snapshot = join(dir, `${safeStem(source)}-${id}${safeExtension(source)}`);
137
+ destination = await open(temporary, "wx", 0o600);
138
+ const buffer = Buffer.allocUnsafe(64 * 1024);
139
+ let position = 0;
140
+ while (position < verified.info.size) {
141
+ const want = Math.min(buffer.length, verified.info.size - position);
142
+ const { bytesRead } = await verified.handle.read(buffer, 0, want, position);
143
+ if (bytesRead <= 0)
144
+ throw new Error(`source changed while snapshotting ${source}`);
145
+ let written = 0;
146
+ while (written < bytesRead) {
147
+ const result = await destination.write(buffer, written, bytesRead - written, position + written);
148
+ if (result.bytesWritten <= 0)
149
+ throw new Error("failed to write gateway snapshot");
150
+ written += result.bytesWritten;
151
+ }
152
+ position += bytesRead;
153
+ }
154
+ const latest = await verified.handle.stat();
155
+ verifyOpenedRegularFileSync(source, latest, {
156
+ action: "send",
157
+ rejectHardLinks: true,
158
+ protectSensitive: true,
159
+ });
160
+ if (latest.dev !== verified.info.dev
161
+ || latest.ino !== verified.info.ino
162
+ || latest.size !== verified.info.size
163
+ || latest.mtimeMs !== verified.info.mtimeMs
164
+ || latest.ctimeMs !== verified.info.ctimeMs)
165
+ throw new Error(`source changed while snapshotting ${source}`);
166
+ await destination.sync();
167
+ await destination.chmod(0o600);
168
+ await destination.close();
169
+ destination = undefined;
170
+ renameSync(temporary, snapshot);
171
+ published = true;
172
+ await appendOutbox(outbox, snapshot);
173
+ return snapshot;
174
+ }
175
+ catch (error) {
176
+ if (published && snapshot) {
177
+ try {
178
+ unlinkSync(snapshot);
179
+ }
180
+ catch { /* best-effort cleanup */ }
181
+ }
182
+ if (temporary) {
183
+ try {
184
+ unlinkSync(temporary);
185
+ }
186
+ catch { /* best-effort cleanup */ }
187
+ }
188
+ throw error;
189
+ }
190
+ finally {
191
+ await destination?.close().catch(() => { });
192
+ await verified.handle.close().catch(() => { });
193
+ }
194
+ }
195
+ /** Serialize admissions for one outbox so parallel send_file calls cannot race past the aggregate budget. */
196
+ export async function queueOutboundSnapshot(sourcePath, outbox) {
197
+ const key = resolve(outbox);
198
+ const previous = outboundQueueLocks.get(key) ?? Promise.resolve();
199
+ let release;
200
+ const gate = new Promise((resolveGate) => { release = resolveGate; });
201
+ const current = previous.then(() => gate, () => gate);
202
+ outboundQueueLocks.set(key, current);
203
+ await previous.catch(() => { });
204
+ try {
205
+ return await queueOutboundSnapshotLocked(sourcePath, outbox);
206
+ }
207
+ finally {
208
+ release();
209
+ if (outboundQueueLocks.get(key) === current)
210
+ outboundQueueLocks.delete(key);
211
+ }
212
+ }
213
+ async function readOutbox(outbox) {
214
+ let verified;
215
+ try {
216
+ verified = await openVerifiedRegularFileNoFollow(resolve(outbox), {
217
+ action: "read gateway outbox",
218
+ rejectHardLinks: true,
219
+ protectSensitive: false,
220
+ });
221
+ if (verified.info.size > OUTBOX_MAX_BYTES)
222
+ throw new Error("gateway outbox is too large");
223
+ const bytes = Buffer.alloc(verified.info.size);
224
+ let offset = 0;
225
+ while (offset < bytes.length) {
226
+ const { bytesRead } = await verified.handle.read(bytes, offset, bytes.length - offset, offset);
227
+ if (!bytesRead)
228
+ break;
229
+ offset += bytesRead;
230
+ }
231
+ return bytes.subarray(0, offset).toString("utf8").split("\n").filter(Boolean);
232
+ }
233
+ catch {
234
+ return [];
235
+ }
236
+ finally {
237
+ await verified?.handle.close().catch(() => { });
238
+ try {
239
+ const current = lstatSync(resolve(outbox));
240
+ if (verified && !current.isSymbolicLink() && current.dev === verified.info.dev && current.ino === verified.info.ino) {
241
+ unlinkSync(resolve(outbox));
242
+ }
243
+ }
244
+ catch { /* missing or replaced outbox is left alone */ }
245
+ }
246
+ }
247
+ function attachmentName(snapshotPath) {
248
+ return basename(snapshotPath).replace(SNAPSHOT_SUFFIX, "") || "attachment";
249
+ }
250
+ /**
251
+ * Drain an outbox and materialize only verified snapshots owned by this queue. The returned bytes are the
252
+ * security boundary: adapters never reopen `snapshotPath`, so replacing it after this function returns cannot
253
+ * alter or disclose what is uploaded. Per-file, batch-byte, and file-count caps bound the in-memory payload.
254
+ */
255
+ export async function consumeOutboundSnapshots(outbox) {
256
+ const dir = outboundSnapshotDir(outbox);
257
+ const queued = await readOutbox(outbox); // always drain/unlink the outbox, even if its snapshot dir is absent
258
+ let dirReal;
259
+ try {
260
+ const info = lstatSync(dir);
261
+ if (!info.isDirectory() || info.isSymbolicLink() || !ownedByProcess(dir))
262
+ return [];
263
+ dirReal = realpathSync.native(dir);
264
+ }
265
+ catch {
266
+ return [];
267
+ }
268
+ const accepted = [];
269
+ let acceptedBytes = 0;
270
+ for (const raw of queued) {
271
+ if (accepted.length >= OUTBOUND_BATCH_MAX_FILES || acceptedBytes >= OUTBOUND_BATCH_MAX_BYTES)
272
+ break;
273
+ const candidate = resolve(raw);
274
+ if (dirname(candidate) !== dir || !OWNED_SNAPSHOT.test(basename(candidate)))
275
+ continue;
276
+ let verified;
277
+ try {
278
+ verified = await openVerifiedRegularFileNoFollow(candidate, {
279
+ action: "send gateway snapshot",
280
+ rejectHardLinks: true,
281
+ protectSensitive: false,
282
+ });
283
+ if (dirname(verified.canonicalPath) !== dirReal
284
+ || verified.info.size > OUTBOUND_FILE_MAX_BYTES
285
+ || verified.info.size > OUTBOUND_BATCH_MAX_BYTES - acceptedBytes
286
+ || !ownedByProcess(candidate))
287
+ continue;
288
+ const bytes = Buffer.allocUnsafe(verified.info.size);
289
+ let offset = 0;
290
+ while (offset < bytes.length) {
291
+ const { bytesRead } = await verified.handle.read(bytes, offset, bytes.length - offset, offset);
292
+ if (!bytesRead)
293
+ break;
294
+ offset += bytesRead;
295
+ }
296
+ if (offset !== bytes.length)
297
+ continue;
298
+ const latest = await verified.handle.stat();
299
+ verifyOpenedRegularFileSync(candidate, latest, {
300
+ action: "send gateway snapshot",
301
+ rejectHardLinks: true,
302
+ protectSensitive: false,
303
+ });
304
+ if (latest.dev !== verified.info.dev
305
+ || latest.ino !== verified.info.ino
306
+ || latest.size !== verified.info.size
307
+ || latest.mtimeMs !== verified.info.mtimeMs
308
+ || latest.ctimeMs !== verified.info.ctimeMs)
309
+ continue;
310
+ consumedSnapshotIdentities.set(candidate, { dev: latest.dev, ino: latest.ino });
311
+ accepted.push({ snapshotPath: candidate, safeName: attachmentName(candidate), bytes });
312
+ acceptedBytes += bytes.length;
313
+ }
314
+ catch {
315
+ /* malformed/replaced queue entry is never delivered */
316
+ }
317
+ finally {
318
+ await verified?.handle.close().catch(() => { });
319
+ }
320
+ }
321
+ return accepted;
322
+ }
323
+ /** Remove only files in this queue's private snapshot directory, never arbitrary outbox entries. */
324
+ export function cleanupOutboundSnapshots(outbox, preserve = []) {
325
+ const dir = outboundSnapshotDir(outbox);
326
+ const keep = new Set(preserve.map((path) => resolve(path)));
327
+ try {
328
+ const info = lstatSync(dir);
329
+ if (!info.isDirectory() || info.isSymbolicLink() || !ownedByProcess(dir))
330
+ return;
331
+ for (const name of readdirSync(dir)) {
332
+ if (!OWNED_SNAPSHOT.test(name))
333
+ continue;
334
+ const path = join(dir, name);
335
+ if (keep.has(resolve(path)))
336
+ continue;
337
+ try {
338
+ const entry = lstatSync(path);
339
+ if ((entry.isFile() || entry.isSymbolicLink()) && ownedByProcess(path))
340
+ unlinkSync(path);
341
+ }
342
+ catch {
343
+ /* raced cleanup is harmless */
344
+ }
345
+ }
346
+ rmdirSync(dir);
347
+ }
348
+ catch {
349
+ /* best-effort cleanup */
350
+ }
351
+ }
352
+ /** Delete one previously verified delivered snapshot and remove its now-empty queue directory. */
353
+ export function cleanupOutboundSnapshot(path) {
354
+ const candidate = resolve(path);
355
+ const expected = consumedSnapshotIdentities.get(candidate);
356
+ consumedSnapshotIdentities.delete(candidate);
357
+ const dir = dirname(candidate);
358
+ if (!dir.endsWith(".files") || !OWNED_SNAPSHOT.test(basename(candidate)))
359
+ return;
360
+ try {
361
+ const parent = lstatSync(dir);
362
+ if (!parent.isDirectory() || parent.isSymbolicLink() || !ownedByProcess(dir))
363
+ return;
364
+ const entry = lstatSync(candidate);
365
+ if (expected
366
+ && entry.isFile()
367
+ && !entry.isSymbolicLink()
368
+ && entry.nlink === 1
369
+ && entry.dev === expected.dev
370
+ && entry.ino === expected.ino
371
+ && ownedByProcess(candidate))
372
+ unlinkSync(candidate);
373
+ if (readdirSync(dir).length === 0)
374
+ rmdirSync(dir);
375
+ }
376
+ catch {
377
+ /* already removed or raced cleanup */
378
+ }
379
+ }