@nanhara/hara 0.122.0 → 0.122.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
package/dist/config.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join, dirname, resolve } from "node:path";
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs";
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { ensurePrivateHaraState } from "./security/private-state.js";
5
+ import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
6
+ import { projectRepositoryTrustedAtStartup } from "./security/project-trust.js";
4
7
  const PROVIDER_DEFAULTS = {
5
8
  anthropic: { model: "claude-opus-4-8", envKey: "ANTHROPIC_API_KEY" },
6
9
  qwen: {
@@ -35,6 +38,65 @@ export const REASONING_EFFORTS = ["off", "low", "medium", "high", "max"];
35
38
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
36
39
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
37
40
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
41
+ const MAX_PROJECT_CONFIG_BYTES = 256 * 1024;
42
+ const KNOWN_CONFIG_KEYS = new Set([
43
+ ...CONFIG_KEYS,
44
+ "hooks", "mcpServers", "modelVision", "overlays", "profiles",
45
+ ]);
46
+ /** Deliberately narrow: these keys change presentation/model preference, but cannot redirect credentials,
47
+ * execute code, grant tools more authority, or disable a safety layer. Everything else requires a launch-
48
+ * time trust decision so cloning/chdir into a repository never silently changes the process trust boundary. */
49
+ const SAFE_PROJECT_CONFIG_KEYS = new Set(["model", "theme", "vimMode", "autoCompact", "reasoningEffort"]);
50
+ const projectConfigWarnings = new Set();
51
+ function printableConfigKeys(keys) {
52
+ // A repository controls JSON property names too. Only schema names are safe diagnostics; an unknown key
53
+ // may itself contain a copied token and must never be treated as printable metadata.
54
+ const safe = [...new Set(keys.map((key) => (KNOWN_CONFIG_KEYS.has(key) ? key : "<unknown-key>")))].sort();
55
+ const shown = safe.slice(0, 32);
56
+ return `${shown.join(", ")}${safe.length > shown.length ? `, … (+${safe.length - shown.length})` : ""}`;
57
+ }
58
+ function warnProjectConfig(kind, message) {
59
+ if (projectConfigWarnings.has(kind))
60
+ return;
61
+ projectConfigWarnings.add(kind);
62
+ try {
63
+ process.stderr.write(`hara: ${message}\n`);
64
+ }
65
+ catch { /* best effort */ }
66
+ }
67
+ function validSafeProjectValue(key, value) {
68
+ if (key === "model")
69
+ return typeof value === "string" && value.trim().length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
70
+ if (key === "theme")
71
+ return value === "dark" || value === "light";
72
+ if (key === "reasoningEffort")
73
+ return REASONING_EFFORTS.includes(value);
74
+ if (key === "vimMode" || key === "autoCompact")
75
+ return typeof value === "boolean" || value === "true" || value === "false";
76
+ return false;
77
+ }
78
+ function filterProjectConfig(input) {
79
+ const blocked = Object.keys(input).filter((key) => !SAFE_PROJECT_CONFIG_KEYS.has(key));
80
+ if (projectRepositoryTrustedAtStartup()) {
81
+ if (blocked.length) {
82
+ const names = printableConfigKeys(blocked);
83
+ warnProjectConfig(`trusted:${names}`, `trusted project config enabled for privileged key(s): ${names}.`);
84
+ }
85
+ return input;
86
+ }
87
+ if (blocked.length) {
88
+ const names = printableConfigKeys(blocked);
89
+ warnProjectConfig(`ignored:${names}`, `ignored untrusted project config key(s): ${names}. Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust.`);
90
+ }
91
+ const invalid = Object.entries(input)
92
+ .filter(([key, value]) => SAFE_PROJECT_CONFIG_KEYS.has(key) && !validSafeProjectValue(key, value))
93
+ .map(([key]) => key);
94
+ if (invalid.length) {
95
+ const names = printableConfigKeys(invalid);
96
+ warnProjectConfig(`invalid-safe:${names}`, `ignored invalid project config value(s) for key(s): ${names}.`);
97
+ }
98
+ return Object.fromEntries(Object.entries(input).filter(([key, value]) => (SAFE_PROJECT_CONFIG_KEYS.has(key) && validSafeProjectValue(key, value))));
99
+ }
38
100
  export function configPath() {
39
101
  return join(homedir(), ".hara", "config.json");
40
102
  }
@@ -42,6 +104,7 @@ function configRecord(value) {
42
104
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
43
105
  }
44
106
  export function readRawConfig() {
107
+ ensurePrivateHaraState();
45
108
  const p = configPath();
46
109
  if (!existsSync(p))
47
110
  return {};
@@ -80,17 +143,78 @@ function nonBlankEnv(value) {
80
143
  const trimmed = value?.trim();
81
144
  return trimmed || undefined;
82
145
  }
83
- /** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
146
+ function projectConfigReadFailure(kind) {
147
+ warnProjectConfig(`unsafe-file:${kind}`, `ignored an unsafe project .hara/config.json (${kind}); no project values were loaded.`);
148
+ return {};
149
+ }
150
+ /** Nearest project override `.hara/config.json`, searching a canonical cwd up to the repo root. Project
151
+ * configuration is repository input, not private Hara state: its `.hara` parent and final entry must remain
152
+ * ordinary single-link filesystem objects while a bounded O_NOFOLLOW descriptor is read. */
84
153
  function readProjectConfig(cwd) {
85
- let dir = resolve(cwd);
154
+ let dir;
155
+ try {
156
+ dir = realpathSync.native(resolve(cwd));
157
+ }
158
+ catch {
159
+ dir = resolve(cwd);
160
+ }
86
161
  for (;;) {
87
- const p = join(dir, ".hara", "config.json");
88
- if (existsSync(p)) {
89
- try {
90
- return configRecord(JSON.parse(readFileSync(p, "utf8")));
91
- }
92
- catch {
93
- return {};
162
+ const hara = join(dir, ".hara");
163
+ const p = join(hara, "config.json");
164
+ let haraInfo;
165
+ try {
166
+ haraInfo = lstatSync(hara);
167
+ }
168
+ catch (error) {
169
+ if (error?.code !== "ENOENT")
170
+ return projectConfigReadFailure("unreadable parent");
171
+ }
172
+ if (haraInfo) {
173
+ if (haraInfo.isSymbolicLink())
174
+ return projectConfigReadFailure("symlink parent");
175
+ if (haraInfo.isDirectory()) {
176
+ let fileInfo;
177
+ try {
178
+ const canonicalParent = realpathSync.native(hara);
179
+ if (canonicalParent !== hara)
180
+ return projectConfigReadFailure("non-canonical parent");
181
+ fileInfo = lstatSync(p);
182
+ }
183
+ catch (error) {
184
+ if (error?.code !== "ENOENT")
185
+ return projectConfigReadFailure("unreadable file");
186
+ }
187
+ if (fileInfo) {
188
+ if (fileInfo.isSymbolicLink())
189
+ return projectConfigReadFailure("symlink file");
190
+ if (!fileInfo.isFile())
191
+ return projectConfigReadFailure("non-regular file");
192
+ try {
193
+ const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_PROJECT_CONFIG_BYTES, {
194
+ action: "read project config",
195
+ protectSensitive: false,
196
+ rejectHardLinks: true,
197
+ });
198
+ const parentAfter = lstatSync(hara);
199
+ if (!parentAfter.isDirectory()
200
+ || parentAfter.isSymbolicLink()
201
+ || parentAfter.dev !== haraInfo.dev
202
+ || parentAfter.ino !== haraInfo.ino
203
+ || realpathSync.native(hara) !== hara)
204
+ return projectConfigReadFailure("changed parent");
205
+ return filterProjectConfig(configRecord(JSON.parse(snapshot.text)));
206
+ }
207
+ catch (error) {
208
+ if (error?.code === "HARA_HARD_LINKED_FILE")
209
+ return projectConfigReadFailure("hard-linked file");
210
+ if (error?.code === "HARA_FILE_TOO_LARGE")
211
+ return projectConfigReadFailure("oversized file");
212
+ if (/changed while (?:opening|reading)|File changed/i.test(error?.message ?? "")) {
213
+ return projectConfigReadFailure("changed file");
214
+ }
215
+ return projectConfigReadFailure("invalid file");
216
+ }
217
+ }
94
218
  }
95
219
  }
96
220
  if (PROJECT_ROOT_MARKERS.some((m) => existsSync(join(dir, m))))
@@ -104,7 +228,12 @@ function readProjectConfig(cwd) {
104
228
  }
105
229
  /** Write the config 0600 (it can hold `apiKey`) + tighten an existing file. */
106
230
  function persistConfig(p, cfg) {
107
- mkdirSync(dirname(p), { recursive: true });
231
+ ensurePrivateHaraState();
232
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
233
+ try {
234
+ chmodSync(dirname(p), 0o700);
235
+ }
236
+ catch { /* best effort */ }
108
237
  writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
109
238
  try {
110
239
  chmodSync(p, 0o600);
@@ -132,7 +261,7 @@ export function setModelVisionOverride(model, cap) {
132
261
  persistConfig(p, cfg);
133
262
  }
134
263
  /**
135
- * Effective config. Precedence (high→low): env vars > project `.hara/config.json` >
264
+ * Effective config. Precedence (high→low): env vars > allowed/trusted project `.hara/config.json` >
136
265
  * named overlay (`overlays.<name>` in global config) > global `~/.hara/config.json`
137
266
  * > provider defaults.
138
267
  *
@@ -1,10 +1,36 @@
1
1
  // Project-context loading (AGENTS.md) — the cross-tool standard read by Codex/Claude Code/OpenClaw.
2
2
  // Walks up from cwd to the project root, concatenates AGENTS.md files, caps total size.
3
- import { readFileSync, existsSync } from "node:fs";
3
+ import { existsSync } from "node:fs";
4
4
  import { join, dirname, resolve } from "node:path";
5
+ import { readModelContextBytePrefixSync } from "../fs-read.js";
5
6
  const FILENAMES = ["AGENTS.override.md", "AGENTS.md"];
6
7
  const ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
7
8
  const MAX_BYTES = 32 * 1024;
9
+ const SEPARATOR = "\n\n--- project-doc ---\n\n";
10
+ const TRUNCATED = "\n…[truncated to project-context budget]";
11
+ function byteLength(value) {
12
+ return Buffer.byteLength(value, "utf8");
13
+ }
14
+ /** Retain only whole Unicode code points whose UTF-8 encoding fits the remaining byte budget. */
15
+ function utf8Prefix(value, maxBytes) {
16
+ if (maxBytes <= 0)
17
+ return "";
18
+ if (byteLength(value) <= maxBytes)
19
+ return value;
20
+ let used = 0;
21
+ let out = "";
22
+ for (const char of value) {
23
+ const bytes = byteLength(char);
24
+ if (used + bytes > maxBytes)
25
+ break;
26
+ out += char;
27
+ used += bytes;
28
+ }
29
+ return out;
30
+ }
31
+ function markBudgetTruncation(value) {
32
+ return utf8Prefix(value, MAX_BYTES - byteLength(TRUNCATED)) + TRUNCATED;
33
+ }
8
34
  export function findProjectRoot(cwd) {
9
35
  let dir = resolve(cwd);
10
36
  for (;;) {
@@ -30,15 +56,28 @@ export function loadAgentsMd(cwd) {
30
56
  break;
31
57
  dir = parent;
32
58
  }
33
- const parts = [];
59
+ let combined = "";
34
60
  for (const d of chain) {
35
61
  for (const name of FILENAMES) {
36
62
  const p = join(d, name);
37
63
  if (existsSync(p)) {
38
64
  try {
39
- const txt = readFileSync(p, "utf8").trim();
40
- if (txt)
41
- parts.push(`<!-- ${name} @ ${d} -->\n${txt}`);
65
+ const separator = combined ? SEPARATOR : "";
66
+ const header = `<!-- ${name} @ ${d} -->\n`;
67
+ const fixedBytes = byteLength(separator + header);
68
+ const remaining = MAX_BYTES - byteLength(combined) - fixedBytes;
69
+ if (remaining <= byteLength(TRUNCATED))
70
+ return markBudgetTruncation(combined);
71
+ const read = readModelContextBytePrefixSync(p, remaining);
72
+ if (read.binary)
73
+ continue;
74
+ let txt = read.text.trim();
75
+ if (!txt)
76
+ continue;
77
+ if (read.truncated) {
78
+ txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
79
+ }
80
+ combined += separator + header + utf8Prefix(txt, remaining);
42
81
  }
43
82
  catch {
44
83
  /* ignore unreadable */
@@ -46,10 +85,6 @@ export function loadAgentsMd(cwd) {
46
85
  }
47
86
  }
48
87
  }
49
- let combined = parts.join("\n\n--- project-doc ---\n\n");
50
- if (Buffer.byteLength(combined, "utf8") > MAX_BYTES) {
51
- combined = Buffer.from(combined, "utf8").subarray(0, MAX_BYTES).toString("utf8") + "\n…[truncated]";
52
- }
53
88
  return combined;
54
89
  }
55
90
  export function hasAgentsMd(cwd) {
@@ -1,11 +1,12 @@
1
1
  // @file mentions — expand `@path` references in user input into appended file contents,
2
2
  // and provide fuzzy file candidates for REPL tab-completion.
3
- import { existsSync, statSync } from "node:fs";
3
+ import { existsSync, lstatSync } from "node:fs";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
  import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
6
6
  import { fuzzyRank } from "../fuzzy.js";
7
7
  import { mediaTypeFor } from "../images.js";
8
- import { readTextPrefixSync } from "../fs-read.js";
8
+ import { readModelContextPrefixSync } from "../fs-read.js";
9
+ import { sensitiveFileError } from "../security/sensitive-files.js";
9
10
  const MAX_FILE = 50_000;
10
11
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
11
12
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
@@ -46,15 +47,20 @@ export function expandMentions(input, cwd) {
46
47
  /** Render one mention as an inline block, or null if it isn't a readable file/dir. */
47
48
  function expandRef(ref, cwd) {
48
49
  const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
50
+ const denied = sensitiveFileError(abs, "attach");
51
+ if (denied)
52
+ return `\nProtected file \`${ref}\` was not inserted into model context. ${denied}\n`;
49
53
  try {
50
54
  if (!existsSync(abs))
51
55
  return null;
52
- const st = statSync(abs);
56
+ const st = lstatSync(abs);
57
+ if (st.isSymbolicLink())
58
+ return `Referenced \`${ref}\` is a symbolic link — it was not inserted into model context.`;
53
59
  if (st.isFile()) {
54
60
  // don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
55
61
  if (mediaTypeFor(abs))
56
62
  return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
57
- const prefix = readTextPrefixSync(abs, MAX_FILE);
63
+ const prefix = readModelContextPrefixSync(abs, MAX_FILE);
58
64
  if (prefix.binary)
59
65
  return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
60
66
  const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
@@ -2,12 +2,36 @@
2
2
  // AGENTS.md / CLAUDE.md (the local conventions for that package) by appending it to the tool result, so a
3
3
  // monorepo's per-package rules reach the model exactly when work moves into that package. Startup already
4
4
  // loads root→cwd (agents-md.ts); this covers the subdirs the agent navigates INTO. Each dir loaded once.
5
- import { readFileSync, existsSync, statSync } from "node:fs";
5
+ import { existsSync, statSync } from "node:fs";
6
6
  import { join, dirname, resolve, relative, isAbsolute } from "node:path";
7
7
  import { findProjectRoot } from "./agents-md.js";
8
+ import { readModelContextBytePrefixSync } from "../fs-read.js";
8
9
  const FILENAMES = ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"];
9
10
  const MAX = 8 * 1024;
11
+ const TRUNCATED = "\n…[truncated to subdirectory-context budget]";
10
12
  const loaded = new Set(); // dirs whose local doc we've already injected (per process / session)
13
+ function byteLength(value) {
14
+ return Buffer.byteLength(value, "utf8");
15
+ }
16
+ function utf8Prefix(value, maxBytes) {
17
+ if (maxBytes <= 0)
18
+ return "";
19
+ if (byteLength(value) <= maxBytes)
20
+ return value;
21
+ let used = 0;
22
+ let out = "";
23
+ for (const char of value) {
24
+ const bytes = byteLength(char);
25
+ if (used + bytes > maxBytes)
26
+ break;
27
+ out += char;
28
+ used += bytes;
29
+ }
30
+ return out;
31
+ }
32
+ function markBudgetTruncation(value) {
33
+ return utf8Prefix(value, MAX - byteLength(TRUNCATED)) + TRUNCATED;
34
+ }
11
35
  function isDir(p) {
12
36
  try {
13
37
  return statSync(p).isDirectory();
@@ -31,7 +55,7 @@ function pathsFrom(input) {
31
55
  export function subdirHint(input, cwd) {
32
56
  const base = resolve(cwd);
33
57
  const root = findProjectRoot(cwd);
34
- const parts = [];
58
+ let hint = "";
35
59
  for (const raw of pathsFrom(input)) {
36
60
  const absPath = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
37
61
  const startDir = isDir(absPath) ? absPath : dirname(absPath);
@@ -58,12 +82,21 @@ export function subdirHint(input, cwd) {
58
82
  if (!existsSync(fp))
59
83
  continue;
60
84
  try {
61
- let txt = readFileSync(fp, "utf8").trim();
85
+ const separator = "\n\n";
86
+ const header = `<!-- ${name} @ ${cd} — local conventions for this directory -->\n`;
87
+ const remaining = MAX - byteLength(hint) - byteLength(separator + header);
88
+ if (remaining <= byteLength(TRUNCATED))
89
+ return markBudgetTruncation(hint);
90
+ const read = readModelContextBytePrefixSync(fp, remaining);
91
+ if (read.binary)
92
+ break;
93
+ let txt = read.text.trim();
62
94
  if (!txt)
63
95
  break;
64
- if (Buffer.byteLength(txt, "utf8") > MAX)
65
- txt = txt.slice(0, MAX) + "\n…[truncated]";
66
- parts.push(`<!-- ${name} @ ${cd} — local conventions for this directory -->\n${txt}`);
96
+ if (read.truncated) {
97
+ txt = utf8Prefix(txt, Math.max(0, remaining - byteLength(TRUNCATED))) + TRUNCATED;
98
+ }
99
+ hint += separator + header + utf8Prefix(txt, remaining);
67
100
  }
68
101
  catch {
69
102
  /* ignore unreadable */
@@ -72,5 +105,5 @@ export function subdirHint(input, cwd) {
72
105
  }
73
106
  }
74
107
  }
75
- return parts.length ? "\n\n" + parts.join("\n\n") : "";
108
+ return hint;
76
109
  }