@nanhara/hara 0.53.0 → 0.67.0

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.
@@ -0,0 +1,56 @@
1
+ // MCP server — expose hara's safe read/search tools over stdio so other MCP clients (Claude Desktop,
2
+ // Cursor, another hara, …) can use them. The high-value one is `codebase_search` over the current repo
3
+ // (semantic if you've built an index, lexical otherwise). `hara mcp` runs this.
4
+ //
5
+ // Read-only by DEFAULT: no edit/write/bash/computer — an external client must not be able to mutate your
6
+ // machine through hara. Override the exposed set with HARA_MCP_TOOLS (comma list) at your own risk.
7
+ //
8
+ // IMPORTANT: stdio is the JSON-RPC transport — this module must never write to stdout. Diagnostics go to
9
+ // stderr (the `hara mcp` command handles that); tool output flows back through the protocol.
10
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
+ import { getTool, getTools } from "../tools/registry.js";
14
+ /** Safe default: navigate + search the repo + the web. No state, no mutation, no privacy-sensitive memory. */
15
+ const SAFE_TOOLS = ["read_file", "grep", "glob", "ls", "codebase_search", "web_fetch", "web_search"];
16
+ /** Names to expose: HARA_MCP_TOOLS (comma list) if set, else the safe default — intersected with what's
17
+ * actually registered. An unknown name in the override is silently dropped. */
18
+ export function mcpServeToolNames() {
19
+ const env = process.env.HARA_MCP_TOOLS;
20
+ const want = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : SAFE_TOOLS;
21
+ const have = new Set(getTools().map((t) => t.name));
22
+ return want.filter((n) => have.has(n));
23
+ }
24
+ /** The exposed tools in MCP `tools/list` shape. */
25
+ export function mcpToolList() {
26
+ return mcpServeToolNames().map((n) => {
27
+ const t = getTool(n);
28
+ return { name: t.name, description: t.description, inputSchema: t.input_schema };
29
+ });
30
+ }
31
+ /** Run one exposed tool. Refuses anything outside the exposed set (defense in depth, even if a client
32
+ * asks for a name it shouldn't know). Never throws — errors come back as `isError` results. */
33
+ export async function mcpCallTool(name, args, ctx) {
34
+ if (!mcpServeToolNames().includes(name)) {
35
+ return { content: [{ type: "text", text: `Tool not exposed by \`hara mcp\`: ${name}` }], isError: true };
36
+ }
37
+ const tool = getTool(name);
38
+ if (!tool)
39
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
40
+ try {
41
+ const out = await tool.run(args ?? {}, ctx);
42
+ return { content: [{ type: "text", text: String(out) }] };
43
+ }
44
+ catch (e) {
45
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
46
+ }
47
+ }
48
+ /** Start the stdio MCP server and block (the transport keeps the process alive). */
49
+ export async function startMcpServer(version, ctx) {
50
+ const server = new Server({ name: "hara", version }, { capabilities: { tools: {} } });
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpToolList() }));
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK's result union has a task-augmented
53
+ // member TS can't narrow to; our {content,isError} shape is a valid CallToolResult (validated at runtime).
54
+ server.setRequestHandler(CallToolRequestSchema, async (req) => (await mcpCallTool(req.params.name, (req.params.arguments ?? {}), ctx)));
55
+ await server.connect(new StdioServerTransport());
56
+ }
@@ -3,9 +3,21 @@
3
3
  // project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
4
4
  import { homedir } from "node:os";
5
5
  import { join, dirname } from "node:path";
6
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
7
7
  import { findProjectRoot } from "../context/agents-md.js";
