@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
@@ -10,21 +10,58 @@
10
10
  // (deny > ask > allow); anything we can't safely parse fails CLOSED to "ask".
11
11
  import { homedir } from "node:os";
12
12
  import { join, dirname, resolve } from "node:path";
13
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
15
+ import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
16
+ import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
17
+ import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
14
18
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
19
+ const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
20
+ const projectPermissionWarnings = new Set();
15
21
  // Programs that are read-only when run as a plain command (no dangerous flags / redirection — see below).
16
22
  const READONLY_PROGRAMS = new Set([
17
- "ls", "pwd", "cat", "head", "tail", "wc", "echo", "printf", "grep", "rg", "fd", "stat", "file", "which",
23
+ "ls", "pwd", "cat", "head", "tail", "wc", "grep", "rg", "fd", "stat", "file", "which",
18
24
  "type", "whoami", "id", "date", "tree", "du", "df", "ps", "uname", "hostname", "dirname", "basename",
19
- "realpath", "sort", "uniq", "cut", "column", "jq", "true", "false", "env",
25
+ "realpath", "sort", "uniq", "cut", "column", "jq", "true", "false",
20
26
  ]);
21
27
  // `git <sub>` is read-only only for these subcommands.
22
28
  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",
29
+ "describe", "rev-parse", "ls-files", "shortlog", "rev-list", "name-rev",
25
30
  ]);
26
31
  // Flags that can turn an otherwise-read-only command destructive → disqualify from autorun.
27
32
  const DANGER_FLAG = /(^|\s)(-i\b|-o\b|--output\b|--exec\b|-delete\b|-fprint\b|--write\b|-w\b|--in-place\b)/;
