@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  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 +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. 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[]. Body = persona/system.
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
- const roleFilter = role?.allowTools
87
- ? (n) => role.allowTools.includes(n)
88
- : role?.denyTools
89
- ? (n) => !role.denyTools.includes(n)
90
- : null;
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
- for (const dir of [...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
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 [...byId.values()];
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, bash]
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 — you have read-only tools.
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
+ }