@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
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ import { formatContextReport } from "./agent/context-report.js";
34
34
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
35
35
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
36
36
  import { mapLimit, maxParallel } from "./concurrency.js";
37
- import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
37
+ import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence, commitMessageInput, protectedStagedPaths, protectedTrackedWorkingTreePaths, protectedWorkingTreePaths, } from "./org/review-chain.js";
38
38
  import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
39
39
  import { parseDeliver } from "./cron/deliver.js";
40
40
  import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
@@ -50,6 +50,8 @@ import { resolvePlatform } from "./providers/registry.js";
50
50
  import { levelsFor } from "./tui/model-picker.js";
51
51
  import { listModels } from "./providers/models.js";
52
52
  import { listJobs, tailJob, killJob } from "./exec/jobs.js";
53
+ import { readModelContextFileSync } from "./fs-read.js";
54
+ import { MIN_NODE_VERSION, unsupportedNodeMessage } from "./runtime.js";
53
55
  /** Render the background-job list for /jobs (user-facing view of what the agent has running in the
54
56
  * background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
55
57
  function renderBgJobs() {
@@ -395,24 +397,31 @@ async function runInit(provider, cwd, sandbox = "off") {
395
397
  /** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
396
398
  * Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
397
399
  async function autoCommit(provider, cwd) {
400
+ const before = protectedWorkingTreePaths(cwd);
401
+ if (before.length)
402
+ return `error: refusing to stage protected path(s): ${before.map((p) => JSON.stringify(p)).join(", ")}`;
398
403
  try {
399
404
  await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
400
405
  }
401
406
  catch {
402
407
  /* fall through — empty diff is reported below */
403
408
  }
