@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
@@ -5,10 +5,10 @@
5
5
  // (iLink only uses crypto for media upload/download, which v1 doesn't do).
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
- import { basename } from "node:path";
8
+ import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
10
9
  import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
11
10
  import { chunkText } from "./telegram.js";
11
+ import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
12
12
  const ILINK_BASE_URL = "https://ilinkai.weixin.qq.com";
13
13
  const CHANNEL_VERSION = "2.2.0";
14
14
  const ILINK_APP_ID = "bot";
@@ -44,7 +44,6 @@ const WEIXIN_CDN_ALLOWLIST = new Set([
44
44
  "mmbiz.qlogo.cn",
45
45
  ]);
46
46
  const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]);
47
- const INBOUND_MEDIA_MAX = 128 * 1024 * 1024; // 128 MiB cap, per the reference
48
47
  const num = (v) => (typeof v === "number" ? v : 0);
49
48
  const str = (v) => (v == null ? "" : String(v));
50
49
  // ── pure protocol helpers (unit-tested) ──────────────────────────────────────
@@ -198,8 +197,16 @@ export function loadWeixinCreds() {
198
197
  }
199
198
  }
200
199
  function saveWeixinCreds(c) {
201
- mkdirSync(weixinDir(), { recursive: true });
200
+ mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
201
+ try {
202
+ chmodSync(weixinDir(), 0o700);
203
+ }
204
+ catch { /* best effort */ }
202
205
  writeFileSync(credsFile(), JSON.stringify(c, null, 2), { mode: 0o600 });
206
+ try {
207
+ chmodSync(credsFile(), 0o600);
208
+ }
209
+ catch { /* best effort */ }
203
210
  }
204
211
  // get_updates_buf cursor — persisted so a restart resumes the message stream where it left off.