33
+ function gitPatchOutputRequested(args) {
34
+ for (const arg of args) {
35
+ if (arg === "--")
36
+ break;
37
+ if (/^-[^-]*[mpucUW]/u.test(arg))
38
+ return true;
39
+ if (/^(?:--patch|--patch-with-raw|--patch-with-stat|--unified|--word-diff|--word-diff-regex|--color-words|--cc|--combined-all-paths|--diff-merges|--remerge-diff|--binary|--check|--function-context|--inter-hunk-context)(?:=|$)/u.test(arg))
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ function gitDiffIsMetadataOnly(args) {
45
+ let metadata = false;
46
+ for (const arg of args) {
47
+ if (arg === "--")
48
+ break;
49
+ if (gitPatchOutputRequested([arg]))
50
+ return false;
51
+ if (/^--(?:stat(?:=.*)?|name-only|name-status|numstat)$/u.test(arg)) {
52
+ metadata = true;
53
+ continue;
54
+ }
55
+ if (/^(?:-z|--cached|--staged|--merge-base|--no-renames|--no-ext-diff|--no-textconv)$/u.test(arg)
56
+ || /^--(?:relative|ignore-submodules|submodule|diff-filter|stat-width|stat-name-width|stat-graph-width|stat-count|abbrev)(?:=|$)/u.test(arg))
57
+ continue;
58
+ // Revisions and pathspecs are positional. Unknown options may enable content or external helpers, so
59
+ // a metadata flag does not make them safe automatically.
60
+ if (arg.startsWith("-"))
61
+ return false;
62
+ }
63
+ return metadata;
64
+ }
28
65
  /** Unwrap shell wrappers and strip leading env/wrappers to get the command's essential form. Lossy on
29
66
  * purpose — used only for matching/classification, never for execution. */
30
67
  export function canonicalize(command) {
@@ -51,12 +88,54 @@ export function isReadOnlyCommand(canonical) {
51
88
  return false;
52
89
  if (/[>]/.test(canonical))
53
90
  return false; // any output redirection → not read-only
91
+ if (/\$(?:\{)?[A-Za-z_][A-Za-z0-9_]*/.test(canonical))
92
+ return false; // variable expansion may print a credential
93
+ if (/^jq\b[\s\S]*(?:\benv\b|\$ENV\b)/.test(canonical))
94
+ return false;
54
95
  if (DANGER_FLAG.test(canonical))
55
96
  return false;
56
97
  const parts = canonical.split(" ");
57
98
  const prog = parts[0];
58
- if (prog === "git")
59
- return GIT_READONLY_SUB.has(parts[1] ?? "");
99
+ // `ps` is normally read-only, but BSD/GNU environment display modifiers turn it into a credential
100
+ // disclosure command. It must never inherit readonly auto-approval (the shell boundary also hard-denies it).
101
+ if (prog === "ps" && psArgumentsExposeEnvironment(parts.slice(1)))
102
+ return false;
103
+ if (prog === "git") {
104
+ const sub = parts[1] ?? "";
105
+ const args = parts.slice(2);
106
+ // Git can expose deleted credential contents without touching the current filesystem. Autorun only
107
+ // explicit metadata diffs; history commands that inherently print file bodies always require approval.
108
+ if (sub === "diff")
109
+ return gitDiffIsMetadataOnly(args);
110
+ if (sub === "log" || sub === "whatchanged")
111
+ return !gitPatchOutputRequested(args);
112
+ if (sub === "status") {
113
+ return !args.some((arg) => arg === "--verbose" || /^-[^-]*v/u.test(arg));
114
+ }
115
+ if (sub === "config")
116
+ return false; // values may be credentials (http.extraHeader, credential helpers, URLs)
117
+ if (GIT_READONLY_SUB.has(sub))
118
+ return true;
119
+ if (sub === "branch") {
120
+ if (!args.length)
121
+ return true;
122
+ // Bare branch names create/delete/move branches. Autorun only explicit display/query forms.
123
+ return args.some((arg) => ["--list", "-l", "--show-current", "-a", "--all", "-r", "--remotes", "-v", "-vv", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--format", "--sort"].includes(arg))
124
+ && !args.some((arg) => ["-d", "-D", "-m", "-M", "-c", "-C", "--delete", "--move", "--copy", "--edit-description", "--set-upstream-to", "--unset-upstream"].includes(arg));
125
+ }
126
+ if (sub === "tag") {
127
+ if (!args.length)
128
+ return true;
129
+ return args.some((arg) => ["--list", "-l", "--contains", "--no-contains", "--merged", "--no-merged", "--points-at", "--format", "--sort"].includes(arg))
130
+ && !args.some((arg) => ["-d", "--delete", "-f", "--force", "-a", "--annotate", "-s", "--sign", "-u", "--local-user"].includes(arg));
131
+ }
132
+ if (sub === "remote") {
133
+ // Bare `git remote` lists names only. Verbose/get-url/show variants can print credential-bearing URLs;
134
+ // mutating and networked variants are not readonly-autorun candidates either.
135
+ return args.length === 0;
136
+ }
137
+ return false;
138
+ }
60
139
  return READONLY_PROGRAMS.has(prog);
61
140
  }
62
141
  /** Split a command on top-level shell operators (&&, ||, ;, |, newline), quote-aware. Returns null if the
@@ -149,11 +228,12 @@ export function decideCommand(command, rules) {
149
228
  return strictest(parts.map((p) => decideSimple(p, rules)));
150
229
  }
151
230
  const DEFAULTS = { allow: [], deny: [], readonlyAutorun: true };
152
- function readRules(p) {
153
- if (!existsSync(p))
154
- return {};
231
+ function permissionRecord(raw) {
155
232
  try {
156
- const j = JSON.parse(readFileSync(p, "utf8"));
233
+ const parsed = JSON.parse(raw);
234
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
235
+ return {};
236
+ const j = parsed;
157
237
  return {
158
238
  allow: Array.isArray(j.allow) ? j.allow.filter((x) => typeof x === "string") : undefined,
159
239
  deny: Array.isArray(j.deny) ? j.deny.filter((x) => typeof x === "string") : undefined,
@@ -164,6 +244,105 @@ function readRules(p) {
164
244
  return {};
165
245
  }
166
246
  }
247
+ function readGlobalRules(p) {
248
+ if (!existsSync(p))
249
+ return {};
250
+ try {
251
+ return permissionRecord(readFileSync(p, "utf8"));
252
+ }
253
+ catch {
254
+ return {};
255
+ }
256
+ }
257
+ function warnProjectPermissions(kind, message) {
258
+ if (projectPermissionWarnings.has(kind))
259
+ return;
260
+ projectPermissionWarnings.add(kind);
261
+ try {
262
+ process.stderr.write(`hara: ${message}\n`);
263
+ }
264
+ catch { /* best effort */ }
265
+ }
266
+ function unsafeProjectPermissions(kind) {
267
+ warnProjectPermissions(`unsafe-file:${kind}`, `ignored an unsafe project .hara/permissions.json (${kind}); no project permission values were loaded.`);
268
+ return {};
269
+ }
270
+ /** Read the nearest repository permission file through a bounded O_NOFOLLOW descriptor. The repository's
271
+ * `.hara` parent is part of the trust boundary too: neither it nor the final file may be an alias, and both
272
+ * identities are rechecked after the read. */
273
+ function readProjectRules(cwd) {
274
+ let dir;
275
+ try {
276
+ dir = realpathSync.native(resolve(cwd));
277
+ }
278
+ catch {
279
+ dir = resolve(cwd);
280
+ }
281
+ for (;;) {
282
+ const hara = join(dir, ".hara");
283
+ const file = join(hara, "permissions.json");
284
+ let parentInfo;
285
+ try {
286
+ parentInfo = lstatSync(hara);
287
+ }
288
+ catch (error) {
289
+ if (error?.code !== "ENOENT")
290
+ return unsafeProjectPermissions("unreadable parent");
291
+ }
292
+ if (parentInfo) {
293
+ if (parentInfo.isSymbolicLink())
294
+ return unsafeProjectPermissions("symlink parent");
295
+ if (!parentInfo.isDirectory())
296
+ return unsafeProjectPermissions("non-directory parent");
297
+ try {
298
+ if (realpathSync.native(hara) !== hara)
299
+ return unsafeProjectPermissions("non-canonical parent");
300
+ const fileInfo = lstatSync(file);
301
+ if (fileInfo.isSymbolicLink())
302
+ return unsafeProjectPermissions("symlink file");
303
+ if (!fileInfo.isFile())
304
+ return unsafeProjectPermissions("non-regular file");
305
+ const snapshot = readVerifiedRegularFileSnapshotSync(file, MAX_PROJECT_PERMISSIONS_BYTES, {
306
+ action: "read project permissions",
307
+ protectSensitive: false,
308
+ rejectHardLinks: true,
309
+ });
310
+ const parentAfter = lstatSync(hara);
311
+ if (!parentAfter.isDirectory()
312
+ || parentAfter.isSymbolicLink()
313
+ || parentAfter.dev !== parentInfo.dev
314
+ || parentAfter.ino !== parentInfo.ino
315
+ || realpathSync.native(hara) !== hara)
316
+ return unsafeProjectPermissions("changed parent");
317
+ return permissionRecord(snapshot.text);
318
+ }
319
+ catch (error) {
320
+ if (error?.code === "ENOENT") {
321
+ // No file in this directory; continue toward the repository root.
322
+ }
323
+ else if (error?.code === "HARA_HARD_LINKED_FILE") {
324
+ return unsafeProjectPermissions("hard-linked file");
325
+ }
326
+ else if (error?.code === "HARA_FILE_TOO_LARGE") {
327
+ return unsafeProjectPermissions("oversized file");
328
+ }
329
+ else if (/changed while (?:opening|reading)|File changed|path changed/i.test(error?.message ?? "")) {
330
+ return unsafeProjectPermissions("changed file");
331
+ }
332
+ else {
333
+ return unsafeProjectPermissions("invalid file");
334
+ }
335
+ }
336
+ }
337
+ if (PROJECT_ROOT_MARKERS.some((marker) => existsSync(join(dir, marker))))
338
+ break;
339
+ const parent = dirname(dir);
340
+ if (parent === dir)
341
+ break;
342
+ dir = parent;
343
+ }
344
+ return {};
345
+ }
167
346
  export function globalPermissionsPath() {
168
347
  return join(homedir(), ".hara", "permissions.json");
169
348
  }
@@ -183,16 +362,34 @@ export function projectPermissionsPath(cwd) {
183
362
  }
184
363
  return null;
185
364
  }
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). */
365
+ /** Effective rules: an untrusted repository may only tighten policy (add deny rules or turn readonly
366
+ * autorun off). Project allow rules and a readonlyAutorun=true expansion require the launch-time trust flag.
367
+ * Missing/invalid files use safe defaults; every deny remains deny-wins. */
188
368
  export function loadPermissionRules(cwd) {
189
- const g = readRules(globalPermissionsPath());
190
- const projPath = projectPermissionsPath(cwd);
191
- const p = projPath ? readRules(projPath) : {};
369
+ const g = readGlobalRules(globalPermissionsPath());
370
+ const p = readProjectRules(cwd);
371
+ const trustedProject = projectRepositoryTrustedAtStartup();
372
+ const globalReadonly = g.readonlyAutorun ?? DEFAULTS.readonlyAutorun;
373
+ const privileged = [
374
+ ...(p.allow?.length ? ["allow"] : []),
375
+ ...(p.readonlyAutorun === true ? ["readonlyAutorun"] : []),
376
+ ];
377
+ if (privileged.length) {
378
+ const names = privileged.join(", ");
379
+ if (trustedProject) {
380
+ warnProjectPermissions(`trusted:${names}`, `trusted project permissions enabled for privileged key(s): ${names}.`);
381
+ }
382
+ else {
383
+ warnProjectPermissions(`ignored:${names}`, `ignored untrusted project permission expansion(s): ${names}. Project deny rules and readonlyAutorun=false still tighten policy. ` +
384
+ "Set HARA_TRUST_PROJECT_CONFIG=1 before starting hara only for a repository you trust.");
385
+ }
386
+ }
192
387
  return {
193
- allow: [...(g.allow ?? []), ...(p.allow ?? [])],
388
+ allow: [...(g.allow ?? []), ...(trustedProject ? (p.allow ?? []) : [])],
194
389
  deny: [...(g.deny ?? []), ...(p.deny ?? [])],
195
- readonlyAutorun: p.readonlyAutorun ?? g.readonlyAutorun ?? DEFAULTS.readonlyAutorun,
390
+ readonlyAutorun: trustedProject
391
+ ? (p.readonlyAutorun ?? globalReadonly)
392
+ : (p.readonlyAutorun === false ? false : globalReadonly),
196
393
  };
197
394
  }
198
395
  const SCAFFOLD = {
@@ -200,9 +397,119 @@ const SCAFFOLD = {
200
397
  deny: ["git push", "rm -rf", "sudo", "git reset --hard"],
201
398
  readonlyAutorun: true,
202
399
  };
400
+ function verifiedDirectory(path, expected) {
401
+ const info = lstatSync(path);
402
+ if (!info.isDirectory()
403
+ || info.isSymbolicLink()
404
+ || realpathSync.native(path) !== path
405
+ || (expected && (info.dev !== expected.dev || info.ino !== expected.ino)))
406
+ throw new Error(`refusing project permissions write: parent directory identity is unsafe or changed`);
407
+ return { dev: info.dev, ino: info.ino };
408
+ }
409
+ function existingScaffoldTarget(path) {
410
+ try {
411
+ const info = lstatSync(path);
412
+ if (info.isSymbolicLink())
413
+ throw new Error("refusing project permissions write: destination is a symlink");
414
+ if (!info.isFile())
415
+ throw new Error("refusing project permissions write: destination is not a regular file");
416
+ if (info.nlink > 1)
417
+ throw new Error("refusing project permissions write: destination is hard-linked");
418
+ return true;
419
+ }
420
+ catch (error) {
421
+ if (error?.code === "ENOENT")
422
+ return false;
423
+ throw error;
424
+ }
425
+ }
426
+ /** Create project permissions without following `.hara` or replacing any existing directory entry. */
427
+ function scaffoldProjectPermissions(cwd) {
428
+ const project = realpathSync.native(resolve(cwd));
429
+ const projectIdentity = verifiedDirectory(project);
430
+ const parent = join(project, ".hara");
431
+ try {
432
+ lstatSync(parent);
433
+ }
434
+ catch (error) {
435
+ if (error?.code !== "ENOENT")
436
+ throw error;
437
+ verifiedDirectory(project, projectIdentity);
438
+ try {
439
+ mkdirSync(parent, { mode: 0o700 });
440
+ }
441
+ catch (mkdirError) {
442
+ if (mkdirError?.code !== "EEXIST")
443
+ throw mkdirError;
444
+ }
445
+ }
446
+ const parentIdentity = verifiedDirectory(parent);
447
+ const target = join(parent, "permissions.json");
448
+ if (existingScaffoldTarget(target))
449
+ return null;
450
+ const temp = join(parent, `.hara-permissions-${process.pid}-${randomUUID()}.tmp`);
451
+ let fd;
452
+ let tempIdentity;
453
+ let committed = false;
454
+ try {
455
+ fd = openSync(temp, "wx", 0o600);
456
+ writeFileSync(fd, JSON.stringify(SCAFFOLD, null, 2) + "\n", "utf8");
457
+ fsyncSync(fd);
458
+ const opened = fstatSync(fd);
459
+ tempIdentity = { dev: opened.dev, ino: opened.ino };
460
+ closeSync(fd);
461
+ fd = undefined;
462
+ verifiedDirectory(parent, parentIdentity);
463
+ const staged = lstatSync(temp);
464
+ if (!staged.isFile() || staged.isSymbolicLink() || staged.dev !== tempIdentity.dev || staged.ino !== tempIdentity.ino) {
465
+ throw new Error("refusing project permissions write: staging file identity changed");
466
+ }
467
+ verifiedDirectory(parent, parentIdentity);
468
+ try {
469
+ // Hard-link commit is create-if-absent on every supported platform; unlike rename it cannot overwrite
470
+ // a symlink/hard-link/ordinary file planted after preflight.
471
+ linkSync(temp, target);
472
+ }
473
+ catch (error) {
474
+ if (error?.code === "EEXIST") {
475
+ if (existingScaffoldTarget(target))
476
+ return null;
477
+ }
478
+ throw error;
479
+ }
480
+ verifiedDirectory(parent, parentIdentity);
481
+ const written = lstatSync(target);
482
+ if (!written.isFile() || written.isSymbolicLink() || written.dev !== tempIdentity.dev || written.ino !== tempIdentity.ino) {
483
+ throw new Error("refusing project permissions write: committed file identity changed");
484
+ }
485
+ unlinkSync(temp);
486
+ committed = true;
487
+ return target;
488
+ }
489
+ finally {
490
+ if (fd !== undefined)
491
+ try {
492
+ closeSync(fd);
493
+ }
494
+ catch { /* best effort */ }
495
+ if (!committed && tempIdentity) {
496
+ try {
497
+ verifiedDirectory(parent, parentIdentity);
498
+ const current = lstatSync(temp);
499
+ if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino)
500
+ unlinkSync(temp);
501
+ }
502
+ catch {
503
+ /* Retain an uncertain private staging file rather than unlinking an attacker-controlled replacement. */
504
+ }
505
+ }
506
+ }
507
+ }
203
508
  /** Write a starter permissions.json (global by default). Returns the path, or null if one already exists. */
204
509
  export function scaffoldPermissions(cwd, scope = "global") {
205
- const p = scope === "project" ? join(resolve(cwd), ".hara", "permissions.json") : globalPermissionsPath();
510
+ if (scope === "project")
511
+ return scaffoldProjectPermissions(cwd);
512
+ const p = globalPermissionsPath();
206
513
  if (existsSync(p))
207
514
  return null;
208
515
  mkdirSync(dirname(p), { recursive: true });