@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.
- package/CHANGELOG.md +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- 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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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
|
-
|
|
1905
|
-
|
|
1943
|
+
if (changes.skippedFiles.length) {
|
|
1944
|
+
out(c.yellow(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
1906
1945
|
}
|
|
1907
|
-
|
|
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:
|
|
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
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
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
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
// the
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
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
|
-
|
|
3721
|
-
|
|
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:
|
|
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
|
package/dist/mcp/client.js
CHANGED
|
@@ -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
|
-
|
|
136
|
+
transport = new StdioClientTransport({
|
|
12
137
|
command: cfg.command,
|
|
13
138
|
args: cfg.args ?? [],
|
|
14
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
180
|
+
clients.push(activeClient);
|
|
181
|
+
log(`mcp: ${diagnosticName} → ${tools.length} tool(s)`);
|
|
37
182
|
}
|
|
38
183
|
catch (e) {
|
|
39
|
-
|
|
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;
|