205
212
  function loadCursor(accountId) {
@@ -213,8 +220,11 @@ function loadCursor(accountId) {
213
220
  }
214
221
  function saveCursor(accountId, buf) {
215
222
  try {
216
- mkdirSync(weixinDir(), { recursive: true });
217
- writeFileSync(join(weixinDir(), `${accountId}.cursor`), buf);
223
+ mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
224
+ chmodSync(weixinDir(), 0o700);
225
+ const file = join(weixinDir(), `${accountId}.cursor`);
226
+ writeFileSync(file, buf, { mode: 0o600 });
227
+ chmodSync(file, 0o600);
218
228
  }
219
229
  catch {
220
230
  /* best-effort */
@@ -223,40 +233,74 @@ function saveCursor(accountId, buf) {
223
233
  // Per-peer context_token: every reply must echo the latest token iLink sent for that peer.
224
234
  class TokenStore {
225
235
  accountId;
226
- cache = {};
236
+ cache = new Map();
227
237
  constructor(accountId) {
228
238
  this.accountId = accountId;
229
239
  try {
230
- if (existsSync(this.file()))
231
- this.cache = JSON.parse(readFileSync(this.file(), "utf8"));
240
+ const exists = existsSync(this.file());
241
+ const parsed = exists ? JSON.parse(readFileSync(this.file(), "utf8")) : {};
242
+ if (exists)
243
+ chmodSync(this.file(), 0o600);
244
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
245
+ for (const [peer, token] of Object.entries(parsed).slice(-1000)) {
246
+ if (peer && typeof token === "string" && token)
247
+ this.cache.set(peer, token);
248
+ }
249
+ }
232
250
  }
233
251
  catch {
234
- this.cache = {};
252
+ this.cache.clear();
235
253
  }
236
254
  }
237
255
  file() {
238
256
  return join(weixinDir(), `${this.accountId}.context-tokens.json`);
239
257
  }
258
+ peerKey(peer) {
259
+ return String(peer ?? "").trim().slice(0, 256);
260
+ }
240
261
  persist() {
262
+ const temporary = `${this.file()}.${process.pid}.${randomUUID()}.tmp`;
241
263
  try {
242
- mkdirSync(weixinDir(), { recursive: true });
243
- writeFileSync(this.file(), JSON.stringify(this.cache));
264
+ mkdirSync(weixinDir(), { recursive: true, mode: 0o700 });
265
+ chmodSync(weixinDir(), 0o700);
266
+ writeFileSync(temporary, JSON.stringify(Object.fromEntries(this.cache)), { mode: 0o600, flag: "wx" });
267
+ renameSync(temporary, this.file());
268
+ chmodSync(this.file(), 0o600);
244
269
  }
245
270
  catch {
246
271
  /* best-effort */
247
272
  }
273
+ finally {
274
+ try {
275
+ rmSync(temporary, { force: true });
276
+ }
277
+ catch {
278
+ /* best-effort */
279
+ }
280
+ }
248
281
  }
249
282
  get(peer) {
250
- return this.cache[peer] || undefined;
283
+ const key = this.peerKey(peer);
284
+ const token = this.cache.get(key);
285
+ if (token) {
286
+ this.cache.delete(key);
287
+ this.cache.set(key, token);
288
+ }
289
+ return token;
251
290
  }
252
291
  set(peer, token) {
253
- if (token) {
254
- this.cache[peer] = token;
292
+ const key = this.peerKey(peer);
293
+ const boundedToken = String(token ?? "").slice(0, 4096);
294
+ if (key && boundedToken) {
295
+ this.cache.delete(key);
296
+ this.cache.set(key, boundedToken);
297
+ while (this.cache.size > 1000)
298
+ this.cache.delete(this.cache.keys().next().value);
255
299
  this.persist();
256
300
  }
257
301
  }
258
302
  del(peer) {
259
- delete this.cache[peer];
303
+ this.cache.delete(this.peerKey(peer));
260
304
  this.persist();
261
305
  }
262
306
  }
@@ -333,14 +377,25 @@ async function sendChunk(creds, tokenStore, peer, text) {
333
377
  const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
334
378
  const post = (ctx) => apiPost(creds.base_url, EP.sendMessage, buildSendBody(peer, text, ctx, clientId), creds.token, API_TIMEOUT_MS).catch((e) => ({ ret: 1, errmsg: String(e?.message ?? e) }));
335
379
  let resp = await post(tokenStore.get(peer));
380
+ // HARA_WX_DEBUG=1 → dump the raw send response (protocol exploration: does iLink return a message id we
381
+ // could use for a future recall/revoke?). Off by default; stderr only.
382
+ if (process.env.HARA_WX_DEBUG === "1")
383
+ console.error("weixin send resp:", JSON.stringify(resp).slice(0, 500));
336
384
  let [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
337
385
  if (isSessionExpired(ret, errcode, errmsg)) {
338
386
  tokenStore.del(peer); // stale → drop it and retry once tokenless (iLink accepts that, degraded)
339
387
  resp = await post(undefined);
340
388
  [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
341
389
  }
390
+ // Rate-limit (ret=-2, empty errmsg): iLink throttles cold/rapid proactive pushes. Back off and retry a few
391
+ // times instead of silently dropping the message — the reused clientId dedups so a retry can't double-send.
392
+ for (let attempt = 0; ret === RATE_LIMIT && errcode === 0 && attempt < 3; attempt++) {
393
+ await sleep(1500 * (attempt + 1));
394
+ resp = await post(tokenStore.get(peer));
395
+ [ret, errcode, errmsg] = [num(resp.ret), num(resp.errcode), str(resp.errmsg ?? resp.msg)];
396
+ }
342
397
  if (ret !== 0 || errcode !== 0)
343
- console.error(`weixin send: ret=${ret} errcode=${errcode} errmsg=${errmsg}`);
398
+ throw new Error(`weixin send failed: ret=${ret} errcode=${errcode} errmsg=${errmsg || "unknown error"}`);
344
399
  }
345
400
  function aes128EcbEncrypt(plaintext, key) {
346
401
  const c = createCipheriv("aes-128-ecb", key, null); // PKCS7 auto-padding (Node default) matches iLink
@@ -397,8 +452,6 @@ export function inboundMediaRefs(itemList) {
397
452
  return out;
398
453
  }
399
454
  const cdnDownloadUrl = (param) => `${WEIXIN_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(param)}`;
400
- const mediaDir = () => join(weixinDir(), "media");
401
- const sanitizeName = (name) => name.replace(/[^\w.\- ]+/g, "_").slice(-80) || "file.bin";
402
455
  function assertCdnHost(url) {
403
456
  let u;
404
457
  try {
@@ -413,7 +466,10 @@ function assertCdnHost(url) {
413
466
  throw new Error(`host ${u.hostname} not in WeChat CDN allowlist (SSRF guard)`);
414
467
  }
415
468
  /** Download (+ AES-decrypt if keyed) an inbound media item to a local file. Returns its path + mime, or null. */
416
- export async function downloadInboundMedia(ref) {
469
+ export async function downloadInboundMedia(ref, options = {
470
+ maxBytes: INBOUND_MEDIA_MAX_BYTES,
471
+ signal: AbortSignal.timeout(INBOUND_MEDIA_TIMEOUT_MS),
472
+ }) {
417
473
  try {
418
474
  let url;
419
475
  if (ref.encryptQueryParam)
@@ -424,22 +480,20 @@ export async function downloadInboundMedia(ref) {
424
480
  }
425
481
  else
426
482
  return null;
427
- const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
483
+ const res = await fetch(url, { signal: options.signal });
428
484
  if (!res.ok)
429
485
  throw new Error(`media download HTTP ${res.status}`);
430
- const ab = await res.arrayBuffer();
431
- if (ab.byteLength > INBOUND_MEDIA_MAX)
432
- throw new Error("media exceeds size cap");
433
- let buf = Buffer.from(ab);
486
+ let buf = await readResponseBytesLimited(res, options.maxBytes, options.signal);
434
487
  if (ref.aesKeyB64)
435
488
  buf = aes128EcbDecrypt(buf, parseAesKey(ref.aesKeyB64));
436
489
  if (!buf.length)
437
490
  return null;
438
- mkdirSync(mediaDir(), { recursive: true });
439
- const tag = randomUUID().slice(0, 8);
440
- const name = ref.kind === "image" ? `img_${tag}.jpg` : ref.kind === "voice" ? `audio_${tag}.silk` : `${tag}_${sanitizeName(ref.fileName || "document.bin")}`;
441
- const path = join(mediaDir(), name);
442
- writeFileSync(path, buf);
491
+ const filenameHint = ref.kind === "image" ? "image.jpg" : ref.kind === "voice" ? "audio.silk" : ref.fileName || "document.bin";
492
+ const path = await savePrivateMediaBytes(buf, {
493
+ platform: "weixin",
494
+ filenameHint,
495
+ ...options,
496
+ });
443
497
  return { path, mime: ref.kind === "image" ? "image/jpeg" : ref.kind === "voice" ? "audio/silk" : "application/octet-stream" };
444
498
  }
445
499
  catch (e) {
@@ -457,14 +511,14 @@ async function cdnUpload(uploadUrl, ciphertext) {
457
511
  throw new Error("CDN upload missing x-encrypted-param header");
458
512
  return ep;
459
513
  }
460
- /** Send a local file to a peer. Images go inline (image_item); everything else (audio/zip/pdf/doc/…) goes as a
514
+ /** Send already-verified bytes to a peer. Images go inline (image_item); everything else (audio/zip/pdf/doc/…) goes as a
461
515
  * file attachment (file_item) carrying the filename. Ported byte-exact from iLink's media protocol:
462
516
  * getuploadurl → AES-128-ECB encrypt → CDN POST → sendmessage(item). */
463
- export async function sendMediaFile(creds, tokenStore, peer, filePath) {
517
+ export async function sendMediaFile(creds, tokenStore, peer, file) {
464
518
  try {
465
- const plaintext = readFileSync(filePath);
519
+ const plaintext = file.bytes;
466
520
  const rawsize = plaintext.length;
467
- const isImage = IMAGE_EXTS.has((filePath.split(".").pop() || "").toLowerCase());
521
+ const isImage = IMAGE_EXTS.has((file.safeName.split(".").pop() || "").toLowerCase());
468
522
  const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
469
523
  const filekey = randomBytes(16).toString("hex");
470
524
  const key = randomBytes(16);
@@ -480,7 +534,7 @@ export async function sendMediaFile(creds, tokenStore, peer, filePath) {
480
534
  const encryptedParam = await cdnUpload(uploadUrl, ciphertext);
481
535
  const item = isImage
482
536
  ? imageInlineItem(encryptedParam, apiAesKey(keyHex), ciphertext.length)
483
- : audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, basename(filePath));
537
+ : audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, file.safeName);
484
538
  const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
485
539
  const send = (ctx) => {
486
540
  const msg = { from_user_id: "", to_user_id: peer, client_id: clientId, message_type: MSG_TYPE_BOT, message_state: MSG_STATE_FINISH, item_list: [item] };
@@ -510,34 +564,67 @@ export function weixinAdapter(creds) {
510
564
  const tokenStore = new TokenStore(creds.account_id);
511
565
  // Reuse parseWeixinMessage for text/voice-transcription, then download any image/file/voice media and append
512
566
  // a `[kind: localpath]` reference so hara can read the file. Handles media-only messages (no text) too.
513
- const buildInbound = async (msg) => {
567
+ const buildInbound = async (msg, signal, shouldDownload) => {
514
568
  const parsed = parseWeixinMessage(msg, creds.account_id);
515
569
  const from = str(msg?.from_user_id).trim();
516
570
  if (!from || from === creds.account_id)
517
571
  return null;
518
- if (guessChatType(msg, creds.account_id).kind !== "dm")
572
+ const chat = guessChatType(msg, creds.account_id);
573
+ const refs = inboundMediaRefs(msg?.item_list);
574
+ let text = parsed?.inbound.text ?? extractText(msg?.item_list);
575
+ const base = {
576
+ chatId: chat.id,
577
+ userId: from,
578
+ userName: from,
579
+ text: text || (refs.some((ref) => ref.kind === "image") ? "[图片]" : refs.length ? "[附件]" : ""),
580
+ chatType: chat.kind === "dm" ? "p2p" : "group",
581
+ };
582
+ if (!base.text)
519
583
  return null;
520
- let text = parsed?.inbound.text ?? "";
584
+ const downloadAllowed = base.chatType === "p2p" && shouldDownload?.(base) === true;
585
+ // Persist context only for an authenticated DM, before any download. Rejected senders cannot grow state.
586
+ if (downloadAllowed)
587
+ tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
521
588
  const images = [];
522
- for (const ref of inboundMediaRefs(msg?.item_list)) {
523
- const dl = await downloadInboundMedia(ref);
524
- if (!dl)
525
- continue;
526
- if (ref.kind === "image") {
527
- // images go through as real attachments (seen/described downstream); leave only a light marker in text
528
- images.push(dl.path);
529
- text += `${text ? "\n" : ""}[图片]`;
530
- }
531
- else {
532
- const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
533
- text += `${text ? "\n" : ""}[${label}: ${dl.path}]`;
589
+ const transientFiles = [];
590
+ let handedOff = false;
591
+ try {
592
+ if (downloadAllowed) {
593
+ const budget = new InboundMediaBudget("weixin", signal);
594
+ for (const ref of refs) {
595
+ let downloaded = null;
596
+ const path = await budget.download(async (options) => {
597
+ downloaded = await downloadInboundMedia(ref, options);
598
+ return downloaded?.path ?? null;
599
+ });
600
+ if (!path || !downloaded)
601
+ continue;
602
+ transientFiles.push(path);
603
+ if (ref.kind === "image") {
604
+ // images go through as real attachments (seen/described downstream); leave only a light marker in text
605
+ images.push(path);
606
+ if (text !== "[图片]")
607
+ text += `${text ? "\n" : ""}[图片]`;
608
+ }
609
+ else {
610
+ const label = ref.kind === "voice" ? "语音" : `文件 ${ref.fileName ?? ""}`.trim();
611
+ text = text === "[附件]" ? `[${label}: ${path}]` : `${text}${text ? "\n" : ""}[${label}: ${path}]`;
612
+ }
613
+ }
534
614
  }
615
+ const inbound = {
616
+ ...base,
617
+ text: text.trim() || base.text,
618
+ images: images.length ? images : undefined,
619
+ transientFiles: transientFiles.length ? transientFiles : undefined,
620
+ };
621
+ handedOff = true;
622
+ return inbound;
623
+ }
624
+ finally {
625
+ if (!handedOff && transientFiles.length)
626
+ await cleanupTransientMedia("weixin", transientFiles);
535
627
  }
536
- text = text.trim();
537
- if (!text && !images.length)
538
- return null;
539
- tokenStore.set(from, parsed?.contextToken || str(msg?.context_token).trim());
540
- return { chatId: from, userId: from, userName: from, text: text || "[图片]", images: images.length ? images : undefined };
541
628
  };
542
629
  return {
543
630
  name: "weixin",
@@ -546,10 +633,11 @@ export function weixinAdapter(creds) {
546
633
  for (const part of chunkText(text || "(empty)"))
547
634
  await sendChunk(creds, tokenStore, peer, part);
548
635
  },
549
- async sendFile(chatId, filePath) {
550
- await sendMediaFile(creds, tokenStore, String(chatId), filePath);
636
+ async sendFile(chatId, file) {
637
+ if (!(await sendMediaFile(creds, tokenStore, String(chatId), file)))
638
+ throw new Error(`weixin file delivery failed: ${file.safeName}`);
551
639
  },
552
- async start(onMessage, signal) {
640
+ async start(onMessage, signal, shouldDownload) {
553
641
  let buf = loadCursor(creds.account_id);
554
642
  let pollMs = LONG_POLL_TIMEOUT_MS;
555
643
  while (!signal.aborted) {
@@ -578,7 +666,7 @@ export function weixinAdapter(creds) {
578
666
  buf = str(resp.get_updates_buf) || buf;
579
667
  saveCursor(creds.account_id, buf);
580
668
  for (const msg of Array.isArray(resp.msgs) ? resp.msgs : []) {
581
- const inbound = await buildInbound(msg);
669
+ const inbound = await buildInbound(msg, signal, shouldDownload);
582
670
  if (inbound)
583
671
  await onMessage(inbound).catch(() => { });
584
672
  }
package/dist/hooks.js CHANGED
@@ -1,24 +1,30 @@
1
1
  // Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
2
- // PreToolUse runs BEFORE a tool: a non-zero exit BLOCKS the call (its output becomes the denial message).
2
+ // PreToolUse runs BEFORE a tool: non-zero, timeout, signal, or launch failure BLOCKS the call.
3
3
  // PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
4
4
  // by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
5
5
  import { spawnSync } from "node:child_process";
6
+ import { resolve } from "node:path";
6
7
  import { loadConfig } from "./config.js";
7
8
  import { pluginHooks } from "./plugins/plugins.js";
8
- let cache = null;
9
+ import { redactToolSubprocessOutput, toolSubprocessEnv } from "./security/subprocess-env.js";
10
+ import { shellCommand } from "./sandbox.js";
11
+ const cache = new Map();
9
12
  export function resetHooksCache() {
10
- cache = null;
13
+ cache.clear();
11
14
  }
12
- function merged() {
13
- if (cache)
14
- return cache;
15
- const cfg = loadConfig().hooks ?? {};
15
+ function merged(cwd) {
16
+ const key = resolve(cwd);
17
+ const cached = cache.get(key);
18
+ if (cached)
19
+ return cached;
20
+ const cfg = loadConfig({ cwd: key }).hooks ?? {};
16
21
  const plg = pluginHooks();
17
- cache = {
22
+ const value = {
18
23
  PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
19
24
  PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
20
25
  };
21
- return cache;
26
+ cache.set(key, value);
27
+ return value;
22
28
  }
23
29
  const matches = (m, name) => {
24
30
  if (!m || m === "*")
@@ -31,33 +37,45 @@ const matches = (m, name) => {
31
37
  }
32
38
  };
33
39
  /** True if any hook is configured (lets the loop skip the work entirely in the common case). */
34
- export function hasHooks() {
35
- const h = merged();
40
+ export function hasHooks(cwd = process.cwd()) {
41
+ const h = merged(cwd);
36
42
  return !!(h.PreToolUse?.length || h.PostToolUse?.length);
37
43
  }
38
- /** Run hooks for an event matching `toolName`. PreToolUse: a non-zero exit BLOCKS (returns the message);
39
- * PostToolUse: observe-only, never blocks. Sync (hooks are short, opt-in); 30s timeout each. */
40
- export function runHooks(event, toolName, payload, cwd) {
41
- for (const h of merged()[event] ?? []) {
44
+ /** Run hooks for an event matching `toolName`. Any abnormal PreToolUse termination fails closed;
45
+ * PostToolUse remains observe-only. Sync (hooks are short, opt-in); 30s timeout each. */
46
+ export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
47
+ for (const h of merged(cwd)[event] ?? []) {
42
48
  if (!matches(h.matcher, toolName))
43
49
  continue;
44
50
  let r;
45
51
  try {
46
- r = spawnSync(h.command, {
47
- shell: true,
52
+ // Hooks are user/plugin-configured external code. Route them through the same command preflight and
53
+ // macOS protected-read mask as Bash, even though hooks remain observe-only after a tool has run.
54
+ const shell = shellCommand(h.command, cwd, "off");
55
+ r = spawnSync(shell.cmd, shell.args, {
48
56
  cwd,
49
57
  input: JSON.stringify({ tool: toolName, payload }),
50
58
  encoding: "utf8",
51
- timeout: 30_000,
52
- env: { ...process.env, HARA_TOOL_NAME: toolName },
59
+ timeout: timeoutMs,
60
+ env: toolSubprocessEnv(process.env, { HARA_TOOL_NAME: toolName }),
53
61
  });
54
62
  }
55
- catch {
63
+ catch (error) {
64
+ if (event === "PreToolUse") {
65
+ const detail = redactToolSubprocessOutput(error instanceof Error ? error.message : String(error));
66
+ return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
67
+ }
68
+ // PostToolUse is best-effort: a policy-blocked or broken observer must never rewrite the result of a
69
+ // tool that already completed. In particular, do not retry it through an unsandboxed fallback shell.
56
70
  continue;
57
71
  }
58
- if (event === "PreToolUse" && r.status !== 0 && r.status !== null) {
59
- const msg = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
60
- return { block: true, message: `⛔ blocked by a PreToolUse hook${msg ? `: ${msg}` : ` (exit ${r.status})`}` };
72
+ // A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
73
+ // writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
74
+ // that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
75
+ if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
76
+ const output = redactToolSubprocessOutput((String(r.stdout ?? "") + String(r.stderr ?? "")).trim());
77
+ const failure = redactToolSubprocessOutput(r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`));
78
+ return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
61
79
  }
62
80
  }
63
81
  return { block: false, message: "" };