@nanhara/hara 0.122.0 → 0.122.2

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 (60) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
@@ -16,6 +16,8 @@ import { listSessions, loadSession } from "../session/store.js";
16
16
  import { homedir, tmpdir } from "node:os";
17
17
  import { join, resolve } from "node:path";
18
18
  import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
19
+ import { redactToolSubprocessOutput, terminateSubprocessTree } from "../security/subprocess-env.js";
20
+ import { cleanupOutboundSnapshot, cleanupOutboundSnapshots, consumeOutboundSnapshots, queueOutboundSnapshot, } from "./outbound-files.js";
19
21
  /** Parse a leading slash-command from a chat message (pure). null if it isn't one. */
20
22
  export function parseCommand(text) {
21
23
  const m = /^\/([a-z]+)\b\s*([\s\S]*)$/i.exec(text.trim());
@@ -45,13 +47,35 @@ export function canonicalGatewayPlatform(platform) {
45
47
  /** Strip hara's CLI chrome from captured `-p` output so a chat reply is just the answer: MCP status lines
46
48
  * (`mcp: …`) and the token-usage footer (`… · ↑N ↓N tok`). Colors are off when piped, so no ANSI to strip. */
47
49
  export function cleanReply(raw) {
48
- return raw
50
+ return redactToolSubprocessOutput(raw)
49
51
  .split("\n")
50
52
  .filter((ln) => !/^\s*mcp: /.test(ln) && !/·\s*↑\d+\s*↓\d+\s*tok\s*$/.test(ln))
51
53
  .join("\n")
52
54
  .trim();
53
55
  }
54
56
  let outboxSeq = 0;
57
+ /** Snapshot and materialize a direct gateway file before an adapter sees it. This also covers TTS output, so
58
+ * every outbound attachment crosses the same verified-byte boundary rather than handing adapters a pathname. */
59
+ async function deliverVerifiedLocalFile(adapter, chatId, sourcePath) {
60
+ const sendFile = adapter.sendFile;
61
+ if (!sendFile)
62
+ throw new Error("this platform can't send files yet");
63
+ const outbox = join(tmpdir(), `hara-direct-send-${process.pid}-${Date.now()}-${outboxSeq++}.txt`);
64
+ let payload;
65
+ try {
66
+ await queueOutboundSnapshot(sourcePath, outbox);
67
+ [payload] = await consumeOutboundSnapshots(outbox);
68
+ cleanupOutboundSnapshots(outbox, payload ? [payload.snapshotPath] : []);
69
+ if (!payload)
70
+ throw new Error("the private file snapshot could not be verified");
71
+ await sendFile(chatId, payload);
72
+ }
73
+ finally {
74
+ if (payload)
75
+ cleanupOutboundSnapshot(payload.snapshotPath);
76
+ cleanupOutboundSnapshots(outbox);
77
+ }
78
+ }
55
79
  export class GatewayQueueFullError extends Error {
56
80
  key;
57
81
  limit;
@@ -234,20 +258,6 @@ function durationLabel(ms) {
234
258
  return `${ms / 60_000}m`;
235
259
  return `${Math.round(ms / 1_000)}s`;
236
260
  }
237
- function signalProcess(child, signal) {
238
- try {
239
- // A detached POSIX child leads its own process group. Kill that group so bash/tool grandchildren cannot
240
- // survive a timed-out agent. Windows has no negative-pid process-group signaling; use ChildProcess.kill.
241
- if (process.platform !== "win32" && child.pid) {
242
- process.kill(-child.pid, signal);
243
- return true;
244
- }
245
- return child.kill(signal);
246
- }
247
- catch {
248
- return false;
249
- }
250
- }
251
261
  /** Run hara headlessly on a chat's session. Returns its cleaned text reply plus any files the agent queued
252
262
  * via send_file. The gateway env (HARA_GATEWAY + a per-message outbox file) is what makes send_file and the
253
263
  * in-chat system context active in the subprocess; the daemon delivers the queued files after it exits. */
@@ -287,10 +297,12 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
287
297
  let exitCode = null;
288
298
  let exitSignal = null;
289
299
  let stopReason;
290
- let killTimer;
291
300
  let drainTimer;
292
- let forceSettleTimer;
301
+ let forceIssued = false;
302
+ let cancelTermination;
293
303
  const cap = (d) => {
304
+ if (settled || stopReason)
305
+ return;
294
306
  out = (out + d.toString().slice(-12_000)).slice(-12_000);
295
307
  };
296
308
  child.stdout?.on("data", cap);
@@ -300,17 +312,11 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
300
312
  child.unref();
301
313
  child.stdout?.unref?.();
302
314
  child.stderr?.unref?.();
303
- const readOutbox = () => {
304
- let files = [];
305
- try {
306
- if (existsSync(outbox)) {
307
- files = readFileSync(outbox, "utf8").split("\n").map((s) => s.trim()).filter(Boolean);
308
- rmSync(outbox, { force: true });
309
- }
310
- }
311
- catch {
312
- /* outbox is best-effort; a missing/unreadable file just means nothing to send */
313
- }
315
+ const readOutbox = async () => {
316
+ const files = await consumeOutboundSnapshots(outbox);
317
+ // Remove abandoned partials and invalid queue entries, but retain accepted snapshots until the adapter
318
+ // has finished uploading them in the parent gateway process.
319
+ cleanupOutboundSnapshots(outbox, files.map((file) => file.snapshotPath));
314
320
  return files;
315
321
  };
316
322
  const finish = (reply) => {
@@ -318,18 +324,24 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
318
324
  return;
319
325
  settled = true;
320
326
  clearTimeout(timeoutTimer);
321
- if (killTimer)
322
- clearTimeout(killTimer);
323
327
  if (drainTimer)
324
328
  clearTimeout(drainTimer);
325
- if (forceSettleTimer)
326
- clearTimeout(forceSettleTimer);
329
+ // The helper deliberately keeps its force timer alive by default. When a direct child closes after
330
+ // TERM, that timer must still SIGKILL the owned group before this stopped run can be considered gone.
331
+ cancelTermination?.();
327
332
  options.signal?.removeEventListener("abort", abortRun);
328
333
  child.stdout?.destroy();
329
334
  child.stderr?.destroy();
330
- res({ reply, files: readOutbox() });
335
+ void readOutbox().then((files) => res({ reply, files }), () => {
336
+ cleanupOutboundSnapshots(outbox);
337
+ res({ reply, files: [] });
338
+ });
331
339
  };
332
340
  const finishFromExit = () => {
341
+ // `exit`/`close` describes only the direct child. During a timeout/shutdown, wait until the forced
342
+ // process-group signal has been issued so a quiet TERM-resistant descendant cannot escape cleanup.
343
+ if (stopReason && !forceIssued)
344
+ return;
333
345
  if (stopReason === "timeout") {
334
346
  finish(runFailure(`hara timed out after ${durationLabel(timeoutMs)}; the run was stopped.`, out));
335
347
  }
@@ -348,31 +360,47 @@ export function runHara(text, sessionId, cwd, platform, images, role, options =
348
360
  }
349
361
  };
350
362
  const terminate = (reason) => {
351
- if (settled || exited)
363
+ if (settled || cancelTermination)
352
364
  return;
353
365
  stopReason ??= reason;
354
- signalProcess(child, "SIGTERM");
355
- killTimer = setTimeout(() => {
356
- if (settled || exited)
357
- return;
358
- signalProcess(child, "SIGKILL");
359
- // `exit` normally arrives immediately. This fallback releases the daemon even if a platform/runtime
360
- // never reports it; the detached process and destroyed pipes cannot hold the parent open afterwards.
361
- forceSettleTimer = setTimeout(finishFromExit, 250);
362
- }, killGraceMs);
366
+ cancelTermination = terminateSubprocessTree(child, {
367
+ processGroup: process.platform !== "win32",
368
+ graceMs: killGraceMs,
369
+ fallbackMs: 250,
370
+ onForce: () => {
371
+ forceIssued = true;
372
+ if (exited)
373
+ finishFromExit();
374
+ },
375
+ // A daemon may escape the group or a runtime may fail to emit exit/close. Destroying our pipe ends in
376
+ // `finish` keeps gateway shutdown and per-run timeout bounded even in that case.
377
+ onFallback: finishFromExit,
378
+ });
363
379
  };
364
380
  const abortRun = () => terminate("shutdown");
365
381
  const timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
366
382
  if (options.signal)
367
383
  options.signal.addEventListener("abort", abortRun, { once: true });
368
- child.once("error", (error) => finish(runFailure(`couldn't start hara: ${error.message}`, out)));
384
+ // Close the narrow race where the signal aborts after the entry check but before the listener is attached.
385
+ if (options.signal?.aborted)
386
+ abortRun();
387
+ child.once("error", (error) => {
388
+ if (!stopReason)
389
+ return finish(runFailure(`couldn't start hara: ${error.message}`, out));
390
+ exited = true;
391
+ if (forceIssued)
392
+ finishFromExit();
393
+ });
369
394
  child.once("exit", (code, signal) => {
370
395
  exited = true;
371
396
  exitCode = code;
372
397
  exitSignal = signal;
373
398
  // Usually `close` follows after the final pipe data. A grandchild can retain stdout/stderr forever, so
374
399
  // cap that drain window and then destroy the pipes ourselves.
375
- drainTimer = setTimeout(finishFromExit, 100);
400
+ if (stopReason)
401
+ finishFromExit();
402
+ else
403
+ drainTimer = setTimeout(finishFromExit, 100);
376
404
  });
377
405
  child.once("close", (code, signal) => {
378
406
  exited = true;
@@ -749,17 +777,26 @@ export async function runGateway(opts) {
749
777
  const audio = await synthesize(cmd.arg);
750
778
  if (!audio)
751
779
  return adapter.send(m.chatId, "✗ TTS failed (check HARA_TTS_* config).");
752
- await adapter.sendFile(m.chatId, audio);
753
- rmSync(audio, { force: true });
780
+ try {
781
+ await deliverVerifiedLocalFile(adapter, m.chatId, audio);
782
+ }
783
+ finally {
784
+ rmSync(audio, { force: true });
785
+ }
754
786
  return;
755
787
  }
756
788
  if (cmd.cmd === "send") {
757
789
  if (!adapter.sendFile)
758
790
  return adapter.send(m.chatId, "this platform can't send files yet.");
759
791
  const p = cmd.arg ? resolve(ctx.cwd, cmd.arg.replace(/^~(?=\/|$)/, homedir())) : "";
760
- if (!p || !existsSync(p) || !statSync(p).isFile())
761
- return adapter.send(m.chatId, `✗ not a file: ${p || "(none)"}\nusage: /send <path> (abs, ~, or relative to current dir)`);
762
- await adapter.sendFile(m.chatId, p);
792
+ if (!p)
793
+ return adapter.send(m.chatId, "usage: /send <path> (abs, ~, or relative to current dir)");
794
+ try {
795
+ await deliverVerifiedLocalFile(adapter, m.chatId, p);
796
+ }
797
+ catch (error) {
798
+ return adapter.send(m.chatId, `✗ couldn't send ${p}: ${error instanceof Error ? error.message : String(error)}`);
799
+ }
763
800
  return;
764
801
  }
765
802
  // any other slash word → treat as a normal task
@@ -791,33 +828,44 @@ export async function runGateway(opts) {
791
828
  if (workingId && adapter.recall)
792
829
  await adapter.recall(m.chatId, workingId).catch(() => { });
793
830
  }
794
- if (ac.signal.aborted)
795
- return;
796
831
  const { reply, files } = result;
797
- const hasReply = Boolean(reply);
798
- if (hasReply)
799
- await adapter.send(m.chatId, plainChat(reply)); // chat bubbles are plain text — flatten markdown
800
- else if (files.length)
801
- await adapter.send(m.chatId, "📎");
802
- // Deliver any files the agent queued via send_file (images inline, others as attachments).
803
- for (const f of files) {
804
- if (!adapter.sendFile) {
805
- await adapter.send(m.chatId, "(this platform can't send files yet)");
806
- break;
807
- }
808
- try {
809
- await adapter.sendFile(m.chatId, f);
832
+ try {
833
+ if (ac.signal.aborted)
834
+ return;
835
+ const hasReply = Boolean(reply);
836
+ if (hasReply)
837
+ await adapter.send(m.chatId, plainChat(reply)); // chat bubbles are plain text flatten markdown
838
+ else if (files.length)
839
+ await adapter.send(m.chatId, "📎");
840
+ // Deliver only immutable private snapshots produced by send_file.
841
+ for (const f of files) {
842
+ if (!adapter.sendFile) {
843
+ await adapter.send(m.chatId, "(this platform can't send files yet)");
844
+ break;
845
+ }
846
+ try {
847
+ await adapter.sendFile(m.chatId, f);
848
+ }
849
+ catch (e) {
850
+ await adapter.send(m.chatId, `✗ couldn't send attachment: ${e.message}`);
851
+ }
810
852
  }
811
- catch (e) {
812
- await adapter.send(m.chatId, `✗ couldn't send ${f}: ${e.message}`);
853
+ if (hasReply && ctx.voice && adapter.sendFile) {
854
+ const audio = await synthesize(reply);
855
+ if (audio) {
856
+ try {
857
+ await deliverVerifiedLocalFile(adapter, m.chatId, audio);
858
+ }
859
+ finally {
860
+ rmSync(audio, { force: true });
861
+ }
862
+ }
813
863
  }
814
864
  }
815
- if (hasReply && ctx.voice && adapter.sendFile) {
816
- const audio = await synthesize(reply);
817
- if (audio) {
818
- await adapter.sendFile(m.chatId, audio);
819
- rmSync(audio, { force: true });
820
- }
865
+ finally {
866
+ // Text-send failures, shutdown races, unsupported adapters, and upload failures all remove snapshots.
867
+ for (const f of files)
868
+ cleanupOutboundSnapshot(f.snapshotPath);
821
869
  }
822
870
  }
823
871
  finally {
@@ -138,7 +138,7 @@ export function signalAdapter(rpcUrl, selfNumber) {
138
138
  return null;
139
139
  }
140
140
  }
141
- /** Recipient/group routing shared by send + sendFile (mirrors hermes). */
141
+ /** Recipient/group routing for outbound text sends (mirrors hermes). */
142
142
  const target = (chatId) => {
143
143
  const id = String(chatId);
144
144
  return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
@@ -169,11 +169,9 @@ export function signalAdapter(rpcUrl, selfNumber) {
169
169
  await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
170
170
  }
171
171
  },
172
- async sendFile(chatId, filePath) {
173
- // Signal has no separate photo/document endpoints everything is an `attachments` path on `send`.
174
- // signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
175
- await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
176
- },
172
+ // signal-cli's JSON-RPC attachment API accepts only a server-side pathname. Exposing sendFile here would
173
+ // reopen the verified snapshot after the security boundary, so outbound files stay disabled for Signal
174
+ // until its daemon offers a byte-oriented upload API.
177
175
  async start(onMessage, signal, shouldDownload) {
178
176
  console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
179
177
  while (!signal.aborted) {
@@ -4,8 +4,6 @@
4
4
  // API (bot token, xoxb-). Same ChatAdapter shape as Discord/Telegram, so all cross-platform gateway plumbing
5
5
  // (send_file, in-chat system context, stuck-guard, image attach/describe) works unchanged. Two tokens are
6
6
  // required because Socket Mode (xapp-) and the Web API (xoxb-) are separate auth scopes.
7
- import { readFileSync } from "node:fs";
8
- import { basename } from "node:path";
9
7
  import { InboundMediaBudget, savePrivateResponse } from "./media.js";
10
8
  import { chunkText } from "./telegram.js";
11
9
  const WEB = "https://slack.com/api";
@@ -93,16 +91,16 @@ export function slackAdapter(appToken, botToken) {
93
91
  await slackApi("chat.postMessage", botToken, { channel: chatId, text: part });
94
92
  }
95
93
  },
96
- async sendFile(chatId, filePath) {
94
+ async sendFile(chatId, file) {
97
95
  // 3-step external-upload flow (getUploadURLExternal → PUT bytes → completeUploadExternal): the current,
98
96
  // non-deprecated path that works with just files:write (the old files.upload can 404/missing_scope).
99
97
  try {
100
- const bytes = readFileSync(filePath);
101
- const name = basename(filePath);
98
+ const bytes = file.bytes;
99
+ const name = file.safeName;
102
100
  const url = await slackApi("files.getUploadURLExternal", botToken, { filename: name, length: bytes.length });
103
101
  if (!url?.ok || !url.upload_url || !url.file_id)
104
102
  return;
105
- const up = await fetch(url.upload_url, { method: "POST", body: new Blob([bytes]) }); // presigned URL: no auth header
103
+ const up = await fetch(url.upload_url, { method: "POST", body: new Blob([new Uint8Array(bytes)]) }); // presigned URL: no auth header
106
104
  if (!up.ok)
107
105
  return;
108
106
  await slackApi("files.completeUploadExternal", botToken, { files: [{ id: url.file_id, title: name }], channel_id: String(chatId) });
@@ -1,8 +1,6 @@
1
1
  // Telegram adapter for `hara gateway` — long-poll getUpdates + sendMessage over the Bot API (built-in
2
2
  // fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
3
3
  // / Feishu plug into next.
4
- import { readFileSync } from "node:fs";
5
- import { basename } from "node:path";
6
4
  import { InboundMediaBudget, savePrivateResponse } from "./media.js";
7
5
  const API = "https://api.telegram.org";
8
6
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -82,13 +80,13 @@ export function telegramAdapter(token) {
82
80
  });
83
81
  }
84
82
  },
85
- async sendFile(chatId, filePath) {
83
+ async sendFile(chatId, file) {
86
84
  // images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
87
- const name = basename(filePath);
85
+ const name = file.safeName;
88
86
  const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
89
87
  const form = new FormData();
90
88
  form.append("chat_id", String(chatId));
91
- form.append(isImg ? "photo" : "document", new Blob([readFileSync(filePath)]), name);
89
+ form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
92
90
  await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form });
93
91
  },
94
92
  async start(onMessage, signal, shouldDownload) {
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
8
8
  // ONLY injects into panes that opted in by registering — never an arbitrary pane.
9
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { chmodSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { join } from "node:path";
12
12
  import { homedir } from "node:os";
@@ -26,8 +26,16 @@ function load() {
26
26
  }
27
27
  }
28
28
  function save(routes) {
29
- mkdirSync(dir(), { recursive: true });
30
- writeFileSync(storePath(), JSON.stringify({ routes }, null, 2));
29
+ mkdirSync(dir(), { recursive: true, mode: 0o700 });
30
+ try {
31
+ chmodSync(dir(), 0o700);
32
+ }
33
+ catch { /* best effort */ }
34
+ writeFileSync(storePath(), JSON.stringify({ routes }, null, 2), { mode: 0o600 });
35
+ try {
36
+ chmodSync(storePath(), 0o600);
37
+ }
38
+ catch { /* best effort */ }
31
39
  }
32
40
  /** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
33
41
  * reply; "bind" = persistent (every reply injects until unbound). */
@@ -10,7 +10,6 @@
10
10
  // chunked protocol → AES-256-CBC decrypt inbound media that carries an `aeskey`. Every request frame carries a
11
11
  // `headers.req_id` and the server echoes it back so request/response are correlated. v1 limitations are documented
12
12
  // at the bottom of this file (the public spec is thin — fields are best-effort and tolerant of shape drift).
13
- import { readFileSync } from "node:fs";
14
13
  import { basename, extname } from "node:path";
15
14
  import { randomUUID, createDecipheriv, createHash } from "node:crypto";
16
15
  import { chunkText } from "./telegram.js";
@@ -204,17 +203,17 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
204
203
  // per-connection request/response correlation lives inside connectOnce; send/sendFile use the live socket handle
205
204
  // it publishes through `conn`.
206
205
  const conn = { send: null };
207
- // Upload a local file to WeCom via the 3-step chunked protocol → its media_id. Throws on any step error.
208
- async function uploadMedia(filePath, mediaType) {
206
+ // Upload already-verified bytes to WeCom via the 3-step chunked protocol → its media_id.
207
+ async function uploadMedia(file, mediaType) {
209
208
  if (!conn.send)
210
209
  throw new Error("WeCom socket not connected");
211
- const data = readFileSync(filePath);
210
+ const data = file.bytes;
212
211
  if (data.length > ABSOLUTE_MAX_BYTES)
213
212
  throw new Error(`file exceeds WeCom 20MB limit: ${data.length} bytes`);
214
213
  const totalChunks = Math.max(1, Math.ceil(data.length / UPLOAD_CHUNK_SIZE));
215
214
  const init = await conn.send(CMD_UPLOAD_INIT, {
216
215
  type: mediaType,
217
- filename: basename(filePath),
216
+ filename: file.safeName,
218
217
  total_size: data.length,
219
218
  total_chunks: totalChunks,
220
219
  md5: createHash("md5").update(data).digest("hex"),
@@ -245,12 +244,12 @@ export function wecomAdapter(botId, secret, wsUrl = DEFAULT_WS_URL) {
245
244
  .catch(() => { });
246
245
  }
247
246
  },
248
- async sendFile(chatId, filePath) {
247
+ async sendFile(chatId, file) {
249
248
  if (!conn.send)
250
249
  return;
251
- const mediaType = isImageName(filePath) ? "image" : "file";
250
+ const mediaType = isImageName(file.safeName) ? "image" : "file";
252
251
  try {
253
- const mediaId = await uploadMedia(filePath, mediaType);
252
+ const mediaId = await uploadMedia(file, mediaType);
254
253
  await conn.send(CMD_SEND, { chatid: String(chatId), msgtype: mediaType, [mediaType]: { media_id: mediaId } });
255
254
  }
256
255
  catch {
@@ -6,7 +6,6 @@
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { chmodSync, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
9
- import { basename } from "node:path";
10
9
  import { randomBytes, randomUUID, createHash, createCipheriv, createDecipheriv } from "node:crypto";
11
10
  import { chunkText } from "./telegram.js";
12
11
  import { INBOUND_MEDIA_MAX_BYTES, INBOUND_MEDIA_TIMEOUT_MS, InboundMediaBudget, cleanupTransientMedia, readResponseBytesLimited, savePrivateMediaBytes, } from "./media.js";
@@ -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 */
@@ -501,14 +511,14 @@ async function cdnUpload(uploadUrl, ciphertext) {
501
511
  throw new Error("CDN upload missing x-encrypted-param header");
502
512
  return ep;
503
513
  }
504
- /** 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
505
515
  * file attachment (file_item) carrying the filename. Ported byte-exact from iLink's media protocol:
506
516
  * getuploadurl → AES-128-ECB encrypt → CDN POST → sendmessage(item). */
507
- export async function sendMediaFile(creds, tokenStore, peer, filePath) {
517
+ export async function sendMediaFile(creds, tokenStore, peer, file) {
508
518
  try {
509
- const plaintext = readFileSync(filePath);
519
+ const plaintext = file.bytes;
510
520
  const rawsize = plaintext.length;
511
- const isImage = IMAGE_EXTS.has((filePath.split(".").pop() || "").toLowerCase());
521
+ const isImage = IMAGE_EXTS.has((file.safeName.split(".").pop() || "").toLowerCase());
512
522
  const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
513
523
  const filekey = randomBytes(16).toString("hex");
514
524
  const key = randomBytes(16);
@@ -524,7 +534,7 @@ export async function sendMediaFile(creds, tokenStore, peer, filePath) {
524
534
  const encryptedParam = await cdnUpload(uploadUrl, ciphertext);
525
535
  const item = isImage
526
536
  ? imageInlineItem(encryptedParam, apiAesKey(keyHex), ciphertext.length)
527
- : audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, basename(filePath));
537
+ : audioFileItem(encryptedParam, apiAesKey(keyHex), rawsize, file.safeName);
528
538
  const clientId = `hara-weixin-${randomUUID().replace(/-/g, "")}`; // reused across the -14 retry for dedup
529
539
  const send = (ctx) => {
530
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] };
@@ -623,9 +633,9 @@ export function weixinAdapter(creds) {
623
633
  for (const part of chunkText(text || "(empty)"))
624
634
  await sendChunk(creds, tokenStore, peer, part);
625
635
  },
626
- async sendFile(chatId, filePath) {
627
- if (!(await sendMediaFile(creds, tokenStore, String(chatId), filePath)))
628
- throw new Error(`weixin file delivery failed: ${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}`);
629
639
  },
630
640
  async start(onMessage, signal, shouldDownload) {
631
641
  let buf = loadCursor(creds.account_id);
package/dist/hooks.js CHANGED
@@ -6,6 +6,8 @@ import { spawnSync } from "node:child_process";
6
6
  import { resolve } from "node:path";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { pluginHooks } from "./plugins/plugins.js";
9
+ import { redactToolSubprocessOutput, toolSubprocessEnv } from "./security/subprocess-env.js";
10
+ import { shellCommand } from "./sandbox.js";
9
11
  const cache = new Map();
10
12
  export function resetHooksCache() {
11
13
  cache.clear();
@@ -47,28 +49,32 @@ export function runHooks(event, toolName, payload, cwd, timeoutMs = 30_000) {
47
49
  continue;
48
50
  let r;
49
51
  try {
50
- r = spawnSync(h.command, {
51
- 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, {
52
56
  cwd,
53
57
  input: JSON.stringify({ tool: toolName, payload }),
54
58
  encoding: "utf8",
55
59
  timeout: timeoutMs,
56
- env: { ...process.env, HARA_TOOL_NAME: toolName },
60
+ env: toolSubprocessEnv(process.env, { HARA_TOOL_NAME: toolName }),
57
61
  });
58
62
  }
59
63
  catch (error) {
60
64
  if (event === "PreToolUse") {
61
- const detail = error instanceof Error ? error.message : String(error);
65
+ const detail = redactToolSubprocessOutput(error instanceof Error ? error.message : String(error));
62
66
  return { block: true, message: `⛔ blocked because a PreToolUse hook could not start${detail ? `: ${detail}` : ""}` };
63
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.
64
70
  continue;
65
71
  }
66
72
  // A hook is allowed to ignore stdin. On Linux, a command that exits successfully before Node finishes
67
73
  // writing `input` can report EPIPE in r.error *alongside status=0*. The wait status is authoritative in
68
74
  // that case; true launch/write failures still have status=null, while timeouts/signals remain blocked.
69
75
  if (event === "PreToolUse" && (r.status !== 0 || !!r.signal)) {
70
- const output = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
71
- const failure = r.error?.message || (r.signal ? `terminated by ${r.signal}` : `exit ${r.status ?? "unknown"}`);
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"}`));
72
78
  return { block: true, message: `⛔ blocked by a PreToolUse hook${output ? `: ${output}` : ` (${failure})`}` };
73
79
  }
74
80
  }