@nanhara/hara 0.53.0 → 0.62.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,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)
@@ -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/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
  }
@@ -102,21 +102,40 @@ registerTool({
102
102
  }
103
103
  }
104
104
  }
105
- // PHASE 2 — commit all changes + show each diff.
106
- const summary = [];
107
- for (const pl of plans) {
108
- if (pl.type === "delete") {
109
- await unlink(pl.abs);
110
- emitDiff(pl.path, pl.before, "", ctx.ui);
111
- summary.push(`deleted ${pl.path}`);
105
+ // PHASE 2 — commit all changes. Truly all-or-nothing: if any write fails mid-way, roll back the ones
106
+ // already applied (restore updated/deleted files, remove created ones) so the tree is never half-patched.
107
+ const applied = [];
108
+ try {
109
+ for (const pl of plans) {
110
+ if (pl.type === "delete") {
111
+ await unlink(pl.abs);
112
+ }
113
+ else {
114
+ await mkdir(dirname(pl.abs), { recursive: true });
115
+ await writeFile(pl.abs, pl.after, "utf8");
116
+ }
117
+ applied.push(pl);
112
118
  }
113
- else {
114
- await mkdir(dirname(pl.abs), { recursive: true });
115
- await writeFile(pl.abs, pl.after, "utf8");
116
- emitDiff(pl.path, pl.before, pl.after, ctx.ui);
117
- summary.push(`${pl.type === "create" ? "created" : "updated"} ${pl.path}`);
119
+ }
120
+ catch (e) {
121
+ for (const pl of applied.reverse()) {
122
+ try {
123
+ if (pl.type === "create" && !pl.existed)
124
+ await unlink(pl.abs); // remove a file we created
125
+ else
126
+ await writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
127
+ }
128
+ catch {
129
+ /* best-effort rollback */
130
+ }
118
131
  }
132
+ return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
119
133
  }
134
+ // All writes succeeded → now show diffs + record the undo snapshot.
135
+ const summary = plans.map((pl) => {
136
+ emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
137
+ return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
138
+ });
120
139
  recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
121
140
  return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
122
141
  },
package/dist/tools/web.js CHANGED
@@ -1,7 +1,67 @@
1
1
  // web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
2
- // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash).
2
+ // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash)
3
+ // so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
4
+ // redirect hop, and the body is read under a hard byte ceiling.
3
5
  import { registerTool } from "./registry.js";
6
+ import { lookup } from "node:dns/promises";
7
+ import { isIP } from "node:net";
4
8
  const MAX = 60_000;
9
+ /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
10
+ export function isPrivateIp(ip) {
11
+ const host = ip.replace(/^\[|\]$/g, "");
12
+ if (isIP(host) === 4) {
13
+ const p = host.split(".").map(Number);
14
+ return p[0] === 0 || p[0] === 10 || p[0] === 127 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127);
15
+ }
16
+ const l = host.toLowerCase();
17
+ if (l === "::1" || l === "::")
18
+ return true;
19
+ if (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
20
+ return true; // link-local + unique-local
21
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
22
+ return m ? isPrivateIp(m[1]) : false;
23
+ }
24
+ /** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
25
+ * / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
26
+ async function assertPublicHost(hostname) {
27
+ const host = hostname.replace(/^\[|\]$/g, "");
28
+ if (isIP(host)) {
29
+ if (isPrivateIp(host))
30
+ throw new Error(`refusing to fetch ${host} (private/loopback address)`);
31
+ return;
32
+ }
33
+ const addrs = await lookup(host, { all: true });
34
+ for (const a of addrs)
35
+ if (isPrivateIp(a.address))
36
+ throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
37
+ }
38
+ /** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
39
+ async function readCapped(res, maxBytes) {
40
+ if (!res.body)
41
+ return res.text();
42
+ const reader = res.body.getReader();
43
+ const chunks = [];
44
+ let total = 0;
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done)
48
+ break;
49
+ if (value) {
50
+ chunks.push(value);
51
+ total += value.length;
52
+ }
53
+ if (total >= maxBytes) {
54
+ try {
55
+ await reader.cancel();
56
+ }
57
+ catch {
58
+ /* already closing */
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
5
65
  /** Strip HTML to a readable-ish plain-text approximation (no dependency). */
6
66
  export function htmlToText(html) {
7
67
  return html
@@ -148,17 +208,30 @@ registerTool({
148
208
  const ctrl = new AbortController();
149
209
  const timer = setTimeout(() => ctrl.abort(), 30_000);
150
210
  try {
151
- const res = await fetch(url, {
152
- signal: ctrl.signal,
153
- redirect: "follow",
154
- headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
155
- });
211
+ // Follow redirects manually so the SSRF guard runs on EVERY hop (a public URL can 30x to 169.254…).
212
+ let current = url;
213
+ let res;
214
+ for (let hop = 0;; hop++) {
215
+ await assertPublicHost(current.hostname);
216
+ res = await fetch(current, {
217
+ signal: ctrl.signal,
218
+ redirect: "manual",
219
+ headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
220
+ });
221
+ const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
222
+ if (!loc || hop >= 5)
223
+ break;
224
+ const next = new URL(loc, current);
225
+ if (next.protocol !== "http:" && next.protocol !== "https:")
226
+ return "Error: redirect to a non-http(s) URL was blocked.";
227
+ current = next;
228
+ }
156
229
  const ct = res.headers.get("content-type") ?? "";
157
- const raw = await res.text();
230
+ const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
158
231
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
159
232
  if (text.length > cap)
160
233
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
161
- return `# ${url.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
234
+ return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
162
235
  }
163
236
  catch (e) {
164
237
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
package/dist/tui/App.js CHANGED
@@ -62,7 +62,7 @@ function Working() {
62
62
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
63
63
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
64
64
  }
65
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
65
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
66
66
  const { exit } = useApp();
67
67
  const [history, setHistory] = useState([]);
68
68
  const [current, setCurrent] = useState([]);
@@ -209,5 +209,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
209
209
  else if (key.tab && key.shift && cycleApproval)
210
210
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
211
211
  });
212
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
212
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
213
213
  }
@@ -7,6 +7,7 @@ import { Box, Text, useInput, useStdout } from "ink";
7
7
  import { useMemo, useState } from "react";
8
8
  import { fileCandidates } from "../context/mentions.js";
9
9
  import { imagePathFromPaste } from "../images.js";
10
+ import { vimNormal } from "./vim.js";
10
11
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
11
12
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
12
13
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
@@ -84,7 +85,7 @@ function InputLine({ value, cursor }) {
84
85
  return _jsx(Text, { children: nodes });
85
86
  }
86
87
  /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
87
- export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
+ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
89
  const { stdout } = useStdout();
89
90
  const w = width ?? stdout?.columns ?? 80;
90
91
  const [value, setValue] = useState("");
@@ -92,6 +93,9 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
92
93
  const [sel, setSel] = useState(0);
93
94
  const [dismissed, setDismissed] = useState(false);
94
95
  const [images, setImages] = useState([]);
96
+ const [mode, setMode] = useState("insert"); // vim only
97
+ const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
98
+ const [register, setRegister] = useState(""); // vim yank/delete register
95
99
  const set = (v, c) => {
96
100
  setValue(v);
97
101
  setCursor(c);
@@ -116,6 +120,8 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
116
120
  onSubmit?.(text, images.length ? images : undefined);
117
121
  set("", 0);
118
122
  setImages([]);
123
+ setMode("insert"); // a fresh prompt starts in insert
124
+ setPending("");
119
125
  };
120
126
  const mention = activeMention(value, cursor);
121
127
  const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
@@ -143,8 +149,36 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
143
149
  return;
144
150
  }
145
151
  if (key.escape) {
146
- if (popupOpen)
152
+ if (popupOpen) {
147
153
  setDismissed(true);
154
+ return;
155
+ }
156
+ if (vim && mode === "insert") {
157
+ setMode("normal");
158
+ setPending("");
159
+ }
160
+ return;
161
+ }
162
+ // vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
163
+ if (vim && mode === "normal") {
164
+ if (key.return)
165
+ return submit(value);
166
+ if (key.leftArrow)
167
+ return setCursor((c) => Math.max(0, c - 1));
168
+ if (key.rightArrow)
169
+ return setCursor((c) => Math.min(value.length, c + 1));
170
+ if (key.backspace || key.delete)
171
+ return setCursor((c) => Math.max(0, c - 1));
172
+ if (input && !key.ctrl && !key.meta) {
173
+ const st = vimNormal({ value, cursor, mode, pending, register }, input);
174
+ setValue(st.value);
175
+ setCursor(st.cursor);
176
+ setMode(st.mode);
177
+ setPending(st.pending);
178
+ setRegister(st.register);
179
+ setSel(0);
180
+ setDismissed(false);
181
+ }
148
182
  return;
149
183
  }
150
184
  if (key.return) {
@@ -204,5 +238,5 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
204
238
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
205
239
  }
206
240
  }, { isActive });
207
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
241
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
208
242
  }