@nanhara/hara 0.121.1 → 0.122.1

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 (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -4,6 +4,191 @@
4
4
  // (it needs runAgent + providers); these are the pure, testable pieces: verdict parsing, change capture,
5
5
  // and the prompts. Used by `hara org --review`.
6
6
  import { execFileSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
9
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
10
+ import { verifyOpenedRegularFileSync } from "../fs-read.js";
11
+ import { sensitiveFileReason } from "../security/sensitive-files.js";
12
+ import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
13
+ const MAX_CHANGED_PATHS = 4096;
14
+ const GIT_CAPTURE_TIMEOUT_MS = 10_000;
15
+ const MAX_STAGED_VERIFY_BYTES = 64 * 1024 * 1024;
16
+ const READ_CHUNK_BYTES = 64 * 1024;
17
+ function gitBytes(cwd, args) {
18
+ return execFileSync("git", args, {
19
+ cwd,
20
+ env: toolSubprocessEnv(),
21
+ encoding: "buffer",
22
+ maxBuffer: 50_000_000,
23
+ timeout: GIT_CAPTURE_TIMEOUT_MS,
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ });
26
+ }
27
+ function nulPaths(bytes) {
28
+ const text = bytes.toString("utf8");
29
+ // A replacement character means a POSIX byte filename could not be represented safely as a JS path.
30
+ // Fail closed instead of accidentally diffing a path other than the one Git named.
31
+ if (text.includes("\uFFFD"))
32
+ throw new Error("git returned a filename that is not valid UTF-8");
33
+ return text.split("\0").filter(Boolean);
34
+ }
35
+ function pathInside(cwd, path) {
36
+ const rel = relative(resolve(cwd), resolve(cwd, path));
37
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
38
+ }
39
+ function safePathLabel(path) {
40
+ return redactToolSubprocessOutput(path);
41
+ }
42
+ function safePathLabels(paths) {
43
+ return [...new Set(paths.map(safePathLabel))];
44
+ }
45
+ function classifyPaths(cwd, paths) {
46
+ const classified = { protected: [], missing: [] };
47
+ for (const path of paths) {
48
+ if (!pathInside(cwd, path)) {
49
+ classified.protected.push(path);
50
+ continue;
51
+ }
52
+ const absolute = join(cwd, path);
53
+ if (sensitiveFileReason(absolute) !== null) {
54
+ classified.protected.push(path);
55
+ continue;
56
+ }
57
+ try {
58
+ const info = lstatSync(absolute);
59
+ // A tracked directory is a submodule gitlink (Git emits only its object id). Every other non-regular
60
+ // inode is excluded so FIFOs/devices/symlinks and hard-link aliases cannot enter model context.
61
+ if (!info.isDirectory() && (!info.isFile() || info.nlink > 1))
62
+ classified.protected.push(path);
63
+ }
64
+ catch (error) {
65
+ if (error?.code === "ENOENT")
66
+ classified.missing.push(path);
67
+ else
68
+ classified.protected.push(path);
69
+ }
70
+ }
71
+ return classified;
72
+ }
73
+ function diffArgs(options, format) {
74
+ const args = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
75
+ if (format === "name-status")
76
+ args.push("--name-status", "-z");
77
+ if (options.staged)
78
+ args.push("--staged");
79
+ else if (options.base) {
80
+ if (options.base.startsWith("-") || options.base.includes("\0"))
81
+ throw new Error("invalid base ref");
82
+ args.push(options.base);
83
+ }
84
+ else
85
+ args.push("HEAD");
86
+ return args;
87
+ }
88
+ function changedPathStatuses(cwd, options) {
89
+ const fields = nulPaths(gitBytes(cwd, [...diffArgs(options, "name-status"), "--"]));
90
+ const statuses = new Map();
91
+ for (let i = 0; i < fields.length;) {
92
+ const rawStatus = fields[i++];
93
+ if (!/^[ACDMRTUXB][0-9]*$/u.test(rawStatus))
94
+ throw new Error("git returned an invalid name-status record");
95
+ const status = rawStatus[0];
96
+ // --no-renames keeps C/R out of ordinary output, but consume both paths defensively if a Git variant
97
+ // still reports one so a source name can never be mistaken for a status token.
98
+ if (status === "C" || status === "R") {
99
+ const source = fields[i++];
100
+ const destination = fields[i++];
101
+ if (source === undefined || destination === undefined)
102
+ throw new Error("git returned a truncated name-status record");
103
+ statuses.set(destination, status);
104
+ continue;
105
+ }
106
+ const path = fields[i++];
107
+ if (path === undefined)
108
+ throw new Error("git returned a truncated name-status record");
109
+ statuses.set(path, status);
110
+ }
111
+ return statuses;
112
+ }
113
+ function stagedIndexEntry(cwd, path) {
114
+ const records = nulPaths(gitBytes(cwd, ["ls-files", "--stage", "-z", "--", path]));
115
+ if (records.length !== 1)
116
+ return null;
117
+ const record = records[0];
118
+ const tab = record.indexOf("\t");
119
+ if (tab < 0 || record.slice(tab + 1) !== path)
120
+ return null;
121
+ const fields = record.slice(0, tab).split(" ");
122
+ if (fields.length !== 3 || fields[2] !== "0" || !/^[0-9a-f]+$/u.test(fields[1]))
123
+ return null;
124
+ return { mode: fields[0], oid: fields[1] };
125
+ }
126
+ function verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) {
127
+ const absolute = join(cwd, path);
128
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
129
+ const fd = openSync(absolute, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
130
+ try {
131
+ const before = fstatSync(fd);
132
+ verifyOpenedRegularFileSync(absolute, before, {
133
+ action: "verify staged commit content",
134
+ rejectHardLinks: true,
135
+ protectSensitive: true,
136
+ });
137
+ if (before.size > maxBytes)
138
+ throw new Error("staged file exceeds verification limit");
139
+ const chunks = [];
140
+ let total = 0;
141
+ while (total <= maxBytes) {
142
+ const want = Math.min(READ_CHUNK_BYTES, maxBytes + 1 - total);
143
+ const buffer = Buffer.allocUnsafe(want);
144
+ const bytesRead = readSync(fd, buffer, 0, want, total);
145
+ if (!bytesRead)
146
+ break;
147
+ chunks.push(buffer.subarray(0, bytesRead));
148
+ total += bytesRead;
149
+ }
150
+ if (total > maxBytes)
151
+ throw new Error("staged file exceeds verification limit");
152
+ const after = fstatSync(fd);
153
+ verifyOpenedRegularFileSync(absolute, after, {
154
+ action: "verify staged commit content",
155
+ rejectHardLinks: true,
156
+ protectSensitive: true,
157
+ });
158
+ if (before.dev !== after.dev
159
+ || before.ino !== after.ino
160
+ || before.size !== after.size
161
+ || before.mtimeMs !== after.mtimeMs
162
+ || before.ctimeMs !== after.ctimeMs
163
+ || total !== before.size)
164
+ throw new Error("staged worktree file changed during verification");
165
+ const hash = createHash(algorithm);
166
+ hash.update(`blob ${total}\0`);
167
+ for (const chunk of chunks)
168
+ hash.update(chunk);
169
+ return hash.digest("hex");
170
+ }
171
+ finally {
172
+ closeSync(fd);
173
+ }
174
+ }
175
+ function stagedBlobMatchesWorktree(cwd, path, maxBytes) {
176
+ try {
177
+ const entry = stagedIndexEntry(cwd, path);
178
+ if (!entry || (entry.mode !== "100644" && entry.mode !== "100755"))
179
+ return false;
180
+ const algorithm = entry.oid.length === 40 ? "sha1" : entry.oid.length === 64 ? "sha256" : null;
181
+ if (!algorithm)
182
+ return false;
183
+ return verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) === entry.oid;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ function untrackedPaths(cwd) {
190
+ return nulPaths(gitBytes(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--"]));
191
+ }
7
192
  /** Fallback reviewer persona when the project has no `reviewer` role. Read-only by intent. */
8
193
  export const REVIEWER_SYSTEM = `You are a senior code reviewer reviewing changes made to accomplish a task.
9
194
  Inspect them for: correctness and bugs, security, missing edge cases, and whether they actually accomplish
@@ -37,33 +222,142 @@ export function parseVerdict(text) {
37
222
  const approved = !CHANGES_RE.test(after) && APPROVE_RE.test(after); // changes-signal vetoes; ambiguous = not approved
38
223
  return { approved, issues: text.slice(0, idx).trim() };
39
224
  }
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 });
225
+ /** Capture model-safe change metadata. Git patches are deliberately excluded because their old side is
226
+ * historical content with no trustworthy current filesystem identity. Reviewers can read current files. */
227
+ export function captureChanges(cwd, cap = 100_000, options = {}) {
228
+ try {
229
+ const statuses = changedPathStatuses(cwd, options);
230
+ const paths = [...statuses.keys()];
231
+ const deleted = new Set(paths.filter((path) => statuses.get(path) === "D"));
232
+ const includeUntracked = options.includeUntracked ?? (!options.staged && !options.base);
233
+ const untracked = includeUntracked ? untrackedPaths(cwd) : [];
234
+ if (paths.length + untracked.length > MAX_CHANGED_PATHS) {
235
+ return { diff: "", newFiles: [], skippedFiles: [], omittedDeletions: [], error: `too many changed paths (>${MAX_CHANGED_PATHS})` };
46
236
  }
47
- catch {
48
- return "";
237
+ const tracked = classifyPaths(cwd, paths);
238
+ const untrackedClass = classifyPaths(cwd, untracked);
239
+ const skippedRaw = new Set([...tracked.protected, ...untrackedClass.protected]);
240
+ // A token-shaped untracked pathname is itself unsafe model input. Once staged, its diff header is
241
+ // redacted like every other subprocess output; before then, omit it rather than advertise a false path.
242
+ for (const path of untracked)
243
+ if (safePathLabel(path) !== path)
244
+ skippedRaw.add(path);
245
+ // Missing is not synonymous with deleted: a staged AD entry has an added blob in the index while the
246
+ // worktree path is absent. Only Git's exact D status is an omitted deletion; every other missing tracked
247
+ // entry is unverifiable and remains protected.
248
+ for (const path of tracked.missing)
249
+ if (!deleted.has(path))
250
+ skippedRaw.add(path);
251
+ const omittedRaw = paths.filter((path) => !skippedRaw.has(path) && deleted.has(path));
252
+ const safePaths = paths.filter((path) => !skippedRaw.has(path) && !deleted.has(path));
253
+ const missingUntracked = new Set(untrackedClass.missing);
254
+ const newFiles = untracked
255
+ .filter((path) => !skippedRaw.has(path) && !missingUntracked.has(path))
256
+ .slice(0, 50);
257
+ // Never provide a Git patch to a model: even an ordinary modification's old side can be a historical
258
+ // hard-link alias of a credential, and a staged index blob can diverge from today's verified worktree.
259
+ // Status + redacted path metadata is sufficient for reviewers to inspect the current file via read_file.
260
+ let diff = safePaths.map((path) => `${statuses.get(path)}\t${safePathLabel(path)}`).join("\n");
261
+ if (diff.length > cap)
262
+ diff = diff.slice(0, cap) + "\n…[diff truncated]";
263
+ return {
264
+ diff,
265
+ newFiles,
266
+ skippedFiles: safePathLabels([...skippedRaw]),
267
+ omittedDeletions: safePathLabels(omittedRaw),
268
+ };
269
+ }
270
+ catch (error) {
271
+ return {
272
+ diff: "",
273
+ newFiles: [],
274
+ skippedFiles: [],
275
+ omittedDeletions: [],
276
+ error: redactToolSubprocessOutput(error instanceof Error ? error.message : String(error)),
277
+ };
278
+ }
279
+ }
280
+ /** Protected files currently staged for commit. Commit-message generation and commit both fail closed. */
281
+ export function protectedStagedPaths(cwd) {
282
+ try {
283
+ const statuses = changedPathStatuses(cwd, { staged: true });
284
+ if (statuses.size > MAX_CHANGED_PATHS)
285
+ return ["(too many staged paths to verify)"];
286
+ const classified = classifyPaths(cwd, [...statuses.keys()]);
287
+ const unverifiable = new Set(classified.missing.filter((path) => statuses.get(path) !== "D"));
288
+ const protectedSet = new Set(classified.protected);
289
+ let remainingBytes = MAX_STAGED_VERIFY_BYTES;
290
+ for (const [path, status] of statuses) {
291
+ if (status === "D" || protectedSet.has(path) || unverifiable.has(path))
292
+ continue;
293
+ let size = remainingBytes + 1;
294
+ try {
295
+ size = lstatSync(join(cwd, path)).size;
296
+ }
297
+ catch { /* verification below fails closed */ }
298
+ if (!["A", "M", "T"].includes(status)
299
+ || size > remainingBytes
300
+ || !stagedBlobMatchesWorktree(cwd, path, remainingBytes)) {
301
+ unverifiable.add(path);
302
+ }
303
+ else {
304
+ remainingBytes -= size;
305
+ }
49
306
  }
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 };
307
+ return safePathLabels([...classified.protected, ...unverifiable]);
308
+ }
309
+ catch {
310
+ // An unreadable/ambiguous Git index cannot be declared safe to send to a model or commit automatically.
311
+ return ["(unable to verify staged paths)"];
312
+ }
313
+ }
314
+ /** Protected tracked/untracked changes that `git add -A` would stage. */
315
+ export function protectedWorkingTreePaths(cwd) {
316
+ try {
317
+ // Compare HEAD directly with the final worktree. Combining cached + unstaged lists misclassifies an AD
318
+ // entry that `git add -A` will collapse to no net change.
319
+ const statuses = changedPathStatuses(cwd, {});
320
+ const tracked = [...statuses.keys()];
321
+ const untracked = untrackedPaths(cwd);
322
+ if (tracked.length + untracked.length > MAX_CHANGED_PATHS)
323
+ return ["(too many changed paths to verify)"];
324
+ const trackedClass = classifyPaths(cwd, tracked);
325
+ const untrackedClass = classifyPaths(cwd, untracked);
326
+ const unverifiable = trackedClass.missing.filter((path) => statuses.get(path) !== "D");
327
+ return safePathLabels([...trackedClass.protected, ...unverifiable, ...untrackedClass.protected]);
328
+ }
329
+ catch {
330
+ return ["(unable to verify working-tree paths)"];
331
+ }
332
+ }
333
+ /** Protected tracked changes that `git add -u` would stage (untracked files are intentionally excluded). */
334
+ export function protectedTrackedWorkingTreePaths(cwd) {
335
+ try {
336
+ const statuses = changedPathStatuses(cwd, {});
337
+ const tracked = [...statuses.keys()];
338
+ if (tracked.length > MAX_CHANGED_PATHS)
339
+ return ["(too many changed paths to verify)"];
340
+ const classified = classifyPaths(cwd, tracked);
341
+ const unverifiable = classified.missing.filter((path) => statuses.get(path) !== "D");
342
+ return safePathLabels([...classified.protected, ...unverifiable]);
343
+ }
344
+ catch {
345
+ return ["(unable to verify tracked working-tree paths)"];
346
+ }
60
347
  }
61
348
  /** True only if the working tree is fully clean — no uncommitted changes. The `--commit` capstone uses
62
349
  * this as a guard: `git add -A` + commit is only safe to run when the tree was clean before the org ran,
63
350
  * so it captures THIS run's work and never sweeps up pre-existing WIP. False for a non-git dir. */
64
351
  export function isTreeClean(cwd) {
65
352
  try {
66
- return execFileSync("git", ["status", "--porcelain"], { cwd, encoding: "utf8", maxBuffer: 50_000_000 }).trim() === "";
353
+ return execFileSync("git", ["status", "--porcelain"], {
354
+ cwd,
355
+ env: toolSubprocessEnv(),
356
+ encoding: "utf8",
357
+ maxBuffer: 50_000_000,
358
+ timeout: GIT_CAPTURE_TIMEOUT_MS,
359
+ stdio: ["ignore", "pipe", "ignore"],
360
+ }).trim() === "";
67
361
  }
68
362
  catch {
69
363
  return false; // not a git repo / git error → treat as "not clean" so we never auto-commit blindly
@@ -73,15 +367,57 @@ export function isTreeClean(cwd) {
73
367
  export function stripCommitFence(text) {
74
368
  return text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
75
369
  }
370
+ function displayPathList(paths, cap = 12_000) {
371
+ const shown = [];
372
+ let length = 0;
373
+ for (const path of paths) {
374
+ const label = JSON.stringify(safePathLabel(path));
375
+ if (length + label.length + (shown.length ? 2 : 0) > cap)
376
+ break;
377
+ shown.push(label);
378
+ length += label.length + (shown.length > 1 ? 2 : 0);
379
+ }
380
+ const remaining = paths.length - shown.length;
381
+ if (!shown.length)
382
+ return `${paths.length} path(s) (names omitted for length)`;
383
+ return shown.join(", ") + (remaining ? `, … and ${remaining} more` : "");
384
+ }
385
+ /** Model-safe input for commit-message generation. A deletion-only commit still has useful metadata, but
386
+ * never receives the unverifiable historical blob that Git's ordinary deletion diff would expose. */
387
+ export function commitMessageInput(changes) {
388
+ const omittedDeletions = changes.omittedDeletions ?? [];
389
+ const parts = [];
390
+ if (changes.diff.trim())
391
+ parts.push(changes.diff);
392
+ if (omittedDeletions.length) {
393
+ parts.push("Tracked deletions (historical file contents intentionally omitted for safety): " +
394
+ displayPathList(omittedDeletions));
395
+ }
396
+ return parts.join("\n\n");
397
+ }
76
398
  /** The reviewer's input: the task + the changes to review. */
77
399
  export function reviewPrompt(task, changes) {
400
+ const skippedFiles = changes.skippedFiles ?? [];
401
+ const omittedDeletions = changes.omittedDeletions ?? [];
78
402
  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```");
403
+ if (changes.diff) {
404
+ parts.push("Change metadata only (status + path; historical patch contents are intentionally omitted):\n```text\n" +
405
+ changes.diff + "\n```");
406
+ }
81
407
  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)");
408
+ parts.push(`New files (use read_file to inspect): ${displayPathList(changes.newFiles)}`);
409
+ if (skippedFiles.length) {
410
+ parts.push("Protected paths were omitted from review context and MUST NOT be opened by the reviewer: " +
411
+ displayPathList(skippedFiles));
412
+ }
413
+ if (omittedDeletions.length) {
414
+ parts.push("Tracked deletions were detected, but their historical contents were omitted because old filesystem " +
415
+ "identity cannot be verified safely: " + displayPathList(omittedDeletions));
416
+ }
417
+ if (changes.error)
418
+ parts.push(`(change capture failed closed: ${changes.error})`);
419
+ if (!changes.diff && !changes.newFiles.length && !omittedDeletions.length)
420
+ parts.push("(no reviewable diff was captured)");
85
421
  parts.push("\nReview these changes against the task. Finish with your VERDICT line.");
86
422
  return parts.join("\n");
87
423
  }
package/dist/org/roles.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // Org roles — markdown agent definitions in <project>/.hara/roles/*.md.
2
- // Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[]. Body = persona/system.
3
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ // Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[], readOnly?. Body = persona/system.
3
+ import { writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { findProjectRoot } from "../context/agents-md.js";
7
7
  import { pluginRoleDirs } from "../plugins/plugins.js";
8
+ import { readModelContextFileSync } from "../fs-read.js";
9
+ const MAX_ROLE_BYTES = 512 * 1024;
8
10
  export function rolesDir(cwd) {
9
11
  return join(findProjectRoot(cwd), ".hara", "roles");
10
12
  }
@@ -83,25 +85,49 @@ function parseFrontmatter(text) {
83
85
  * parallel), with a role allowed to narrow further but never to grant write/exec. `isReadonly` is the
84
86
  * read-kind predicate. This is the guard that keeps the `agent` tool from bypassing the approval gate. */
85
87
  export function subagentToolFilter(role, isReadonly) {
86
- const roleFilter = role?.allowTools
87
- ? (n) => role.allowTools.includes(n)
88
- : role?.denyTools
89
- ? (n) => !role.denyTools.includes(n)
90
- : null;
88
+ // Treat allow + deny as an intersection when both are present. A deny must never disappear merely
89
+ // because an allow-list was also declared (mixed policies are common in generated role bundles).
90
+ const roleFilter = role?.allowTools || role?.denyTools
91
+ ? (n) => (!role.allowTools || role.allowTools.includes(n)) && (!role.denyTools || !role.denyTools.includes(n))
92
+ : null;
91
93
  return (n) => isReadonly(n) && (roleFilter ? roleFilter(n) : true);
92
94
  }
95
+ /** Apply a role's declared tool policy to a normal (approval-gated) run. Undefined means unrestricted. */
96
+ export function roleToolFilter(role) {
97
+ if (!role)
98
+ return undefined;
99
+ const declared = (name) => (!role.allowTools || role.allowTools.includes(name)) && (!role.denyTools || !role.denyTools.includes(name));
100
+ if (role.readOnly) {
101
+ // Raw bash is intentionally absent: even commands that look read-only can hide redirection, command
102
+ // substitution, hooks, or an executable with side effects. Reviewers get dedicated read/search tools.
103
+ const safe = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
104
+ return (name) => safe.has(name) && declared(name);
105
+ }
106
+ return role.allowTools || role.denyTools ? declared : undefined;
107
+ }
93
108
  export function loadRoles(cwd) {
94
- const byId = new Map();
95
109
  // lowest→highest precedence: plugins < org(B-end push) < global < .claude/agents < .hara/roles (project wins)
96
- for (const dir of [...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
110
+ return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]).values()];
111
+ }
112
+ /** The project-independent layers only (plugins + org-pushed + ~/.hara/roles) — what the global agent
113
+ * index lists as "runs anywhere". Excludes cwd-derived layers by construction. */
114
+ export function loadGlobalRoles() {
115
+ return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir()]).values()];
116
+ }
117
+ function rolesFromDirs(dirs) {
118
+ const byId = new Map();
119
+ for (const dir of dirs) {
97
120
  if (!existsSync(dir))
98
121
  continue;
99
122
  for (const f of readdirSync(dir)) {
100
123
  if (!f.endsWith(".md") || f === "README.md")
101
124
  continue;
102
125
  try {
103
- const { fm, body } = parseFrontmatter(readFileSync(join(dir, f), "utf8"));
126
+ const { fm, body } = parseFrontmatter(readModelContextFileSync(join(dir, f), MAX_ROLE_BYTES));
104
127
  const id = fm.name || f.replace(/\.md$/, "");
128
+ const explicitReadOnly = /^(true|false)$/i.test(String(fm.readOnly ?? ""))
129
+ ? String(fm.readOnly).toLowerCase() === "true"
130
+ : undefined;
105
131
  byId.set(id, {
106
132
  id,
107
133
  description: fm.description || "",
@@ -112,6 +138,7 @@ export function loadRoles(cwd) {
112
138
  model: fm.model && !/^(sonnet|opus|haiku|inherit)$/i.test(String(fm.model)) ? fm.model : undefined,
113
139
  allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
114
140
  denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
141
+ readOnly: explicitReadOnly ?? (id.toLowerCase() === "reviewer" ? true : undefined),
115
142
  system: body,
116
143
  });
117
144
  }
@@ -120,7 +147,7 @@ export function loadRoles(cwd) {
120
147
  }
121
148
  }
122
149
  }
123
- return [...byId.values()];
150
+ return byId;
124
151
  }
125
152
  export function hasRoles(cwd) {
126
153
  return loadRoles(cwd).length > 0;
@@ -140,10 +167,11 @@ End with a one-line summary of what changed.
140
167
  name: reviewer
141
168
  description: Reviews code for bugs, correctness, security, and style. Does not modify code.
142
169
  owns: [review, audit, check, correctness, security, vulnerability, lint, quality]
143
- allowTools: [read_file, bash]
170
+ allowTools: [read_file, grep, glob, ls, codebase_search]
171
+ readOnly: true
144
172
  ---
145
173
  You are the **reviewer**. Read the relevant code and report concrete issues (bug / correctness /
146
- security / style) with file:line and a suggested fix. Do NOT edit files — you have read-only tools.
174
+ security / style) with file:line and a suggested fix. Do NOT edit files — your tool surface is enforced read-only.
147
175
  Be specific; skip nitpicks unless asked.
148
176
  `,
149
177
  "docs.md": `---
@@ -164,6 +192,7 @@ Each \`*.md\` here is a role-agent. Frontmatter:
164
192
  - \`rejects\` — keywords that exclude this role (REJECT)
165
193
  - \`model\` — optional model override
166
194
  - \`allowTools\` / \`denyTools\` — restrict the role's tools
195
+ - \`readOnly\` — enforce read/search-only tools (defaults on for a role named \`reviewer\`)
167
196
 
168
197
  Run \`hara org "<task>"\` to dispatch a task to the owning role, or \`hara org --role <id> "<task>"\`.
169
198
  `,