8
- const DIGEST_CAP = 4000; // chars of MEMORY/USER injected at session start (logs reached via search)
8
+ // Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
9
+ // project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
10
+ // boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
11
+ // per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
12
+ const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
13
+ /** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
14
+ function capAtLine(text, cap) {
15
+ if (text.length <= cap)
16
+ return text;
17
+ const cut = text.slice(0, cap);
18
+ const nl = cut.lastIndexOf("\n");
19
+ return (nl > cap * 0.5 ? cut.slice(0, nl) : cut).trimEnd() + "\n…[truncated — memory_search for the rest]";
20
+ }
9
21
  export function memoryDir(scope, cwd) {
10
22
  if (scope === "global")
11
23
  return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
@@ -49,7 +61,9 @@ export function forgetMemory(scope, target, match, cwd) {
49
61
  writeFileSync(f, kept.join("\n"), "utf8");
50
62
  return lines.length - kept.length;
51
63
  }
52
- /** Capped MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. */
64
+ /** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
65
+ * capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
66
+ * never starves USER prefs) and no entry is cut mid-line. Daily logs are reached via memory_search. */
53
67
  export function memoryDigest(cwd) {
54
68
  const sources = [
55
69
  ["project", "memory", "project MEMORY"],
@@ -64,14 +78,38 @@ export function memoryDigest(cwd) {
64
78
  try {
65
79
  const t = readFileSync(f, "utf8").trim();
66
80
  if (t)
67
- parts.push(`## ${label}\n${t}`);
81
+ parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
82
+ }
83
+ catch {
84
+ /* skip unreadable */
85
+ }
86
+ }
87
+ return parts.join("\n\n");
88
+ }
89
+ /** Concatenate the daily logs (`log/YYYY-MM-DD.md`) from the last `days` for one scope — the short-term
90
+ * tier `hara memory distill` consolidates into evergreen MEMORY. Empty if there's no log dir. */
91
+ export function readRecentLogs(scope, cwd, days) {
92
+ const dir = join(memoryDir(scope, cwd), "log");
93
+ if (!existsSync(dir))
94
+ return "";
95
+ const cutoff = Date.now() - days * 86_400_000;
96
+ const out = [];
97
+ for (const f of readdirSync(dir).sort()) {
98
+ if (!f.endsWith(".md"))
99
+ continue;
100
+ const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(f);
101
+ if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
102
+ continue;
103
+ try {
104
+ const t = readFileSync(join(dir, f), "utf8").trim();
105
+ if (t)
106
+ out.push(`### ${f}\n${t}`);
68
107
  }
69
108
  catch {
70
109
  /* skip unreadable */
71
110
  }
72
111
  }
73
- const out = parts.join("\n\n");
74
- return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
112
+ return out.join("\n\n");
75
113
  }
76
114
  /** Create memory dirs + seed files (global + project). Returns files written. */
77
115
  export function scaffoldMemory(cwd) {
@@ -0,0 +1,91 @@
1
+ // Multi-role review chain — hara's "runs like an engineering org" move. After an implementer makes
2
+ // changes, a reviewer role inspects the diff and either APPROVES or requests changes; requested changes
3
+ // feed back to the implementer, looping until approved or a round cap. The orchestration lives in runOrg
4
+ // (it needs runAgent + providers); these are the pure, testable pieces: verdict parsing, change capture,
5
+ // and the prompts. Used by `hara org --review`.
6
+ import { execFileSync } from "node:child_process";
7
+ /** Fallback reviewer persona when the project has no `reviewer` role. Read-only by intent. */
8
+ export const REVIEWER_SYSTEM = `You are a senior code reviewer reviewing changes made to accomplish a task.
9
+ Inspect them for: correctness and bugs, security, missing edge cases, and whether they actually accomplish
10
+ the task. Use read_file / grep / glob / ls to inspect context and any new files. Be concrete and specific —
11
+ cite files. Block only on real problems (bugs, breakage, security), not style preferences.
12
+
13
+ A script parses your final line, so it MUST be EXACTLY one of these two, verbatim, as the LAST line —
14
+ the literal word APPROVED or CHANGES_REQUESTED. Do NOT paraphrase it (not "No issues found", not "LGTM"),
15
+ do NOT bold it, do NOT add words after the token:
16
+ VERDICT: APPROVED
17
+ VERDICT: CHANGES_REQUESTED
18
+
19
+ Use APPROVED only if the changes correctly and safely accomplish the task. If anything must be fixed, use
20
+ CHANGES_REQUESTED and list the required fixes as a short numbered list ABOVE the verdict line — each one
21
+ naming the file and exactly what to change.`;
22
+ // Real models won't reliably emit the literal token — across live runs glm-5 wrote `VERDICT: APPROVED`,
23
+ // `**VERDICT**: No issues found`, and `**VERDICT**: PASS`. So we anchor on the (markdown-tolerant) VERDICT
24
+ // marker, then CLASSIFY the phrase after it: a "changes" signal vetoes (safer), an "approve" signal passes,
25
+ // and anything ambiguous stays NOT approved — worst case is one extra review round, never a bad auto-commit.
26
+ const CHANGES_RE = /\b(changes?[ _-]?request\w*|request\w*[ _-]?changes?|fail(ed|ure)?|reject\w*|block\w*|rework|needs?[ _-]?(work|fix\w*|change\w*)|must[ _-]?(fix|change)|not[ _-]?approv\w*)\b/i;
27
+ const APPROVE_RE = /\b(approv\w*|passe?d?|lgtm|accept\w*|ship[ _-]?it|no[ _-]?(issues?|problems?|changes?|concerns?)|looks?[ _-]?good)\b/i;
28
+ /** Parse a reviewer's reply into a verdict — see the note above for why it's lenient. Takes the LAST
29
+ * VERDICT marker (the final call) and classifies the phrase after it; `issues` is the body before it. */
30
+ export function parseVerdict(text) {
31
+ const markers = [...text.matchAll(/VERDICT\b[*_:\s]*/gi)];
32
+ const last = markers[markers.length - 1];
33
+ if (!last)
34
+ return { approved: false, issues: text.trim() };
35
+ const idx = last.index ?? 0;
36
+ const after = text.slice(idx + last[0].length, idx + last[0].length + 80); // the verdict phrase itself
37
+ const approved = !CHANGES_RE.test(after) && APPROVE_RE.test(after); // changes-signal vetoes; ambiguous = not approved
38
+ return { approved, issues: text.slice(0, idx).trim() };
39
+ }
40
+ /** Capture the working-tree changes vs HEAD (what to review). Non-destructive; empty for a non-git dir
41
+ * or a repo with no commits. New (untracked) files are listed by name — the reviewer can read_file them. */
42
+ export function captureChanges(cwd, cap = 100_000) {
43
+ const git = (args) => {
44
+ try {
45
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50_000_000 });
46
+ }
47
+ catch {
48
+ return "";
49
+ }
50
+ };
51
+ let diff = git(["diff", "HEAD"]).trim();
52
+ if (diff.length > cap)
53
+ diff = diff.slice(0, cap) + "\n…[diff truncated]";
54
+ const newFiles = git(["ls-files", "--others", "--exclude-standard"])
55
+ .split("\n")
56
+ .map((s) => s.trim())
57
+ .filter(Boolean)
58
+ .slice(0, 50);
59
+ return { diff, newFiles };
60
+ }
61
+ /** True only if the working tree is fully clean — no uncommitted changes. The `--commit` capstone uses
62
+ * this as a guard: `git add -A` + commit is only safe to run when the tree was clean before the org ran,
63
+ * so it captures THIS run's work and never sweeps up pre-existing WIP. False for a non-git dir. */
64
+ export function isTreeClean(cwd) {
65
+ try {
66
+ return execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", maxBuffer: 50_000_000 }).trim() === "";
67
+ }
68
+ catch {
69
+ return false; // not a git repo / git error → treat as "not clean" so we never auto-commit blindly
70
+ }
71
+ }
72
+ /** Strip a leading/trailing markdown code fence a model sometimes wraps a commit message in. */
73
+ export function stripCommitFence(text) {
74
+ return text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
75
+ }
76
+ /** The reviewer's input: the task + the changes to review. */
77
+ export function reviewPrompt(task, changes) {
78
+ const parts = [`Task that was implemented:\n${task}`, ""];
79
+ if (changes.diff)
80
+ parts.push("Changes (working tree vs HEAD):\n```diff\n" + changes.diff + "\n```");
81
+ if (changes.newFiles.length)
82
+ parts.push(`New files (use read_file to inspect): ${changes.newFiles.join(", ")}`);
83
+ if (!changes.diff && !changes.newFiles.length)
84
+ parts.push("(no diff captured — inspect the working tree with git / read_file)");
85
+ parts.push("\nReview these changes against the task. Finish with your VERDICT line.");
86
+ return parts.join("\n");
87
+ }
88
+ /** Feed the reviewer's requested changes back to the implementer. */
89
+ export function fixPrompt(issues) {
90
+ return `A code reviewer reviewed your changes and requires these fixes before this can ship:\n\n${issues}\n\nMake these fixes now — edit the files directly; don't just explain.`;
91
+ }
package/dist/org/roles.js CHANGED
@@ -49,6 +49,17 @@ function parseFrontmatter(text) {
49
49
  }