404
- let diff = "";
405
- try {
406
- diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
409
+ const protectedAfterStage = protectedStagedPaths(cwd);
410
+ if (protectedAfterStage.length) {
411
+ return `error: refusing to inspect or commit protected staged path(s): ${protectedAfterStage.map((p) => JSON.stringify(p)).join(", ")}`;
407
412
  }
408
- catch (e) {
409
- return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
413
+ const staged = captureChanges(cwd, 120_000, { staged: true, includeUntracked: false });
414
+ if (staged.error)
415
+ return `error: git diff failed closed (${staged.error})`;
416
+ if (staged.skippedFiles.length) {
417
+ return `error: refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`;
410
418
  }
411
- if (!diff.trim())
419
+ const changeInput = commitMessageInput(staged);
420
+ if (!changeInput.trim())
412
421
  return "nothing to commit";
413
422
  const r = await provider.turn({
414
423
  system: COMMIT_SYSTEM,
415
- history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
424
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
416
425
  tools: [],
417
426
  onText: () => { },
418
427
  });
@@ -422,6 +431,10 @@ async function autoCommit(provider, cwd) {
422
431
  const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
423
432
  writeFileSync(tmp, msg + "\n", "utf8");
424
433
  try {
434
+ const protectedBeforeCommit = protectedStagedPaths(cwd);
435
+ if (protectedBeforeCommit.length) {
436
+ return `error: staged paths changed while writing the message; protected path(s) will not be committed: ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}`;
437
+ }
425
438
  const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
426
439
  return (res.stdout || "").trim().split("\n")[0] || "committed";
427
440
  }
@@ -536,7 +549,12 @@ async function runOrg(task, o) {
536
549
  const maxRounds = Math.max(1, o.rounds ?? 3);
537
550
  for (let round = 1; round <= maxRounds; round++) {
538
551
  const changes = captureChanges(o.cwd);
539
- if (!changes.diff && !changes.newFiles.length) {
552
+ if (changes.error) {
553
+ out(c.red(`(review change capture failed closed: ${changes.error})\n`));
554
+ await doCommit(false);
555
+ return { status: "halted", error: `review change capture failed: ${changes.error}` };
556
+ }
557
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
540
558
  out(c.dim("(no changes to review)\n"));
541
559
  return implementerOutcome;
542
560
  }
@@ -586,7 +604,7 @@ function lastAssistantText(history) {
586
604
  /** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
587
605
  async function executeAtom(atom, plan, done, roles, o) {
588
606
  atom.status = "running";
589
- savePlan(o.cwd, plan);
607
+ await savePlan(o.cwd, plan);
590
608
  const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
591
609
  const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
592
610
  const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
@@ -610,7 +628,7 @@ async function executeAtom(atom, plan, done, roles, o) {
610
628
  if (failure) {
611
629
  atom.status = "failed";
612
630
  atom.note = failure;
613
- savePlan(o.cwd, plan);
631
+ await savePlan(o.cwd, plan);
614
632
  out(c.red(` ✗ ${atom.id} agent ${outcome.status}: ${failure}\n`));
615
633
  return false;
616
634
  }
@@ -618,14 +636,14 @@ async function executeAtom(atom, plan, done, roles, o) {
618
636
  catch (e) {
619
637
  atom.status = "failed";
620
638
  atom.note = e.message;
621
- savePlan(o.cwd, plan);
639
+ await savePlan(o.cwd, plan);
622
640
  out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
623
641
  return false;
624
642
  }
625
643
  const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
626
644
  atom.status = v.ok ? "done" : "failed";
627
645
  atom.note = v.reason;
628
- savePlan(o.cwd, plan);
646
+ await savePlan(o.cwd, plan);
629
647
  out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
630
648
  return v.ok;
631
649
  }
@@ -718,7 +736,7 @@ async function runPlan(task, o) {
718
736
  return { status: "halted", error: "plan execution was cancelled" };
719
737
  }
720
738
  }
721
- savePlan(o.cwd, plan);
739
+ await savePlan(o.cwd, plan);
722
740
  return executePlan(plan, roles, o);
723
741
  }
724
742
  /** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
@@ -747,15 +765,31 @@ async function runResume(o) {
747
765
  for (const a of plan.atoms)
748
766
  if (a.status === "failed" || a.status === "running")
749
767
  a.status = "pending"; // retry interrupted
750
- savePlan(o.cwd, plan);
768
+ await savePlan(o.cwd, plan);
751
769
  return executePlan(plan, roles, o);
752
770
  }
753
771
  const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
754
- const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
772
+ const REVIEW_SYSTEM = "You are a senior code reviewer. Review the safe Git status metadata the user provides for: correctness bugs, security " +
755
773
  "issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
756
- "for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
774
+ "to inspect their current contents; historical patch lines are intentionally unavailable. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
757
775
  "**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
758
- const COMMIT_SYSTEM = "Write a git commit message for the staged diff. A concise imperative subject (≤72 chars; an optional " +
776
+ function standaloneReviewPrompt(changes) {
777
+ const parts = ["Review these changes:"];
778
+ if (changes.diff)
779
+ parts.push(`Change metadata only (status + path; no historical patch contents):\n\`\`\`text\n${changes.diff}\n\`\`\``);
780
+ if (changes.newFiles.length)
781
+ parts.push(`New files (inspect with read_file): ${changes.newFiles.map((p) => JSON.stringify(p)).join(", ")}`);
782
+ if (changes.skippedFiles.length) {
783
+ parts.push("Protected paths were omitted and MUST NOT be opened during this review: " +
784
+ changes.skippedFiles.map((p) => JSON.stringify(p)).join(", "));
785
+ }
786
+ if (changes.omittedDeletions.length) {
787
+ parts.push("Tracked deletions were detected; their historical contents were omitted because old filesystem " +
788
+ "identity cannot be verified safely: " + changes.omittedDeletions.map((p) => JSON.stringify(p)).join(", "));
789
+ }
790
+ return parts.join("\n\n");
791
+ }
792
+ const COMMIT_SYSTEM = "Write a git commit message for the staged change metadata. A concise imperative subject (≤72 chars; an optional " +
759
793
  "conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
760
794
  "non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
761
795
  "Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
@@ -834,7 +868,7 @@ async function compactConversation(provider, history, meta, stats) {
834
868
  // most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
835
869
  const restore = buildFileRestore(recentTouched(5), (p) => {
836
870
  try {
837
- return readFileSync(p, "utf8");
871
+ return readModelContextFileSync(p, 32 * 1024);
838
872
  }
839
873
  catch {
840
874
  return null;
@@ -938,7 +972,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
938
972
  function runDoctor(cfg) {
939
973
  const ok = (b) => (b ? c.green("✓") : c.red("✗"));
940
974
  const dot = c.dim("·");
941
- const nodeMajor = Number(process.versions.node.split(".")[0]);
975
+ const nodeSupported = unsupportedNodeMessage() === null;
942
976
  const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
943
977
  const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
944
978
  const authed = hasKey || oauthOk;
@@ -948,7 +982,7 @@ function runDoctor(cfg) {
948
982
  const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
949
983
  const lines = [
950
984
  c.bold("hara doctor"),
951
- `${ok(nodeMajor >= 20)} node ${process.versions.node} ${c.dim("(need ≥20)")}`,
985
+ `${ok(nodeSupported)} node ${process.versions.node} ${c.dim(`(need ≥${MIN_NODE_VERSION})`)}`,
952
986
  `${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
953
987
  `${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
954
988
  `${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
@@ -1550,14 +1584,14 @@ profileCmd
1550
1584
  profileCmd
1551
1585
  .command("pin [id]")
1552
1586
  .description("write `.hara-profile` in this dir to lock the active profile here (omit id = pin current active)")
1553
- .action((id) => {
1587
+ .action(async (id) => {
1554
1588
  const target = (id && id.trim()) || activeId();
1555
1589
  if (!getProfile(target)) {
1556
1590
  out(c.red(`No profile '${target}'.\n`) + c.dim("List: `hara profile list`\n"));
1557
1591
  process.exit(1);
1558
1592
  }
1559
1593
  try {
1560
- const { file } = writePin(process.cwd(), target);
1594
+ const { file } = await writePin(process.cwd(), target);
1561
1595
  out(c.green(`✓ pinned ${target} to ${relPath(file)}\n`));
1562
1596
  // .hara-profile carries personal identity (which org you're as), unlike .nvmrc which
1563
1597
  // is project-level. Nudge user toward GLOBAL gitignore so they don't accidentally
@@ -1886,7 +1920,7 @@ program
1886
1920
  });
1887
1921
  program
1888
1922
  .command("review")
1889
- .description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
1923
+ .description("review your uncommitted changes for bugs, security, and missing tests")
1890
1924
  .option("--staged", "review only staged changes")
1891
1925
  .option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
1892
1926
  .action(async (opts) => {
@@ -1896,19 +1930,22 @@ program
1896
1930
  out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1897
1931
  process.exit(1);
1898
1932
  }
1899
- const cmd = opts.base ? `git diff ${opts.base}` : opts.staged ? "git diff --staged" : "git diff HEAD";
1900
- let diff = "";
1901
- try {
1902
- diff = (await runShell(cmd, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1933
+ const changes = captureChanges(cfg.cwd, 120_000, {
1934
+ staged: opts.staged,
1935
+ base: opts.base,
1936
+ includeUntracked: !opts.staged && !opts.base,
1937
+ });
1938
+ if (changes.error)
1939
+ return void out(c.red(`Git change capture failed closed: ${changes.error}\n`) + c.dim("(is this a git repo / valid base ref?)\n"));
1940
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
1941
+ return void out(c.dim("No changes to review.\n"));
1903
1942
  }
1904
- catch (e) {
1905
- return void out(c.red(`\`${cmd}\` failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
1943
+ if (changes.skippedFiles.length) {
1944
+ out(c.yellow(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
1906
1945
  }
1907
- if (!diff.trim())
1908
- return void out(c.dim(`No changes to review (${cmd}).\n`));
1909
- out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
1946
+ out(c.dim(`Reviewing ${changes.diff ? changes.diff.split("\n").length : 0} safe change entries…\n\n`));
1910
1947
  const stats = { input: 0, output: 0, lastInput: 0 };
1911
- await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
1948
+ await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
1912
1949
  provider,
1913
1950
  ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
1914
1951
  approval: "full-auto",
@@ -1936,6 +1973,10 @@ program
1936
1973
  process.exit(1);
1937
1974
  }
1938
1975
  if (opts.all) {
1976
+ const protectedTracked = protectedTrackedWorkingTreePaths(cfg.cwd);
1977
+ if (protectedTracked.length) {
1978
+ return void out(c.red(`Refusing to stage protected path(s): ${protectedTracked.map((p) => JSON.stringify(p)).join(", ")}\n`));
1979
+ }
1939
1980
  try {
1940
1981
  await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
1941
1982
  }
@@ -1943,19 +1984,23 @@ program
1943
1984
  /* report below if nothing is staged */
1944
1985
  }
1945
1986
  }
1946
- let diff = "";
1947
- try {
1948
- diff = (await runShell("git diff --staged", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1987
+ const protectedStaged = protectedStagedPaths(cfg.cwd);
1988
+ if (protectedStaged.length) {
1989
+ return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${protectedStaged.map((p) => JSON.stringify(p)).join(", ")}\n`));
1949
1990
  }
1950
- catch (e) {
1951
- return void out(c.red(`git diff failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
1991
+ const staged = captureChanges(cfg.cwd, 120_000, { staged: true, includeUntracked: false });
1992
+ if (staged.error)
1993
+ return void out(c.red(`git diff failed closed: ${staged.error}\n`) + c.dim("(is this a git repo?)\n"));
1994
+ if (staged.skippedFiles.length) {
1995
+ return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
1952
1996
  }
1953
- if (!diff.trim())
1997
+ const changeInput = commitMessageInput(staged);
1998
+ if (!changeInput.trim())
1954
1999
  return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
1955
2000
  out(c.dim("Writing a commit message…\n"));
1956
2001
  const r = await provider.turn({
1957
2002
  system: COMMIT_SYSTEM,
1958
- history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
2003
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
1959
2004
  tools: [],
1960
2005
  onText: () => { },
1961
2006
  });
@@ -1975,6 +2020,10 @@ program
1975
2020
  const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
1976
2021
  writeFileSync(tmp, msg + "\n", "utf8");
1977
2022
  try {
2023
+ const protectedBeforeCommit = protectedStagedPaths(cfg.cwd);
2024
+ if (protectedBeforeCommit.length) {
2025
+ return void out(c.red(`Staged paths changed; refusing to commit protected path(s): ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}\n`));
2026
+ }
1978
2027
  const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
1979
2028
  out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
1980
2029
  }
@@ -2123,8 +2172,8 @@ memoryCmd.command("show").description("print the memory digest injected at sessi
2123
2172
  const d = memoryDigest(process.cwd());
2124
2173
  out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
2125
2174
  });
2126
- memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
2127
- const w = scaffoldMemory(process.cwd());
2175
+ memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(async () => {
2176
+ const w = await scaffoldMemory(process.cwd());
2128
2177
  out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
2129
2178
  });
2130
2179
  memoryCmd
@@ -2320,8 +2369,8 @@ const skillsCmd = program.command("skills").description("manage skills (.hara/sk
2320
2369
  skillsCmd
2321
2370
  .command("init")
2322
2371
  .description("scaffold an example skill")
2323
- .action(() => {
2324
- const written = scaffoldSkills(process.cwd());
2372
+ .action(async () => {
2373
+ const written = await scaffoldSkills(process.cwd());
2325
2374
  out(written.length
2326
2375
  ? c.green(`Created an example skill: ${written.join(", ")}\n`)
2327
2376
  : c.dim("Skills already exist in .hara/skills/.\n"));
@@ -2588,13 +2637,34 @@ program.action(async (opts) => {
2588
2637
  out(c.yellow(message));
2589
2638
  }
2590
2639
  const stats = { input: 0, output: 0, lastInput: 0 };
2591
- // A read-only role has a process-side-effect-free tool surface: even connecting to an MCP server runs an
2592
- // arbitrary configured command before a tool is selected. Interactive sessions and non-read-only roles keep
2593
- // the existing MCP behavior unchanged.
2594
- if (!requestedHeadlessRole?.readOnly) {
2595
- const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2596
- if (Object.keys(mcpAll).length) {
2597
- await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")));
2640
+ // Connecting to MCP executes configured external commands before an MCP tool is selected, so the ordinary
2641
+ // per-tool confirmation is too late. Obtain one explicit startup grant for the named server set. askConfirm
2642
+ // uses a short-lived Ink prompt and restores stdin, making it safe before either the main TUI or readline REPL
2643
+ // owns the terminal. Non-interactive runs remain closed unless the user opted in before process launch.
2644
+ const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2645
+ const mcpNames = Object.keys(mcpAll);
2646
+ if (!requestedHeadlessRole?.readOnly && mcpNames.length) {
2647
+ const trustedOptIn = process.env.HARA_ALLOW_TRUSTED_EXTENSIONS === "1";
2648
+ const interactiveTerminal = !opts.print && !!stdin.isTTY && !!stdout.isTTY;
2649
+ const approved = trustedOptIn ||
2650
+ (interactiveTerminal &&
2651
+ (await askConfirm(`Start configured MCP server${mcpNames.length === 1 ? "" : "s"} ${mcpNames
2652
+ .slice(0, 8)
2653
+ .map((name) => `'${name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80)}'`)
2654
+ .join(", ")}${mcpNames.length > 8 ? ` and ${mcpNames.length - 8} more` : ""}? ` +
2655
+ "They execute external code outside Hara's protected-file boundary.", false)));
2656
+ if (approved) {
2657
+ await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")), { approved: true });
2658
+ }
2659
+ else {
2660
+ const message = interactiveTerminal
2661
+ ? "hara: MCP server startup declined; no configured MCP command was executed.\n"
2662
+ : "hara: MCP servers skipped because this run has no interactive startup approval. " +
2663
+ "Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only for reviewed servers.\n";
2664
+ if (machineOutput || !interactiveTerminal)
2665
+ process.stderr.write(message);
2666
+ else
2667
+ out(c.dim(message));
2598
2668
  }
2599
2669
  }
2600
2670
  // one-shot
@@ -2730,7 +2800,18 @@ program.action(async (opts) => {
2730
2800
  let schemaObj = null;
2731
2801
  if (opts.schema) {
2732
2802
  const rawArg = String(opts.schema);
2733
- const raw = existsSync(rawArg) ? readFileSync(rawArg, "utf8") : rawArg;
2803
+ let raw = rawArg;
2804
+ if (existsSync(rawArg)) {
2805
+ try {
2806
+ raw = readModelContextFileSync(rawArg, 1024 * 1024);
2807
+ }
2808
+ catch (error) {
2809
+ process.stderr.write(`hara: refusing unsafe schema file: ${error instanceof Error ? error.message : String(error)}\n`);
2810
+ process.exitCode = 2;
2811
+ await closeMcp();
2812
+ return;
2813
+ }
2814
+ }
2734
2815
  const parsed = parseSchemaArg(raw);
2735
2816
  if ("error" in parsed) {
2736
2817
  process.stderr.write(`hara: ${parsed.error}\n`);
@@ -3713,19 +3794,19 @@ program.action(async (opts) => {
3713
3794
  return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
3714
3795
  }
3715
3796
  if (nm === "review") {
3716
- let diff = "";
3717
- try {
3718
- diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
3797
+ const changes = captureChanges(cwd, 120_000, { includeUntracked: true });
3798
+ if (changes.error)
3799
+ return void h.sink.notice(`(review capture failed closed: ${changes.error})`);
3800
+ if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
3801
+ return void h.sink.notice("(nothing to review — no changes vs HEAD)");
3719
3802
  }
3720
- catch {
3721
- /* not a git repo empty */
3803
+ if (changes.skippedFiles.length) {
3804
+ h.sink.notice(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`);
3722
3805
  }
3723
- if (!diff.trim())
3724
- return void h.sink.notice("(nothing to review — no changes vs HEAD)");
3725
3806
  const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3726
3807
  const xin = stats.input;
3727
3808
  const xout = stats.output;
3728
- await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
3809
+ await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
3729
3810
  provider,
3730
3811
  ctx: { cwd, sandbox, ui: rui },
3731
3812
  approval: "full-auto", // read-only via the tool filter, so nothing prompts
@@ -2,21 +2,158 @@
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
4
  import { registerTool } from "../tools/registry.js";
5
+ import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
6
+ import { sensitiveStructuredInputReason } from "../security/sensitive-files.js";
5
7
  const clients = [];
8
+ const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
9
+ const MIN_STARTUP_TIMEOUT_MS = 50;
10
+ const MAX_STARTUP_TIMEOUT_MS = 60_000;
11
+ const safeDiagnosticName = (name) => name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128) || "server";
12
+ function startupTimeout(value) {
13
+ if (value === undefined || !Number.isFinite(value))
14
+ return DEFAULT_STARTUP_TIMEOUT_MS;
15
+ return Math.max(MIN_STARTUP_TIMEOUT_MS, Math.min(MAX_STARTUP_TIMEOUT_MS, Math.floor(value)));
16
+ }
17
+ async function closeFailedMcp(client, transport) {
18
+ try {
19
+ if (client)
20
+ await client.close();
21
+ else if (transport)
22
+ await transport.close();
23
+ }
24
+ catch {
25
+ // Client.close normally owns the transport. If it failed part-way through, make one direct best-effort
26
+ // attempt so an unresponsive startup cannot leave its child process running in the background.
27
+ try {
28
+ await transport?.close();
29
+ }
30
+ catch { /* preserve the original connect/list error */ }
31
+ }
32
+ }
33
+ /** Drain MCP stderr without letting a server leak split credentials, inject terminal control bytes, or grow
34
+ * an unbounded no-newline buffer. Oversized lines are dropped whole so even a partial token is never emitted. */
35
+ function mcpStderrRedactor(name, explicitEnv, log) {
36
+ const lineLimit = 16 * 1024;
37
+ const totalLimit = 64 * 1024;
38
+ const safeName = safeDiagnosticName(name);
39
+ let pending = "";
40
+ let droppingLine = false;
41
+ let emitted = 0;
42
+ let silenced = false;
43
+ const emitOmitted = () => {
44
+ if (silenced)
45
+ return;
46
+ const message = `mcp: ${safeName} stderr: [oversized diagnostic line omitted]`;
47
+ if (emitted + message.length > totalLimit) {
48
+ log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
49
+ silenced = true;
50
+ return;
51
+ }
52
+ emitted += message.length;
53
+ log(message);
54
+ };
55
+ const emit = (raw) => {
56
+ if (silenced)
57
+ return;
58
+ const clean = redactToolSubprocessOutput(raw, process.env, explicitEnv ?? {})
59
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
60
+ .replace(/\r?\n$/, "");
61
+ if (!clean)
62
+ return;
63
+ const message = `mcp: ${safeName} stderr: ${clean}`;
64
+ if (emitted + message.length > totalLimit) {
65
+ log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
66
+ silenced = true;
67
+ return;
68
+ }
69
+ emitted += message.length;
70
+ log(message);
71
+ };
72
+ return {
73
+ push(chunk) {
74
+ if (silenced || !chunk)
75
+ return;
76
+ let start = 0;
77
+ while (start < chunk.length && !silenced) {
78
+ const newline = chunk.indexOf("\n", start);
79
+ const end = newline < 0 ? chunk.length : newline + 1;
80
+ const length = end - start;
81
+ if (droppingLine) {
82
+ if (newline >= 0) {
83
+ droppingLine = false;
84
+ emitOmitted();
85
+ }
86
+ }
87
+ else if (pending.length + length > lineLimit) {
88
+ pending = "";
89
+ if (newline >= 0)
90
+ emitOmitted();
91
+ else
92
+ droppingLine = true;
93
+ }
94
+ else {
95
+ pending += chunk.slice(start, end);
96
+ if (newline >= 0) {
97
+ emit(pending);
98
+ pending = "";
99
+ }
100
+ }
101
+ start = end;
102
+ }
103
+ },
104
+ flush() {
105
+ if (droppingLine)
106
+ emitOmitted();
107
+ else if (pending)
108
+ emit(pending);
109
+ pending = "";
110
+ droppingLine = false;
111
+ },
112
+ };
113
+ }
6
114
  /** Connect each server, register its tools as `mcp__<server>__<tool>`. Returns #tools registered. */
7
- export async function connectMcpServers(servers, log) {
115
+ export async function connectMcpServers(servers, log, options = {}) {
116
+ // Starting a configured MCP server already executes arbitrary external code, before any MCP tool call
117
+ // reaches the agent approval loop. Keep that side effect behind its own explicit startup grant even when
118
+ // a future caller forgets to apply the CLI-level policy.
119
+ if (!options.approved && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
120
+ if (Object.keys(servers).length) {
121
+ log("mcp: skipped — configured servers are trusted extensions outside Hara's file boundary; " +
122
+ "approve them interactively or set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch after review");
123
+ }
124
+ return 0;
125
+ }
126
+ const timeoutMs = startupTimeout(options.timeoutMs);
127
+ const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs };
8
128
  let count = 0;
9
129
  for (const [name, cfg] of Object.entries(servers)) {
130
+ const diagnosticName = safeDiagnosticName(name);
131
+ let flushStderr;
132
+ let transport;
133
+ let client;
134
+ let stage = "connect";
10
135
  try {
11
- const transport = new StdioClientTransport({
136
+ transport = new StdioClientTransport({
12
137
  command: cfg.command,
13
138
  args: cfg.args ?? [],
14
- env: { ...process.env, ...(cfg.env ?? {}) },
139
+ // The inherited agent environment is scrubbed; server-specific cfg.env is an explicit grant.
140
+ env: toolSubprocessEnv(process.env, cfg.env ?? {}),
141
+ // The SDK default is "inherit", which would let an external server print credentials or terminal
142
+ // control sequences straight to Hara's stderr. Pipe it so every diagnostic crosses our redactor.
143
+ stderr: "pipe",
15
144
  });
16
- const client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
17
- await client.connect(transport);
18
- clients.push(client);
19
- const { tools } = await client.listTools();
145
+ const stderrRedactor = mcpStderrRedactor(name, cfg.env, log);
146
+ flushStderr = () => stderrRedactor.flush();
147
+ // `stderr` is available before connect() when piping is requested, so early startup errors are neither
148
+ // lost nor inherited. Line buffering prevents a credential split across chunks from evading redaction.
149
+ transport.stderr?.on("data", (chunk) => stderrRedactor.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk)));
150
+ transport.stderr?.on("end", flushStderr);
151
+ transport.stderr?.on("close", flushStderr);
152
+ client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
153
+ await client.connect(transport, requestOptions);
154
+ stage = "list tools";
155
+ const activeClient = client;
156
+ const { tools } = await activeClient.listTools(undefined, requestOptions);
20
157
  for (const t of tools) {
21
158
  const schema = t.inputSchema ?? { type: "object", properties: {} };
22
159
  registerTool({
@@ -24,19 +161,34 @@ export async function connectMcpServers(servers, log) {
24
161
  description: t.description ?? `${name}/${t.name}`,
25
162
  input_schema: schema,
26
163
  kind: "exec",
27
- async run(input) {
28
- const res = await client.callTool({ name: t.name, arguments: input ?? {} });
164
+ trustBoundary: "external",
165
+ async run(input, ctx) {
166
+ if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
167
+ return "Blocked: MCP is a trusted extension outside Hara's file boundary and is disabled in non-interactive runs. Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only after reviewing the server.";
168
+ }
169
+ const protectedReason = sensitiveStructuredInputReason(input, ctx.cwd);
170
+ if (protectedReason)
171
+ return `Blocked: MCP input names protected ${protectedReason}.`;
172
+ const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} });
29
173
  const blocks = Array.isArray(res?.content) ? res.content : [];
30
174
  const text = blocks.map((b) => (b?.type === "text" ? b.text : JSON.stringify(b))).join("\n");
31
- return text || "(no output)";
175
+ return redactToolSubprocessOutput(text || "(no output)", process.env, cfg.env ?? {});
32
176
  },
33
177
  });
34
178
  count++;
35
179
  }
36
- log(`mcp: ${name} → ${tools.length} tool(s)`);
180
+ clients.push(activeClient);
181
+ log(`mcp: ${diagnosticName} → ${tools.length} tool(s)`);
37
182
  }
38
183
  catch (e) {
39
- log(`mcp: ${name} failed (${e?.message ?? e})`);
184
+ // Do this before returning/logging: a timeout is not merely a failed request; the external process
185
+ // must be torn down immediately and must not enter the global successful-client pool.
186
+ await closeFailedMcp(client, transport);
187
+ flushStderr?.();
188
+ const error = redactToolSubprocessOutput(String(e?.message ?? e), process.env, cfg.env ?? {})
189
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
190
+ .trim();
191
+ log(`mcp: ${diagnosticName} failed during ${stage} (${error})`);
40
192
  }
41
193
  }
42
194
  return count;