@nanhara/hara 0.121.0 → 0.122.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.
- package/CHANGELOG.md +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Global agent index — ~/.hara/projects.json registers canonical project homes. The registry is private,
|
|
2
|
+
// atomically replaced, and guarded across processes because gateway/desktop/CLI may update it concurrently.
|
|
3
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { loadRoles, loadGlobalRoles } from "./roles.js";
|
|
8
|
+
const PROJECT_NAME = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|
9
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
10
|
+
const LOCK_ATTEMPTS = 500;
|
|
11
|
+
const LOCK_WAIT_MS = 10;
|
|
12
|
+
function haraDir() {
|
|
13
|
+
const dir = join(homedir(), ".hara");
|
|
14
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(dir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* best effort on non-POSIX filesystems */
|
|
20
|
+
}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
const projectsFile = () => join(haraDir(), "projects.json");
|
|
24
|
+
/** Handles are case-insensitive and form the left side of `project:agent`; canonical storage is lowercase. */
|
|
25
|
+
export function canonicalProjectName(value) {
|
|
26
|
+
if (typeof value !== "string")
|
|
27
|
+
return null;
|
|
28
|
+
const name = value.trim().toLowerCase();
|
|
29
|
+
return PROJECT_NAME.test(name) ? name : null;
|
|
30
|
+
}
|
|
31
|
+
/** Existing paths resolve symlinks so one project cannot be registered twice through aliases. */
|
|
32
|
+
export function canonicalProjectPath(value, requireDirectory = false) {
|
|
33
|
+
if (typeof value !== "string" || !value.trim() || !isAbsolute(value))
|
|
34
|
+
return null;
|
|
35
|
+
const absolute = resolve(value);
|
|
36
|
+
try {
|
|
37
|
+
if (requireDirectory && !statSync(absolute).isDirectory())
|
|
38
|
+
return null;
|
|
39
|
+
return realpathSync.native(absolute);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return requireDirectory ? null : absolute;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readLock(path) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
return Number.isInteger(parsed?.pid) && parsed.pid > 0 && typeof parsed?.token === "string" && parsed.token
|
|
49
|
+
? { pid: parsed.pid, token: parsed.token }
|
|
50
|
+
: null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function pidAlive(pid) {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, 0);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return error?.code === "EPERM";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function writeExclusive(path, record) {
|
|
66
|
+
let fd;
|
|
67
|
+
try {
|
|
68
|
+
fd = openSync(path, "wx", 0o600);
|
|
69
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
70
|
+
fsyncSync(fd);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (fd !== undefined) {
|
|
74
|
+
try {
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
fd = undefined;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(path, { force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (fd !== undefined)
|
|
86
|
+
closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function withProjectsLock(fn) {
|
|
90
|
+
const file = projectsFile();
|
|
91
|
+
const lock = `${file}.lock`;
|
|
92
|
+
const reclaim = `${lock}.reclaim`;
|
|
93
|
+
let claim;
|
|
94
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
95
|
+
if (existsSync(reclaim)) {
|
|
96
|
+
const stale = readLock(reclaim);
|
|
97
|
+
if (stale && !pidAlive(stale.pid)) {
|
|
98
|
+
const current = readLock(reclaim);
|
|
99
|
+
if (current?.pid === stale.pid && current.token === stale.token && !pidAlive(current.pid)) {
|
|
100
|
+
rmSync(reclaim, { force: true });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const candidate = { pid: process.pid, token: randomUUID() };
|
|
108
|
+
try {
|
|
109
|
+
writeExclusive(lock, candidate);
|
|
110
|
+
claim = candidate;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error?.code !== "EEXIST")
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const held = readLock(lock);
|
|
118
|
+
if (held && !pidAlive(held.pid)) {
|
|
119
|
+
const guard = { pid: process.pid, token: randomUUID() };
|
|
120
|
+
try {
|
|
121
|
+
writeExclusive(reclaim, guard);
|
|
122
|
+
const current = readLock(lock);
|
|
123
|
+
if (current?.pid === held.pid && current.token === held.token && !pidAlive(current.pid))
|
|
124
|
+
rmSync(lock);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Another writer is reclaiming, or the evidence is malformed. Never fail open.
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
const currentGuard = readLock(reclaim);
|
|
131
|
+
if (currentGuard?.pid === process.pid && currentGuard.token === guard.token)
|
|
132
|
+
rmSync(reclaim, { force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
|
|
136
|
+
}
|
|
137
|
+
if (!claim)
|
|
138
|
+
throw new Error("projects registry is busy; retry the operation");
|
|
139
|
+
try {
|
|
140
|
+
return fn();
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
const current = readLock(lock);
|
|
144
|
+
if (current?.pid === process.pid && current.token === claim.token)
|
|
145
|
+
rmSync(lock, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function parseProjects(raw, strict) {
|
|
149
|
+
const parsed = JSON.parse(raw);
|
|
150
|
+
const source = Array.isArray(parsed) ? parsed : parsed?.projects;
|
|
151
|
+
if (!Array.isArray(source))
|
|
152
|
+
throw new Error("projects registry must contain a projects array");
|
|
153
|
+
const out = [];
|
|
154
|
+
const names = new Set();
|
|
155
|
+
const paths = new Set();
|
|
156
|
+
for (const value of source) {
|
|
157
|
+
const name = canonicalProjectName(value?.name);
|
|
158
|
+
const path = canonicalProjectPath(value?.path);
|
|
159
|
+
if (!name || !path || names.has(name) || paths.has(path)) {
|
|
160
|
+
if (strict)
|
|
161
|
+
throw new Error("projects registry contains invalid or duplicate entries");
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
names.add(name);
|
|
165
|
+
paths.add(path);
|
|
166
|
+
out.push({ name, path });
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function loadProjectsUnlocked(strict) {
|
|
171
|
+
const file = projectsFile();
|
|
172
|
+
if (!existsSync(file))
|
|
173
|
+
return [];
|
|
174
|
+
try {
|
|
175
|
+
return parseProjects(readFileSync(file, "utf8"), strict);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (strict)
|
|
179
|
+
throw error;
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function loadProjects() {
|
|
184
|
+
return loadProjectsUnlocked(false);
|
|
185
|
+
}
|
|
186
|
+
function atomicSave(list) {
|
|
187
|
+
const file = projectsFile();
|
|
188
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
189
|
+
let fd;
|
|
190
|
+
try {
|
|
191
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
192
|
+
writeFileSync(fd, JSON.stringify({ projects: list }, null, 2) + "\n", "utf8");
|
|
193
|
+
fsyncSync(fd);
|
|
194
|
+
closeSync(fd);
|
|
195
|
+
fd = undefined;
|
|
196
|
+
renameSync(tmp, file);
|
|
197
|
+
try {
|
|
198
|
+
chmodSync(file, 0o600);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* best effort on non-POSIX filesystems */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
if (fd !== undefined)
|
|
206
|
+
closeSync(fd);
|
|
207
|
+
rmSync(tmp, { force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function normalizeProjectList(list) {
|
|
211
|
+
if (!Array.isArray(list))
|
|
212
|
+
throw new Error("projects must be an array");
|
|
213
|
+
const normalized = list.map((value) => {
|
|
214
|
+
const name = canonicalProjectName(value?.name);
|
|
215
|
+
const path = canonicalProjectPath(value?.path);
|
|
216
|
+
if (!name || !path)
|
|
217
|
+
throw new Error("project entries require a valid name and absolute path");
|
|
218
|
+
return { name, path };
|
|
219
|
+
});
|
|
220
|
+
if (new Set(normalized.map((p) => p.name)).size !== normalized.length || new Set(normalized.map((p) => p.path)).size !== normalized.length) {
|
|
221
|
+
throw new Error("project names and paths must be unique");
|
|
222
|
+
}
|
|
223
|
+
return normalized;
|
|
224
|
+
}
|
|
225
|
+
export function saveProjects(list) {
|
|
226
|
+
withProjectsLock(() => atomicSave(normalizeProjectList(list)));
|
|
227
|
+
}
|
|
228
|
+
export function addProject(nameInput, pathInput) {
|
|
229
|
+
const name = canonicalProjectName(nameInput);
|
|
230
|
+
if (!name)
|
|
231
|
+
return "invalid project name: use 1-64 lowercase letters, numbers, '.', '_' or '-'";
|
|
232
|
+
const path = canonicalProjectPath(pathInput, true);
|
|
233
|
+
if (!path)
|
|
234
|
+
return `path does not exist or is not a directory: ${pathInput}`;
|
|
235
|
+
try {
|
|
236
|
+
return withProjectsLock(() => {
|
|
237
|
+
const list = loadProjectsUnlocked(true);
|
|
238
|
+
const alias = list.find((p) => p.path === path && p.name !== name);
|
|
239
|
+
if (alias)
|
|
240
|
+
return `path is already registered as '${alias.name}': ${path}`;
|
|
241
|
+
const next = list.filter((p) => p.name !== name);
|
|
242
|
+
next.push({ name, path });
|
|
243
|
+
atomicSave(next);
|
|
244
|
+
return null;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
return `projects registry: ${error instanceof Error ? error.message : String(error)}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function removeProject(nameInput) {
|
|
252
|
+
const name = canonicalProjectName(nameInput);
|
|
253
|
+
if (!name)
|
|
254
|
+
return false;
|
|
255
|
+
return withProjectsLock(() => {
|
|
256
|
+
const list = loadProjectsUnlocked(true);
|
|
257
|
+
const next = list.filter((p) => p.name !== name);
|
|
258
|
+
if (next.length === list.length)
|
|
259
|
+
return false;
|
|
260
|
+
atomicSave(next);
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function sameList(a, b) {
|
|
265
|
+
if (a === undefined || b === undefined)
|
|
266
|
+
return a === b;
|
|
267
|
+
if (a.length !== b.length)
|
|
268
|
+
return false;
|
|
269
|
+
const left = [...a].sort();
|
|
270
|
+
const right = [...b].sort();
|
|
271
|
+
return left.every((value, i) => value === right[i]);
|
|
272
|
+
}
|
|
273
|
+
/** Project roles with the same prompt can still intentionally override model/routing/tool policy. */
|
|
274
|
+
function sameRoleDefinition(a, b) {
|
|
275
|
+
return (a.id === b.id &&
|
|
276
|
+
a.description === b.description &&
|
|
277
|
+
a.system === b.system &&
|
|
278
|
+
a.model === b.model &&
|
|
279
|
+
sameList(a.owns, b.owns) &&
|
|
280
|
+
sameList(a.rejects, b.rejects) &&
|
|
281
|
+
sameList(a.allowTools, b.allowTools) &&
|
|
282
|
+
sameList(a.denyTools, b.denyTools) &&
|
|
283
|
+
a.readOnly === b.readOnly);
|
|
284
|
+
}
|
|
285
|
+
/** Build the global index: inherited globals are listed once; any project override gets its qualified home. */
|
|
286
|
+
export function buildAgentsIndex() {
|
|
287
|
+
const globals = loadGlobalRoles();
|
|
288
|
+
const globalById = new Map(globals.map((role) => [role.id, role]));
|
|
289
|
+
const out = globals.map((role) => ({ name: role.id, description: role.description, home: "" }));
|
|
290
|
+
for (const project of loadProjects()) {
|
|
291
|
+
let roles = [];
|
|
292
|
+
try {
|
|
293
|
+
roles = loadRoles(project.path);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
for (const role of roles) {
|
|
299
|
+
const global = globalById.get(role.id);
|
|
300
|
+
if (global && sameRoleDefinition(global, role))
|
|
301
|
+
continue;
|
|
302
|
+
out.push({ name: role.id, description: role.description, home: project.path, project: project.name });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
/** Resolve `global:agent`, `project:agent`, or a bare name.
|
|
308
|
+
*
|
|
309
|
+
* Bare names normally prefer the global definition, then a unique project match. Callers that already
|
|
310
|
+
* have a working directory (the chat gateway, for example) may pass it as `preferredHome`; a matching
|
|
311
|
+
* project override then wins before the global fallback. This makes `/agent reviewer` do the local thing
|
|
312
|
+
* while keeping registry-wide, context-free lookups deterministic. */
|
|
313
|
+
export function resolveAgent(refInput, preferredHome) {
|
|
314
|
+
if (typeof refInput !== "string")
|
|
315
|
+
return null;
|
|
316
|
+
const ref = refInput.trim();
|
|
317
|
+
const index = buildAgentsIndex();
|
|
318
|
+
const separator = ref.indexOf(":");
|
|
319
|
+
if (separator > 0) {
|
|
320
|
+
const namespace = ref.slice(0, separator).trim().toLowerCase();
|
|
321
|
+
const name = ref.slice(separator + 1).trim();
|
|
322
|
+
if (namespace === "global") {
|
|
323
|
+
if (!name || name.includes(":"))
|
|
324
|
+
return null;
|
|
325
|
+
return index.find((entry) => !entry.project && entry.name === name) ?? null;
|
|
326
|
+
}
|
|
327
|
+
const project = canonicalProjectName(namespace);
|
|
328
|
+
if (!project || !name || name.includes(":"))
|
|
329
|
+
return null;
|
|
330
|
+
return index.find((entry) => entry.project === project && entry.name === name) ?? null;
|
|
331
|
+
}
|
|
332
|
+
const hits = index.filter((entry) => entry.name === ref);
|
|
333
|
+
if (!hits.length)
|
|
334
|
+
return null;
|
|
335
|
+
if (hits.length === 1)
|
|
336
|
+
return hits[0];
|
|
337
|
+
if (preferredHome) {
|
|
338
|
+
const preferred = canonicalProjectPath(preferredHome);
|
|
339
|
+
const local = preferred ? hits.find((entry) => !!entry.project && entry.home === preferred) : undefined;
|
|
340
|
+
if (local)
|
|
341
|
+
return local;
|
|
342
|
+
}
|
|
343
|
+
const global = hits.find((entry) => !entry.project);
|
|
344
|
+
if (global)
|
|
345
|
+
return global;
|
|
346
|
+
return { ambiguous: hits };
|
|
347
|
+
}
|
package/dist/org/roles.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Org roles — markdown agent definitions in <project>/.hara/roles/*.md.
|
|
2
|
-
// Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[]
|
|
2
|
+
// Frontmatter: name, description, owns[], rejects[], model?, allowTools[], denyTools[], readOnly?. Body = persona/system.
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -83,17 +83,38 @@ function parseFrontmatter(text) {
|
|
|
83
83
|
* parallel), with a role allowed to narrow further but never to grant write/exec. `isReadonly` is the
|
|
84
84
|
* read-kind predicate. This is the guard that keeps the `agent` tool from bypassing the approval gate. */
|
|
85
85
|
export function subagentToolFilter(role, isReadonly) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
// Treat allow + deny as an intersection when both are present. A deny must never disappear merely
|
|
87
|
+
// because an allow-list was also declared (mixed policies are common in generated role bundles).
|
|
88
|
+
const roleFilter = role?.allowTools || role?.denyTools
|
|
89
|
+
? (n) => (!role.allowTools || role.allowTools.includes(n)) && (!role.denyTools || !role.denyTools.includes(n))
|
|
90
|
+
: null;
|
|
91
91
|
return (n) => isReadonly(n) && (roleFilter ? roleFilter(n) : true);
|
|
92
92
|
}
|
|
93
|
+
/** Apply a role's declared tool policy to a normal (approval-gated) run. Undefined means unrestricted. */
|
|
94
|
+
export function roleToolFilter(role) {
|
|
95
|
+
if (!role)
|
|
96
|
+
return undefined;
|
|
97
|
+
const declared = (name) => (!role.allowTools || role.allowTools.includes(name)) && (!role.denyTools || !role.denyTools.includes(name));
|
|
98
|
+
if (role.readOnly) {
|
|
99
|
+
// Raw bash is intentionally absent: even commands that look read-only can hide redirection, command
|
|
100
|
+
// substitution, hooks, or an executable with side effects. Reviewers get dedicated read/search tools.
|
|
101
|
+
const safe = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
|
|
102
|
+
return (name) => safe.has(name) && declared(name);
|
|
103
|
+
}
|
|
104
|
+
return role.allowTools || role.denyTools ? declared : undefined;
|
|
105
|
+
}
|
|
93
106
|
export function loadRoles(cwd) {
|
|
94
|
-
const byId = new Map();
|
|
95
107
|
// lowest→highest precedence: plugins < org(B-end push) < global < .claude/agents < .hara/roles (project wins)
|
|
96
|
-
|
|
108
|
+
return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]).values()];
|
|
109
|
+
}
|
|
110
|
+
/** The project-independent layers only (plugins + org-pushed + ~/.hara/roles) — what the global agent
|
|
111
|
+
* index lists as "runs anywhere". Excludes cwd-derived layers by construction. */
|
|
112
|
+
export function loadGlobalRoles() {
|
|
113
|
+
return [...rolesFromDirs([...pluginRoleDirs(), orgRolesDir(), globalRolesDir()]).values()];
|
|
114
|
+
}
|
|
115
|
+
function rolesFromDirs(dirs) {
|
|
116
|
+
const byId = new Map();
|
|
117
|
+
for (const dir of dirs) {
|
|
97
118
|
if (!existsSync(dir))
|
|
98
119
|
continue;
|
|
99
120
|
for (const f of readdirSync(dir)) {
|
|
@@ -102,6 +123,9 @@ export function loadRoles(cwd) {
|
|
|
102
123
|
try {
|
|
103
124
|
const { fm, body } = parseFrontmatter(readFileSync(join(dir, f), "utf8"));
|
|
104
125
|
const id = fm.name || f.replace(/\.md$/, "");
|
|
126
|
+
const explicitReadOnly = /^(true|false)$/i.test(String(fm.readOnly ?? ""))
|
|
127
|
+
? String(fm.readOnly).toLowerCase() === "true"
|
|
128
|
+
: undefined;
|
|
105
129
|
byId.set(id, {
|
|
106
130
|
id,
|
|
107
131
|
description: fm.description || "",
|
|
@@ -112,6 +136,7 @@ export function loadRoles(cwd) {
|
|
|
112
136
|
model: fm.model && !/^(sonnet|opus|haiku|inherit)$/i.test(String(fm.model)) ? fm.model : undefined,
|
|
113
137
|
allowTools: Array.isArray(fm.allowTools) ? fm.allowTools : claudeTools(fm.tools),
|
|
114
138
|
denyTools: Array.isArray(fm.denyTools) ? fm.denyTools : undefined,
|
|
139
|
+
readOnly: explicitReadOnly ?? (id.toLowerCase() === "reviewer" ? true : undefined),
|
|
115
140
|
system: body,
|
|
116
141
|
});
|
|
117
142
|
}
|
|
@@ -120,7 +145,7 @@ export function loadRoles(cwd) {
|
|
|
120
145
|
}
|
|
121
146
|
}
|
|
122
147
|
}
|
|
123
|
-
return
|
|
148
|
+
return byId;
|
|
124
149
|
}
|
|
125
150
|
export function hasRoles(cwd) {
|
|
126
151
|
return loadRoles(cwd).length > 0;
|
|
@@ -140,10 +165,11 @@ End with a one-line summary of what changed.
|
|
|
140
165
|
name: reviewer
|
|
141
166
|
description: Reviews code for bugs, correctness, security, and style. Does not modify code.
|
|
142
167
|
owns: [review, audit, check, correctness, security, vulnerability, lint, quality]
|
|
143
|
-
allowTools: [read_file,
|
|
168
|
+
allowTools: [read_file, grep, glob, ls, codebase_search]
|
|
169
|
+
readOnly: true
|
|
144
170
|
---
|
|
145
171
|
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 —
|
|
172
|
+
security / style) with file:line and a suggested fix. Do NOT edit files — your tool surface is enforced read-only.
|
|
147
173
|
Be specific; skip nitpicks unless asked.
|
|
148
174
|
`,
|
|
149
175
|
"docs.md": `---
|
|
@@ -164,6 +190,7 @@ Each \`*.md\` here is a role-agent. Frontmatter:
|
|
|
164
190
|
- \`rejects\` — keywords that exclude this role (REJECT)
|
|
165
191
|
- \`model\` — optional model override
|
|
166
192
|
- \`allowTools\` / \`denyTools\` — restrict the role's tools
|
|
193
|
+
- \`readOnly\` — enforce read/search-only tools (defaults on for a role named \`reviewer\`)
|
|
167
194
|
|
|
168
195
|
Run \`hara org "<task>"\` to dispatch a task to the owning role, or \`hara org --role <id> "<task>"\`.
|
|
169
196
|
`,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Shared secret redaction for anything that may leave the live model context (session JSON, public
|
|
2
|
+
* feedback, logs). Deliberately conservative: a false positive hides a value in a local transcript;
|
|
3
|
+
* a false negative leaves a credential on disk. */
|
|
4
|
+
const CREDENTIAL_NAME = String.raw `(?:[A-Za-z][A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*|(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*)`;
|
|
5
|
+
const CREDENTIAL_FLAG = String.raw `--?(?:api[-_]?key|token|secret|password|passwd)`;
|
|
6
|
+
const PATTERNS = [
|
|
7
|
+
{
|
|
8
|
+
label: "private-key",
|
|
9
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
10
|
+
replace: "<REDACTED:private-key>",
|
|
11
|
+
},
|
|
12
|
+
{ label: "sk-key", re: /\bsk-[A-Za-z0-9_-]{8,}\b/g, replace: "sk-***" },
|
|
13
|
+
{ label: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: "gh*_***" },
|
|
14
|
+
{ label: "gitlab-token", re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g, replace: "glpat-***" },
|
|
15
|
+
{ label: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{16,}\b/g, replace: "xox*-***" },
|
|
16
|
+
{ label: "npm-token", re: /\bnpm_[A-Za-z0-9]{20,}\b/g, replace: "npm_***" },
|
|
17
|
+
{ label: "google-api-key", re: /\bAIza[A-Za-z0-9_-]{20,}\b/g, replace: "AIza***" },
|
|
18
|
+
{ label: "stripe-live-key", re: /\b(?:sk|rk)_live_[A-Za-z0-9]{12,}\b/g, replace: "stripe-live-***" },
|
|
19
|
+
{ label: "aws-key", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, replace: "AWS-KEY-***" },
|
|
20
|
+
{ label: "jwt", re: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: "JWT-***" },
|
|
21
|
+
{
|
|
22
|
+
label: "bearer-token",
|
|
23
|
+
re: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
24
|
+
replace: "Bearer ***",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "url-credential",
|
|
28
|
+
re: /(\bhttps?:\/\/[^\s/:@]+:)([^\s/@]{3,})(@)/gi,
|
|
29
|
+
replace: (_match, prefix, _value, suffix) => `${prefix}***${suffix}`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
// Quoted credentials may legitimately contain whitespace. Handle them before the unquoted patterns so
|
|
33
|
+
// `PASSWORD="correct horse battery staple"` is redacted as one value rather than leaking its tail.
|
|
34
|
+
label: "environment-credential",
|
|
35
|
+
re: /(\b[A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASSWD)\b\s*=\s*)(["'])([\s\S]*?)\2/g,
|
|
36
|
+
replace: (_match, prefix, quote) => `${prefix}${quote}***${quote}`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "credential-assignment",
|
|
40
|
+
re: new RegExp(`(\\b${CREDENTIAL_NAME}\\b["']?\\s*[:=]\\s*)(["'])([\\s\\S]*?)\\2`, "gi"),
|
|
41
|
+
replace: (_match, prefix, quote) => `${prefix}${quote}***${quote}`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: "authorization",
|
|
45
|
+
re: /(\bAuthorization\s*[:=]\s*)(["'])([\s\S]*?)\2/gi,
|
|
46
|
+
replace: (_match, prefix, quote) => `${prefix}${quote}***${quote}`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: "credential-flag",
|
|
50
|
+
re: new RegExp(`(${CREDENTIAL_FLAG}(?:\\s+|=))(["'])([\\s\\S]*?)\\2`, "gi"),
|
|
51
|
+
replace: (_match, prefix, quote) => `${prefix}${quote}***${quote}`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
// Generic all-caps environment variables such as OPENAI_KEY / SOME_SERVICE_PRIVATE_KEY. Keep this
|
|
55
|
+
// case-sensitive so ordinary prose/code identifiers ending in "key" are not over-redacted.
|
|
56
|
+
label: "environment-credential",
|
|
57
|
+
re: /(\b[A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASSWD)\b\s*=\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/g,
|
|
58
|
+
replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
// FEISHU_APP_SECRET=… · apiKey: "…" · "access_token": "…". The optional quote immediately
|
|
62
|
+
// after the key handles JSON without consuming the opening quote of the value.
|
|
63
|
+
label: "credential-assignment",
|
|
64
|
+
re: new RegExp(`(\\b${CREDENTIAL_NAME}\\b["']?\\s*[:=]\\s*)(?!["'])([^\\s"',;}\\]]{6,})`, "gi"),
|
|
65
|
+
replace: (_match, prefix) => `${prefix}***`,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
label: "authorization",
|
|
69
|
+
re: /(\bAuthorization\s*[:=]\s*)(?!["']|Bearer\s+)([^\s"',;}\]]{6,})/gi,
|
|
70
|
+
replace: (_match, prefix) => `${prefix}***`,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: "credential-flag",
|
|
74
|
+
re: new RegExp(`(${CREDENTIAL_FLAG}(?:\\s+|=))(?!["'])([^\\s"']{6,})`, "gi"),
|
|
75
|
+
replace: (_match, prefix) => `${prefix}***`,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
export function redactSensitiveText(text) {
|
|
79
|
+
let out = text;
|
|
80
|
+
const redactions = [];
|
|
81
|
+
for (const pattern of PATTERNS) {
|
|
82
|
+
pattern.re.lastIndex = 0;
|
|
83
|
+
out = out.replace(pattern.re, (...args) => {
|
|
84
|
+
redactions.push(pattern.label);
|
|
85
|
+
return typeof pattern.replace === "string" ? pattern.replace : pattern.replace(...args);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { text: out, redactions };
|
|
89
|
+
}
|
|
90
|
+
/** Structured tool/config data often carries an opaque value that has no recognizable token prefix. In that
|
|
91
|
+
* case the FIELD name is the evidence (`apiKey`, `access_token`, `FEISHU_APP_SECRET`, …). Keep this narrow
|
|
92
|
+
* enough not to erase ordinary fields such as `tokenCount` or `secretary`. */
|
|
93
|
+
function sensitiveFieldName(key) {
|
|
94
|
+
if (/^[A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASSWD)$/.test(key))
|
|
95
|
+
return true;
|
|
96
|
+
const compact = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
97
|
+
return (compact === "authorization" ||
|
|
98
|
+
compact.endsWith("apikey") ||
|
|
99
|
+
compact.endsWith("privatekey") ||
|
|
100
|
+
compact.endsWith("token") ||
|
|
101
|
+
compact.endsWith("secret") ||
|
|
102
|
+
compact.endsWith("password") ||
|
|
103
|
+
compact.endsWith("passwd"));
|
|
104
|
+
}
|
|
105
|
+
/** Deep-copy a JSON-shaped value while redacting every string. Session history contains secrets not only
|
|
106
|
+
* in user text but also in assistant tool inputs and tool results, so a top-level content-only pass is
|
|
107
|
+
* insufficient. The live value is never mutated. */
|
|
108
|
+
export function redactSensitiveValue(value) {
|
|
109
|
+
const hits = [];
|
|
110
|
+
const seen = new WeakMap();
|
|
111
|
+
const walk = (v) => {
|
|
112
|
+
if (typeof v === "string") {
|
|
113
|
+
const r = redactSensitiveText(v);
|
|
114
|
+
hits.push(...r.redactions);
|
|
115
|
+
return r.text;
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(v)) {
|
|
118
|
+
const prior = seen.get(v);
|
|
119
|
+
if (prior)
|
|
120
|
+
return prior;
|
|
121
|
+
const out = [];
|
|
122
|
+
seen.set(v, out);
|
|
123
|
+
for (const child of v)
|
|
124
|
+
out.push(walk(child));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
if (v && typeof v === "object") {
|
|
128
|
+
const prior = seen.get(v);
|
|
129
|
+
if (prior)
|
|
130
|
+
return prior;
|
|
131
|
+
const out = {};
|
|
132
|
+
seen.set(v, out);
|
|
133
|
+
for (const [key, child] of Object.entries(v)) {
|
|
134
|
+
let copied;
|
|
135
|
+
if (typeof child === "string" && child && sensitiveFieldName(key)) {
|
|
136
|
+
hits.push("credential-field");
|
|
137
|
+
copied = "***";
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
copied = walk(child);
|
|
141
|
+
}
|
|
142
|
+
// defineProperty keeps an own `__proto__` data key from mutating the clone's prototype.
|
|
143
|
+
Object.defineProperty(out, key, { value: copied, enumerable: true, writable: true, configurable: true });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
return v;
|
|
148
|
+
};
|
|
149
|
+
return { value: walk(value), redactions: hits };
|
|
150
|
+
}
|