50
50
  return { fm, body: m[2].trim() };
51
51
  }
52
+ /** Tool filter for a fan-out sub-agent: ALWAYS read-only (sub-agents run full-auto + unconfirmed +
53
+ * parallel), with a role allowed to narrow further but never to grant write/exec. `isReadonly` is the
54
+ * read-kind predicate. This is the guard that keeps the `agent` tool from bypassing the approval gate. */
55
+ export function subagentToolFilter(role, isReadonly) {
56
+ const roleFilter = role?.allowTools
57
+ ? (n) => role.allowTools.includes(n)
58
+ : role?.denyTools
59
+ ? (n) => !role.denyTools.includes(n)
60
+ : null;
61
+ return (n) => isReadonly(n) && (roleFilter ? roleFilter(n) : true);
62
+ }
52
63
  export function loadRoles(cwd) {
53
64
  const byId = new Map();
54
65
  // lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins, same as memory/config)
@@ -0,0 +1,94 @@
1
+ // B-end device enrollment (the OSS client side of the fleet/control-plane story). A hara device joins a
2
+ // fleet by exchanging a one-time enrollment code for a scoped, revocable DEVICE TOKEN — it never holds the
3
+ // real provider key (that stays at the gateway). hara then points its OpenAI-compatible calls at the
4
+ // gateway, which validates the token, maps it to an upstream key, and proxies. Heartbeats give the control
5
+ // plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
6
+ //
7
+ // Protocol (what `hara-control` implements on the other end):
8
+ // POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
9
+ // POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
10
+ // POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
11
+ import { homedir, hostname, platform } from "node:os";
12
+ import { join } from "node:path";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
14
+ const orgPath = () => join(homedir(), ".hara", "org.json");
15
+ const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
16
+ /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
17
+ export function gatewayBaseURL(e) {
18
+ return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
19
+ }
20
+ export function loadEnrollment() {
21
+ const p = orgPath();
22
+ if (!existsSync(p))
23
+ return null;
24
+ try {
25
+ const e = JSON.parse(readFileSync(p, "utf8"));
26
+ return e && typeof e === "object" && e.gatewayUrl && e.deviceToken ? e : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function saveEnrollment(e) {
33
+ mkdirSync(join(homedir(), ".hara"), { recursive: true });
34
+ writeFileSync(orgPath(), JSON.stringify(e, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); // holds a device token → 0600
35
+ try {
36
+ chmodSync(orgPath(), 0o600);
37
+ }
38
+ catch {
39
+ /* best-effort */
40
+ }
41
+ }
42
+ export function clearEnrollment() {
43
+ if (!existsSync(orgPath()))
44
+ return false;
45
+ rmSync(orgPath());
46
+ return true;
47
+ }
48
+ /** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
49
+ export function parseEnrollResponse(gatewayUrl, j, now) {
50
+ const deviceToken = (j.device_token ?? j.deviceToken);
51
+ if (!deviceToken)
52
+ throw new Error("enroll response missing device_token");
53
+ return {
54
+ gatewayUrl: gatewayUrl.replace(/\/$/, ""),
55
+ deviceToken,
56
+ deviceId: String(j.device_id ?? j.deviceId ?? ""),
57
+ model: String(j.model ?? ""),
58
+ baseURL: (j.base_url ?? j.baseURL),
59
+ enrolledAt: now,
60
+ };
61
+ }
62
+ /** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
63
+ export async function enrollDevice(gatewayUrl, code, signal) {
64
+ const base = gatewayUrl.replace(/\/$/, "");
65
+ const res = await fetch(`${base}/v1/enroll`, {
66
+ method: "POST",
67
+ signal,
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ code, device: deviceInfo() }),
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}: ${(await res.text()).slice(0, 200)}`);
73
+ const e = parseEnrollResponse(base, (await res.json()), new Date().toISOString());
74
+ saveEnrollment(e);
75
+ return e;
76
+ }
77
+ /** Best-effort heartbeat so the control plane shows this device online. Never throws. */
78
+ export async function heartbeat(signal) {
79
+ const e = loadEnrollment();
80
+ if (!e)
81
+ return false;
82
+ try {
83
+ const res = await fetch(`${e.gatewayUrl}/v1/heartbeat`, {
84
+ method: "POST",
85
+ signal,
86
+ headers: { "content-type": "application/json", authorization: `Bearer ${e.deviceToken}` },
87
+ body: JSON.stringify({ device_id: e.deviceId, ...deviceInfo() }),
88
+ });
89
+ return res.ok;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
@@ -3,7 +3,7 @@
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
7
7
  const BASE = "https://chat.qwen.ai";
8
8
  const DEVICE_CODE_URL = `${BASE}/api/v1/oauth2/device/code`;
9
9
  const TOKEN_URL = `${BASE}/api/v1/oauth2/token`;
@@ -28,7 +28,14 @@ export function loadQwenToken() {
28
28
  function saveQwenToken(t) {
29
29
  const p = tokenPath();
30
30
  mkdirSync(join(homedir(), ".hara"), { recursive: true });
31
- writeFileSync(p, JSON.stringify(t, null, 2) + "\n", "utf8");
31
+ // 0600 the file holds long-lived access + refresh tokens; don't leave it world-readable on shared boxes.
32
+ writeFileSync(p, JSON.stringify(t, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
33
+ try {
34
+ chmodSync(p, 0o600); // tighten an existing file that predated the mode
35
+ }
36
+ catch {
37
+ /* best-effort */
38
+ }
32
39
  }
33
40
  const b64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
41
  function pkce() {
package/dist/recall.js CHANGED
@@ -24,6 +24,20 @@ export function titleOf(text, path) {
24
24
  const h = /^#\s+(.+)$/m.exec(text);
25
25
  return h ? h[1].trim() : (path.split("/").pop() ?? path);
26
26
  }
27
+ /** A ranking boost from the asset's declared dimensions: a query word in the title or the frontmatter
28
+ * tags/lang matters more than one buried in the body. Used to order results, NOT the base relevance
29
+ * score (which the dedup threshold relies on). */
30
+ export function metaBoost(text, title, words) {
31
+ const titleL = title.toLowerCase();
32
+ let b = words.filter((w) => titleL.includes(w)).length * 3;
33
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
34
+ if (fm) {
35
+ const tags = (/(?:^|\n)tags:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
36
+ const lang = (/(?:^|\n)lang:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
37
+ b += words.filter((w) => `${tags} ${lang}`.includes(w)).length * 2;
38
+ }
39
+ return b;
40
+ }
27
41
  /**
28
42
  * Lexical search: rank .md files by how many query words appear in path+content.
29
43
  * Default searches the code-asset library (relative paths). Pass `roots` to search other dirs
@@ -48,14 +62,16 @@ export function searchAssets(query, limit = 5, roots) {
48
62
  continue;
49
63
  }
50
64
  const hay = (rel + "\n" + text).toLowerCase();
51
- const score = words.filter((w) => hay.includes(w)).length;
65
+ const score = words.filter((w) => hay.includes(w)).length; // distinct query words present (dedup threshold uses this)
52
66
  if (!score)
53
67
  continue;
54
- hits.push({ path: abs ? join(dir, rel) : rel, title: titleOf(text, rel), snippet: text.slice(0, 800), score });
68
+ const title = titleOf(text, rel);
69
+ hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text.slice(0, 800), score, boost: metaBoost(text, title, words) });
55
70
  }
56
71
  }
57
- hits.sort((a, b) => b.score - a.score || a.path.length - b.path.length);
58
- return hits.slice(0, limit);
72
+ // rank by relevance, then by the declared-dimension boost (title/tags/lang), then prefer the shorter path
73
+ hits.sort((a, b) => b.score - a.score || b.boost - a.boost || a.path.length - b.path.length);
74
+ return hits.slice(0, limit).map(({ boost, ...r }) => r);
59
75
  }
60
76
  /** Create the assets dir with an example snippet + README. Returns files written. */
61
77
  export function scaffoldAssets() {
package/dist/sandbox.js CHANGED
@@ -1,6 +1,10 @@
1
- // OS sandboxing for the bash tool. macOS = Seatbelt (sandbox-exec); other platforms run unsandboxed
2
- // (the approval gate + cwd-scoped file tools still apply). Only the `bash` shell is sandboxed —
3
- // hara's own file tools (write_file/edit_file) are in-process, explicit, and gated.
1
+ // OS sandboxing for the bash tool WRITE CONFINEMENT, not a security jail. macOS = Seatbelt
2
+ // (sandbox-exec) restricting `file-write*` to the workspace (workspace-write) or to nothing outside
3
+ // temp (read-only). Reads, network, and process exec are NOT restricted, and /private/tmp +
4
+ // /private/var/folders stay writable in every mode — so this stops a stray `rm`/overwrite escaping the
5
+ // project, NOT a determined exfiltration. Other platforms run UNSANDBOXED (a one-time warning is
6
+ // emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
7
+ // shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
4
8
  import { spawn } from "node:child_process";
5
9
  import { writeFileSync, mkdtempSync } from "node:fs";
6
10
  import { tmpdir, platform } from "node:os";
@@ -23,6 +27,7 @@ function seatbeltProfile(cwd, mode) {
23
27
  export function sandboxSupported() {
24
28
  return platform() === "darwin";
25
29
  }
30
+ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per process
26
31
  /**
27
32
  * Run a shell command, sandboxed when mode != off and the platform supports it.
28
33
  * Streams output via `opts.onData` while capturing it for the resolved value.
@@ -39,6 +44,10 @@ export function runShell(command, cwd, mode, opts) {
39
44
  args = ["-f", profileFile, "/bin/bash", "-lc", command];
40
45
  }
41
46
  else {
47
+ if (mode !== "off" && !warnedUnsandboxed) {
48
+ warnedUnsandboxed = true;
49
+ process.stderr.write(`hara: --sandbox ${mode} is macOS-only — the shell runs UNSANDBOXED on ${platform()}.\n`);
50
+ }
42
51
  cmd = "/bin/sh";
43
52
  args = ["-c", command];
44
53
  }
@@ -47,20 +56,31 @@ export function runShell(command, cwd, mode, opts) {
47
56
  let stdout = "";
48
57
  let stderr = "";
49
58
  let timedOut = false;
59
+ let killedForSize = false;
50
60
  const grow = (cur, add) => (cur.length < opts.maxBuffer ? cur + add : cur);
51
61
  const timer = setTimeout(() => {
52
62
  timedOut = true;
53
63
  child.kill("SIGKILL");
54
64
  }, opts.timeout);
65
+ // Kill a runaway command once its total output passes maxBuffer — don't let it stream GBs to the UI
66
+ // until the timeout just because we stopped retaining the bytes.
67
+ const checkOverflow = () => {
68
+ if (!killedForSize && stdout.length + stderr.length >= opts.maxBuffer) {
69
+ killedForSize = true;
70
+ child.kill("SIGKILL");
71
+ }
72
+ };
55
73
  child.stdout.on("data", (d) => {
56
74
  const s = d.toString();
57
75
  stdout = grow(stdout, s);
58
76
  opts.onData?.(s);
77
+ checkOverflow();
59
78
  });
60
79
  child.stderr.on("data", (d) => {
61
80
  const s = d.toString();
62
81
  stderr = grow(stderr, s);
63
82
  opts.onData?.(s);
83
+ checkOverflow();
64
84
  });
65
85
  child.on("error", (e) => {
66
86
  clearTimeout(timer);
@@ -68,6 +88,8 @@ export function runShell(command, cwd, mode, opts) {
68
88
  });
69
89
  child.on("close", (code) => {
70
90
  clearTimeout(timer);
91
+ if (killedForSize)
92
+ return resolve({ stdout, stderr: stderr + `\n[output truncated — exceeded ${opts.maxBuffer} bytes; process killed]` });
71
93
  if (timedOut)
72
94
  return reject(Object.assign(new Error(`timed out after ${opts.timeout}ms`), { stdout, stderr }));
73
95
  if (code !== 0)
@@ -121,6 +121,13 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
121
121
  export function indexExists(name, cwd) {
122
122
  return existsSync(indexPath(name, cwd));
123
123
  }
124
+ // Never embed (and POST to an embedding provider, then persist in the index) a secret-bearing file —
125
+ // the asset/skill/memory dirs aren't .gitignore-filtered, so a stray credentials.json/secrets.yaml/.env
126
+ // there would otherwise leak. Defense-in-depth (the repo walk already respects .gitignore).
127
+ const SECRET_FILE = /(^\.?env(\.|$)|secret|credential|password|apikey|api[_-]?key|\btoken|\.(pem|key|p12|pfx|keystore|crt)$|^\.netrc$|^\.npmrc$|id_(rsa|ed25519|ecdsa))/i;
128
+ function looksSecret(rel) {
129
+ return SECRET_FILE.test(rel.split("/").pop() ?? rel);
130
+ }
124
131
  /** Walk one knowledge directory (code-assets / skills / memory) and chunk its files. Files come back as
125
132
  * absolute paths so recall/memory_search can read or open them directly. */
126
133
  export function collectDirChunks(dir, source) {
@@ -128,7 +135,7 @@ export function collectDirChunks(dir, source) {
128
135
  return [];
129
136
  const chunks = [];
130
137
  for (const rel of walkFiles(dir)) {
131
- if (!CODE_RE.test(rel))
138
+ if (!CODE_RE.test(rel) || looksSecret(rel))
132
139
  continue;
133
140
  const abs = join(dir, rel);
134
141
  if (fileSize(abs) > 200_000)
@@ -150,7 +157,7 @@ export function collectDirChunks(dir, source) {
150
157
  export function collectRepoChunks(root) {
151
158
  const chunks = [];
152
159
  for (const rel of listProjectFiles(root)) {
153
- if (!CODE_RE.test(rel))
160
+ if (!CODE_RE.test(rel) || looksSecret(rel))
154
161
  continue;
155
162
  const abs = join(root, rel);
156
163
  if (fileSize(abs) > 200_000)
@@ -42,6 +42,8 @@ export function cleanSessionName(raw) {
42
42
  * (unlike the ASCII-slug `cleanSessionName`), trims code/whitespace, caps length. Empty for blank input
43
43
  * (callers fall back to the short id, never "new session"). */
44
44
  export function deriveTitle(text) {
45
+ if (typeof text !== "string")
46
+ return ""; // a malformed/hand-edited session may have a non-string content
45
47
  const t = text
46
48
  .replace(/^\/\S+\s*/, "") // drop a leading slash-command
47
49
  .replace(/```[\s\S]*?```/g, " ") // drop fenced code blocks
@@ -75,12 +77,18 @@ export function saveSession(meta, history) {
75
77
  const data = { meta, history };
76
78
  writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
77
79
  }
80
+ /** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
81
+ function isSessionData(d) {
82
+ const o = d;
83
+ return !!o && typeof o === "object" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
84
+ }
78
85
  export function loadSession(id) {
79
86
  const p = sessionFile(id);
80
87
  if (!existsSync(p))
81
88
  return null;
82
89
  try {
83
- return JSON.parse(readFileSync(p, "utf8"));
90
+ const d = JSON.parse(readFileSync(p, "utf8"));
91
+ return isSessionData(d) ? d : null; // a corrupt / hand-edited file resumes as "no session" instead of crashing
84
92
  }
85
93
  catch {
86
94
  return null;
@@ -93,7 +101,9 @@ export function listSessions(cwd) {
93
101
  if (!f.endsWith(".json"))
94
102
  continue;
95
103
  try {
96
- metas.push(JSON.parse(readFileSync(join(sessionsDir(), f), "utf8")).meta);
104
+ const d = JSON.parse(readFileSync(join(sessionsDir(), f), "utf8"));
105
+ if (d?.meta && typeof d.meta === "object" && d.meta.id && d.meta.updatedAt)
106
+ metas.push(d.meta); // skip metaless/corrupt
97
107
  }
98
108
  catch {
99
109
  /* skip corrupt */
@@ -12,15 +12,20 @@ import { registerTool } from "./registry.js";
12
12
  import { loadConfig } from "../config.js";
13
13
  const RANK = { off: 0, read: 1, click: 2, full: 3 };
14
14
  const ACTION_MIN = { screenshot: "read", find: "read", activate: "click", move: "click", click: "click", type: "full", key: "full" };
15
- // dangerous combos refused even at full tier (quit / close / delete / task-switch-kill)
15
+ // dangerous combos refused even at full tier (quit / close / delete / task-switch-kill).
16
16
  const KEY_BLOCK = /(?:\b(cmd|command|ctrl|control|alt|option|win|super|meta)\b.*\+.*\b(q|w|delete|del|f4|escape|esc)\b)|ctrl\+alt\+(?:delete|del|backspace)/i;
17
+ // Windows SendKeys spells modifiers as % (Alt) / ^ (Ctrl) with no modifier WORD, so the combo regex
18
+ // above misses them: block Alt+F4 / Ctrl+F4 (close window) and Ctrl+W (close tab) in that syntax.
19
+ const KEY_BLOCK_SENDKEYS = /[%^]\s*\{\s*f4\s*\}|\^\s*w\b/i;
20
+ // Linux/X keysyms for logout / power-off (xdotool key XF86LogOff …) — not modifier combos.
21
+ const KEY_BLOCK_KEYSYM = /\bxf86(logoff|poweroff|reboot|sleep)\b/i;
17
22
  /** Whether the configured tier permits the action. Exported for tests. */
18
23
  export function actionAllowed(tier, action) {
19
24
  return RANK[tier] >= RANK[ACTION_MIN[action] ?? "full"];
20
25
  }
21
26
  /** Whether a key combo is on the dangerous blocklist. Exported for tests. */
22
27
  export function keyIsBlocked(keys) {
23
- return KEY_BLOCK.test(keys);
28
+ return KEY_BLOCK.test(keys) || KEY_BLOCK_SENDKEYS.test(keys) || KEY_BLOCK_KEYSYM.test(keys);
24
29
  }
25
30
  // Circuit breaker (learned from codex): bound consecutive screen-control failures so the agent can't loop
26
31
  // forever on a broken setup. Reset on any success; after FAIL_LIMIT in a row, return a clear stop + how to fix.
@@ -315,7 +320,7 @@ registerTool({
315
320
  const app = String(input.app ?? input.target ?? "");
316
321
  if (!app)
317
322
  return "activate needs an `app` name (e.g. 'WeChat').";
318
- if (!cfg.computerApps.some((a) => app.toLowerCase().includes(a.toLowerCase()) || a.toLowerCase().includes(app.toLowerCase())))
323
+ if (!cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase()))
319
324
  return `Refused: "${app}" isn't in your allowlist (${cfg.computerApps.join(", ") || "empty"}). Add it: \`hara config set computerApps "${app}"\`.`;
320
325
  const r = activateApp(app);
321
326
  return r.ok ? ok(`✓ ${r.msg} — now screenshot/find/click to act on it`) : fail(r.msg);
@@ -325,7 +330,7 @@ registerTool({
325
330
  if (!cfg.computerApps.length)
326
331
  return "No apps allowlisted — set `hara config set computerApps \"App Name, …\"` before clicking/typing.";
327
332
  const app = frontmostApp();
328
- const allowed = cfg.computerApps.some((a) => app.toLowerCase().includes(a.toLowerCase()) || a.toLowerCase().includes(app.toLowerCase()));
333
+ const allowed = cfg.computerApps.some((a) => a.toLowerCase() === app.toLowerCase());
329
334
  if (!allowed)
330
335
  return `Refused: frontmost app "${app || "unknown"}" isn't in your allowlist (${cfg.computerApps.join(", ")}). Switch to an allowed app or update computerApps.`;
331
336
  }