@nanhara/hara 0.70.0 → 0.89.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,211 @@
1
+ // Fine-grained, command-level permission policy for the `bash` tool — the layer that turns "confirm every
2
+ // command" / "full-auto, unguarded" into a personal allow/ask/deny policy. Pure-Node, zero-dep. This is
3
+ // governance-as-config (hara's moat): it composes with approval modes rather than replacing them.
4
+ //
5
+ // Rules live in ~/.hara/permissions.json (+ a project .hara/permissions.json, deny-wins on merge):
6
+ // { "allow": ["npm test", "git commit", "npm run *"], "deny": ["git push", "rm -rf", "sudo"],
7
+ // "readonlyAutorun": true }
8
+ // A pattern matches a command if the canonical command equals it, starts with "<pattern> ", or glob-matches
9
+ // (with `*`). The decision for a compound command (&&, ||, ;, |) is the STRICTEST of its parts
10
+ // (deny > ask > allow); anything we can't safely parse fails CLOSED to "ask".
11
+ import { homedir } from "node:os";
12
+ import { join, dirname, resolve } from "node:path";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
14
+ const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
15
+ // Programs that are read-only when run as a plain command (no dangerous flags / redirection — see below).
16
+ const READONLY_PROGRAMS = new Set([
17
+ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "printf", "grep", "rg", "fd", "stat", "file", "which",
18
+ "type", "whoami", "id", "date", "tree", "du", "df", "ps", "uname", "hostname", "dirname", "basename",
19
+ "realpath", "sort", "uniq", "cut", "column", "jq", "true", "false", "env",
20
+ ]);
21
+ // `git <sub>` is read-only only for these subcommands.
22
+ const GIT_READONLY_SUB = new Set([
23
+ "status", "log", "diff", "show", "branch", "remote", "tag", "describe", "rev-parse", "ls-files",
24
+ "ls-remote", "config", "blame", "shortlog", "cat-file", "whatchanged", "grep", "rev-list", "name-rev",
25
+ ]);
26
+ // Flags that can turn an otherwise-read-only command destructive → disqualify from autorun.
27
+ const DANGER_FLAG = /(^|\s)(-i\b|-o\b|--output\b|--exec\b|-delete\b|-fprint\b|--write\b|-w\b|--in-place\b)/;
28
+ /** Unwrap shell wrappers and strip leading env/wrappers to get the command's essential form. Lossy on
29
+ * purpose — used only for matching/classification, never for execution. */
30
+ export function canonicalize(command) {
31
+ let cmd = command.trim();
32
+ // Unwrap `bash -lc "…"` / `sh -c '…'` / `zsh -c …` (one level).
33
+ const wrap = /^(?:\/usr\/bin\/|\/bin\/)?(?:ba|z)?sh\s+-[a-z]*c\s+(['"])([\s\S]*)\1\s*$/.exec(cmd);
34
+ if (wrap)
35
+ cmd = wrap[2].trim();
36
+ // Strip leading `VAR=val` assignments (NODE_ENV=production foo …).
37
+ while (/^[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+/.test(cmd))
38
+ cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+/, "");
39
+ // Strip benign leading wrappers that don't change what's actually run.
40
+ for (;;) {
41
+ const m = /^(timeout\s+\d+[a-z]?\s+|time\s+|nice\s+(-n\s+-?\d+\s+)?|command\s+|\\)/.exec(cmd);
42
+ if (!m)
43
+ break;
44
+ cmd = cmd.slice(m[0].length).trimStart();
45
+ }
46
+ return cmd.replace(/\s+/g, " ").trim();
47
+ }
48
+ /** True if the (already-canonical, simple) command is read-only and safe to autorun. */
49
+ export function isReadOnlyCommand(canonical) {
50
+ if (!canonical)
51
+ return false;
52
+ if (/[>]/.test(canonical))
53
+ return false; // any output redirection → not read-only
54
+ if (DANGER_FLAG.test(canonical))
55
+ return false;
56
+ const parts = canonical.split(" ");
57
+ const prog = parts[0];
58
+ if (prog === "git")
59
+ return GIT_READONLY_SUB.has(parts[1] ?? "");
60
+ return READONLY_PROGRAMS.has(prog);
61
+ }
62
+ /** Split a command on top-level shell operators (&&, ||, ;, |, newline), quote-aware. Returns null if the
63
+ * command uses constructs we don't safely model (command substitution / backticks / unbalanced quotes) —
64
+ * the caller then fails closed. */
65
+ export function splitCompound(command) {
66
+ const parts = [];
67
+ let buf = "";
68
+ let sq = false, dq = false;
69
+ for (let i = 0; i < command.length; i++) {
70
+ const ch = command[i];
71
+ const next = command[i + 1];
72
+ if (sq) {
73
+ if (ch === "'")
74
+ sq = false;
75
+ buf += ch;
76
+ continue;
77
+ }
78
+ if (dq) {
79
+ if (ch === '"')
80
+ dq = false;
81
+ buf += ch;
82
+ continue;
83
+ }
84
+ if (ch === "'") {
85
+ sq = true;
86
+ buf += ch;
87
+ continue;
88
+ }
89
+ if (ch === '"') {
90
+ dq = true;
91
+ buf += ch;
92
+ continue;
93
+ }
94
+ if (ch === "`")
95
+ return null; // backtick command substitution → can't model, fail closed
96
+ if (ch === "$" && next === "(")
97
+ return null; // $(…) substitution → fail closed
98
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
99
+ parts.push(buf);
100
+ buf = "";
101
+ i++;
102
+ continue;
103
+ }
104
+ if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
105
+ parts.push(buf);
106
+ buf = "";
107
+ continue;
108
+ }
109
+ buf += ch;
110
+ }
111
+ if (sq || dq)
112
+ return null; // unbalanced quotes → fail closed
113
+ parts.push(buf);
114
+ return parts.map((p) => canonicalize(p)).filter(Boolean);
115
+ }
116
+ /** Match a canonical command against a single rule pattern: exact, prefix (pattern + space), or glob (*). */
117
+ export function matchesPattern(canonical, pattern) {
118
+ const pat = canonicalize(pattern);
119
+ if (!pat)
120
+ return false;
121
+ if (pat.includes("*")) {
122
+ const re = new RegExp("^" + pat.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
123
+ return re.test(canonical);
124
+ }
125
+ return canonical === pat || canonical.startsWith(pat + " ");
126
+ }
127
+ const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
128
+ const strictest = (ds) => ds.reduce((a, b) => (STRICTNESS[b] > STRICTNESS[a] ? b : a), "allow");
129
+ /** Decide a single simple (canonical) command against the rules. deny > allow-rule > read-only > ask. */
130
+ function decideSimple(canonical, rules) {
131
+ if (rules.deny.some((p) => matchesPattern(canonical, p)))
132
+ return "deny";
133
+ if (rules.allow.some((p) => matchesPattern(canonical, p)))
134
+ return "allow";
135
+ if (rules.readonlyAutorun && isReadOnlyCommand(canonical))
136
+ return "allow";
137
+ return "ask";
138
+ }
139
+ /** Decide a (possibly compound) shell command. Strictest part wins; unparseable → deny if any deny pattern
140
+ * hits the whole string, else "ask" (fail closed). */
141
+ export function decideCommand(command, rules) {
142
+ const canonical = canonicalize(command);
143
+ const parts = splitCompound(command);
144
+ if (!parts) {
145
+ return rules.deny.some((p) => matchesPattern(canonical, p)) ? "deny" : "ask";
146
+ }
147
+ if (!parts.length)
148
+ return "ask";
149
+ return strictest(parts.map((p) => decideSimple(p, rules)));
150
+ }
151
+ const DEFAULTS = { allow: [], deny: [], readonlyAutorun: true };
152
+ function readRules(p) {
153
+ if (!existsSync(p))
154
+ return {};
155
+ try {
156
+ const j = JSON.parse(readFileSync(p, "utf8"));
157
+ return {
158
+ allow: Array.isArray(j.allow) ? j.allow.filter((x) => typeof x === "string") : undefined,
159
+ deny: Array.isArray(j.deny) ? j.deny.filter((x) => typeof x === "string") : undefined,
160
+ readonlyAutorun: typeof j.readonlyAutorun === "boolean" ? j.readonlyAutorun : undefined,
161
+ };
162
+ }
163
+ catch {
164
+ return {};
165
+ }
166
+ }
167
+ export function globalPermissionsPath() {
168
+ return join(homedir(), ".hara", "permissions.json");
169
+ }
170
+ /** Nearest project `.hara/permissions.json`, cwd → repo root. */
171
+ export function projectPermissionsPath(cwd) {
172
+ let dir = resolve(cwd);
173
+ for (;;) {
174
+ const p = join(dir, ".hara", "permissions.json");
175
+ if (existsSync(p))
176
+ return p;
177
+ if (PROJECT_ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
178
+ break;
179
+ const parent = dirname(dir);
180
+ if (parent === dir)
181
+ break;
182
+ dir = parent;
183
+ }
184
+ return null;
185
+ }
186
+ /** Effective rules: project ∪ global, deny-wins (union of denies); project's readonlyAutorun overrides
187
+ * global's. Missing/invalid files → safe defaults (read-only autorun on, no allow/deny rules). */
188
+ export function loadPermissionRules(cwd) {
189
+ const g = readRules(globalPermissionsPath());
190
+ const projPath = projectPermissionsPath(cwd);
191
+ const p = projPath ? readRules(projPath) : {};
192
+ return {
193
+ allow: [...(g.allow ?? []), ...(p.allow ?? [])],
194
+ deny: [...(g.deny ?? []), ...(p.deny ?? [])],
195
+ readonlyAutorun: p.readonlyAutorun ?? g.readonlyAutorun ?? DEFAULTS.readonlyAutorun,
196
+ };
197
+ }
198
+ const SCAFFOLD = {
199
+ allow: ["npm test", "npm run lint", "npm run build", "git commit"],
200
+ deny: ["git push", "rm -rf", "sudo", "git reset --hard"],
201
+ readonlyAutorun: true,
202
+ };
203
+ /** Write a starter permissions.json (global by default). Returns the path, or null if one already exists. */
204
+ export function scaffoldPermissions(cwd, scope = "global") {
205
+ const p = scope === "project" ? join(resolve(cwd), ".hara", "permissions.json") : globalPermissionsPath();
206
+ if (existsSync(p))
207
+ return null;
208
+ mkdirSync(dirname(p), { recursive: true });
209
+ writeFileSync(p, JSON.stringify(SCAFFOLD, null, 2) + "\n", "utf8");
210
+ return p;
211
+ }
@@ -6,6 +6,7 @@ import { runShell } from "../sandbox.js";
6
6
  import { nearestPaths } from "../fs-walk.js";
7
7
  import { emitDiff } from "../diff.js";
8
8
  import { recordEdit } from "../undo.js";
9
+ import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
9
10
  const MAX = 100_000;
10
11
  function abs(p, cwd) {
11
12
  return isAbsolute(p) ? p : resolve(cwd, p);
@@ -13,6 +14,14 @@ function abs(p, cwd) {
13
14
  function cap(s) {
14
15
  return s.length > MAX ? s.slice(0, MAX) + `\n…[truncated ${s.length - MAX} chars]` : s;
15
16
  }
17
+ /** Truncate keeping the HEAD and the TAIL — for command output, where the start gives context but the END
18
+ * usually holds the error/result, so plain head-truncation would cut exactly the part that matters most. */
19
+ export function capHeadTail(s, max = MAX) {
20
+ if (s.length <= max)
21
+ return s;
22
+ const head = Math.floor(max * 0.6);
23
+ return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
24
+ }
16
25
  registerTool({
17
26
  name: "read_file",
18
27
  description: "Read a UTF-8 text file and return its contents.",
@@ -70,11 +79,16 @@ registerTool({
70
79
  properties: {
71
80
  command: { type: "string" },
72
81
  timeout_ms: { type: "number", description: "default 120000" },
82
+ background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); returns a job id immediately — tail/kill it with the `job` tool" },
73
83
  },
74
84
  required: ["command"],
75
85
  },
76
86
  kind: "exec",
77
87
  async run(input, ctx) {
88
+ if (input.background) {
89
+ const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
90
+ return `Started background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all.`;
91
+ }
78
92
  let buf = ""; // TUI: line-buffer live output into the sink (one notice per line)
79
93
  const live = ctx.ui
80
94
  ? (s) => {
@@ -97,10 +111,43 @@ registerTool({
97
111
  if (ctx.ui && buf)
98
112
  ctx.ui.notice(buf); // flush trailing partial line
99
113
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");
100
- return cap(combined.trim() || "(no output)");
114
+ return capHeadTail(combined.trim() || "(no output)");
101
115
  }
102
116
  catch (e) {
103
- return cap(`Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`);
117
+ return capHeadTail(`Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`);
118
+ }
119
+ },
120
+ });
121
+ registerTool({
122
+ name: "job",
123
+ description: "Manage background shell jobs (started via bash {background:true}) — dev servers, watchers, long tasks. action: list | tail | kill.",
124
+ input_schema: {
125
+ type: "object",
126
+ properties: {
127
+ action: { type: "string", enum: ["list", "tail", "kill"] },
128
+ id: { type: "string", description: "job id (required for tail/kill)" },
129
+ lines: { type: "number", description: "tail: number of trailing lines to show (default 40)" },
130
+ },
131
+ required: ["action"],
132
+ },
133
+ kind: "read", // manages only the agent's own background jobs; safe to run unconfirmed
134
+ async run(input) {
135
+ const action = String(input.action);
136
+ if (action === "list") {
137
+ const js = listJobs();
138
+ if (!js.length)
139
+ return "(no background jobs)";
140
+ return js.map((j) => `${j.id} [${j.status}${j.code != null ? " " + j.code : ""}] ${Math.round(j.ageMs / 1000)}s ${j.command}`).join("\n");
141
+ }
142
+ const id = String(input.id ?? "");
143
+ if (!id)
144
+ return "Error: `id` is required for tail/kill.";
145
+ if (action === "tail") {
146
+ const t = tailJob(id, Number(input.lines) || 40);
147
+ return t == null ? `No job ${id}.` : t.trim() || "(no output yet)";
104
148
  }
149
+ if (action === "kill")
150
+ return killJob(id) ? `Killed ${id}.` : `No running job ${id} (already exited/killed or unknown).`;
151
+ return `Error: unknown action '${action}'.`;
105
152
  },
106
153
  });
@@ -0,0 +1,35 @@
1
+ import { registerTool } from "./registry.js";
2
+ import { appendFileSync, existsSync, statSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ // `send_file` — the agent's ONLY way to push a file/image to the user when running inside `hara gateway`
5
+ // (Telegram / WeChat). The gateway sets HARA_GATEWAY_OUTBOX to a per-message temp file; this tool appends the
6
+ // path there, and after the headless run ends the daemon delivers each queued file to the chat via the platform
7
+ // adapter (images inline, everything else as an attachment). It does NOT touch the desktop client — driving the
8
+ // WeChat/desktop app with the `computer` tool targets a different surface and never reaches this chat peer.
9
+ //
10
+ // Self-gated on HARA_GATEWAY: registered only in the gateway subprocess, so it never clutters normal CLI/TUI runs.
11
+ if (process.env.HARA_GATEWAY) {
12
+ registerTool({
13
+ name: "send_file",
14
+ description: "Send a local file (image, document, audio, zip, …) to the user in the CURRENT chat. Use this whenever the " +
15
+ "user asks you to send/share/发 a file or image. Pass an absolute path to a file that already exists (generate " +
16
+ "it first if needed). Images are delivered inline; other files as attachments. This is the only channel that " +
17
+ "reaches the user — do NOT use the computer tool or desktop automation to deliver files.",
18
+ kind: "exec",
19
+ input_schema: {
20
+ type: "object",
21
+ properties: { path: { type: "string", description: "Absolute path to the file to send." } },
22
+ required: ["path"],
23
+ },
24
+ async run(input, ctx) {
25
+ const outbox = process.env.HARA_GATEWAY_OUTBOX;
26
+ if (!outbox)
27
+ return "send_file only works inside `hara gateway` — there is no chat to send to here.";
28
+ const p = resolve(ctx.cwd, String(input.path ?? ""));
29
+ if (!existsSync(p) || !statSync(p).isFile())
30
+ return `No such file: ${p || "(none)"} — create the file first, then call send_file with its absolute path.`;
31
+ appendFileSync(outbox, p + "\n");
32
+ return `Queued for delivery to the user in this chat: ${p}`;
33
+ },
34
+ });
35
+ }
package/dist/tools/web.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { registerTool } from "./registry.js";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
+ import { wrapUntrusted } from "../security/external-content.js";
8
9
  const MAX = 60_000;
9
10
  /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
10
11
  export function isPrivateIp(ip) {
@@ -150,7 +151,7 @@ registerTool({
150
151
  const j = (await res.json());
151
152
  const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
152
153
  if (rs.length)
153
- return fmt(rs);
154
+ return wrapUntrusted(fmt(rs), `web_search: ${q}`);
154
155
  }
155
156
  // Tavily failed → fall through to the keyless best-effort path.
156
157
  }
@@ -171,7 +172,7 @@ registerTool({
171
172
  const results = parseSearchResults(await res.text(), limit);
172
173
  if (!results.length)
173
174
  return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
174
- return fmt(results);
175
+ return wrapUntrusted(fmt(results), `web_search: ${q}`);
175
176
  }
176
177
  catch (e) {
177
178
  return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
@@ -231,7 +232,7 @@ registerTool({
231
232
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
232
233
  if (text.length > cap)
233
234
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
234
- return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
235
+ return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
235
236
  }
236
237
  catch (e) {
237
238
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.70.0",
3
+ "version": "0.89.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sdk": "^0.104.2",
54
+ "@larksuiteoapi/node-sdk": "^1.68.0",
54
55
  "@modelcontextprotocol/sdk": "^1.29.0",
55
56
  "commander": "^15.0.0",
56
57
  "ink": "^6.8.0",
@@ -65,6 +66,7 @@
65
66
  "typescript": "^6.0.3"
66
67
  },
67
68
  "optionalDependencies": {
68
- "@zvec/zvec": "^0.5.0"
69
+ "@zvec/zvec": "^0.5.0",
70
+ "qrcode-terminal": "^0.12.0"
69
71
  }
70
72
  }