@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
@@ -0,0 +1,299 @@
1
+ // Owner-only migration for Hara's local control plane. New writers should still create private files
2
+ // directly, but this repairs installations created by older releases and makes ~/.hara non-traversable by
3
+ // other local users before credentials/session state are read.
4
+ import { closeSync, chmodSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, realpathSync, unlinkSync, } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, join, resolve, sep } from "node:path";
7
+ import { FileReadLimitError, MAX_EDIT_READ_BYTES, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
8
+ const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
9
+ const tightenedHomes = new Set();
10
+ const DEFAULT_MIGRATION_CAP = 50_000;
11
+ function isMissing(error) {
12
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
13
+ }
14
+ function chmodPrivate(path, mode) {
15
+ try {
16
+ chmodSync(path, mode);
17
+ }
18
+ catch (error) {
19
+ // Windows does not implement POSIX ownership modes. On POSIX, a failed repair is a security failure:
20
+ // propagate it so startup can retry instead of permanently caching an incomplete migration.
21
+ if (process.platform !== "win32")
22
+ throw error;
23
+ }
24
+ }
25
+ function checkedPrivateComponent(component) {
26
+ if (!component || component === "." || component === ".." || basename(component) !== component || component.includes(sep) || component.includes("\0")) {
27
+ throw new Error(`invalid private Hara state path component '${component}'`);
28
+ }
29
+ return component;
30
+ }
31
+ function verifyPrivateDirectory(identity) {
32
+ const info = lstatSync(identity.path);
33
+ if (!info.isDirectory()
34
+ || info.isSymbolicLink()
35
+ || info.dev !== identity.dev
36
+ || info.ino !== identity.ino
37
+ || realpathSync.native(identity.path) !== identity.path)
38
+ throw new Error(`private Hara state directory changed: '${identity.path}'`);
39
+ }
40
+ /** Tighten one directory through a no-follow descriptor so a pre-existing link is never chmod'd. */
41
+ function inspectPrivateDirectory(path) {
42
+ const before = lstatSync(path);
43
+ if (!before.isDirectory() || before.isSymbolicLink()) {
44
+ throw new Error(`refusing private Hara state directory: '${path}' is not a real directory`);
45
+ }
46
+ if (realpathSync.native(path) !== path) {
47
+ throw new Error(`refusing private Hara state directory: '${path}' contains a symbolic-link component`);
48
+ }
49
+ return { path, dev: before.dev, ino: before.ino };
50
+ }
51
+ /** Tighten one directory through a no-follow descriptor so a pre-existing link is never chmod'd. */
52
+ function verifyAndTightenPrivateDirectory(path) {
53
+ const inspected = inspectPrivateDirectory(path);
54
+ // Opening a directory descriptor is not portable to Windows. There, re-checking the link identity around
55
+ // the validation retains the same fail-closed preseed policy and POSIX ownership bits do not apply;
56
+ // POSIX uses O_NOFOLLOW + fchmod.
57
+ if (process.platform === "win32") {
58
+ // no POSIX ownership mode to repair
59
+ }
60
+ else {
61
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
62
+ const directoryOnly = typeof constants.O_DIRECTORY === "number" ? constants.O_DIRECTORY : 0;
63
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow | directoryOnly);
64
+ try {
65
+ const opened = fstatSync(fd);
66
+ if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
67
+ throw new Error(`private Hara state directory changed while opening: '${path}'`);
68
+ }
69
+ fchmodSync(fd, 0o700);
70
+ }
71
+ finally {
72
+ closeSync(fd);
73
+ }
74
+ }
75
+ const after = lstatSync(path);
76
+ if (after.dev !== inspected.dev || after.ino !== inspected.ino) {
77
+ throw new Error(`private Hara state directory changed while tightening: '${path}'`);
78
+ }
79
+ const identity = { path, dev: inspected.dev, ino: inspected.ino };
80
+ verifyPrivateDirectory(identity);
81
+ return identity;
82
+ }
83
+ /**
84
+ * Create a private control-plane subdirectory one component at a time below a canonical trusted base.
85
+ * Existing `.hara`/child components must be real directories; recursive mkdir/chmod is intentionally
86
+ * avoided because either operation would follow a malicious repository-provided link.
87
+ */
88
+ export function ensurePrivateStateSubdirectory(base, components, tightenExistingFrom = 0) {
89
+ if (!Number.isInteger(tightenExistingFrom) || tightenExistingFrom < 0 || tightenExistingFrom > components.length) {
90
+ throw new TypeError("private Hara state tighten boundary is out of range");
91
+ }
92
+ let current = realpathSync.native(resolve(base));
93
+ const baseInfo = lstatSync(current);
94
+ if (!baseInfo.isDirectory() || baseInfo.isSymbolicLink()) {
95
+ throw new Error(`private Hara state base is not a canonical directory: '${current}'`);
96
+ }
97
+ let parent = { path: current, dev: baseInfo.dev, ino: baseInfo.ino };
98
+ verifyPrivateDirectory(parent);
99
+ for (let index = 0; index < components.length; index++) {
100
+ const raw = components[index];
101
+ const component = checkedPrivateComponent(raw);
102
+ verifyPrivateDirectory(parent);
103
+ current = join(current, component);
104
+ try {
105
+ const existing = lstatSync(current);
106
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
107
+ throw new Error(`refusing private Hara state directory: '${current}' is not a real directory`);
108
+ }
109
+ }
110
+ catch (error) {
111
+ if (error?.code !== "ENOENT")
112
+ throw error;
113
+ // Parent identity is checked immediately before the namespace mutation. mkdir is non-recursive and
114
+ // therefore cannot walk through a newly supplied child link.
115
+ verifyPrivateDirectory(parent);
116
+ try {
117
+ mkdirSync(current, { mode: 0o700 });
118
+ }
119
+ catch (mkdirError) {
120
+ if (mkdirError?.code !== "EEXIST")
121
+ throw mkdirError;
122
+ }
123
+ }
124
+ parent = index >= tightenExistingFrom
125
+ ? verifyAndTightenPrivateDirectory(current)
126
+ : inspectPrivateDirectory(current);
127
+ verifyPrivateDirectory(parent);
128
+ }
129
+ return parent;
130
+ }
131
+ /** Open/read one internal state file without following aliases, reject hard links, and repair mode by fd. */
132
+ export async function readPrivateStateFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
133
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
134
+ const limit = Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
135
+ let verified;
136
+ try {
137
+ verified = await openVerifiedRegularFileNoFollow(path, {
138
+ action: "read private Hara state",
139
+ rejectHardLinks: true,
140
+ protectSensitive: false,
141
+ });
142
+ }
143
+ catch (error) {
144
+ if (error?.code === "ENOENT")
145
+ return null;
146
+ throw error;
147
+ }
148
+ try {
149
+ const { handle } = verified;
150
+ try {
151
+ await handle.chmod(0o600);
152
+ }
153
+ catch (error) {
154
+ if (process.platform !== "win32")
155
+ throw error;
156
+ }
157
+ const before = await handle.stat();
158
+ if (before.size > limit)
159
+ throw new FileReadLimitError(path, limit);
160
+ const chunks = [];
161
+ let total = 0;
162
+ let position = 0;
163
+ while (total <= limit) {
164
+ const want = Math.min(64 * 1024, limit + 1 - total);
165
+ const buffer = Buffer.allocUnsafe(want);
166
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
167
+ if (!bytesRead)
168
+ break;
169
+ chunks.push(buffer.subarray(0, bytesRead));
170
+ total += bytesRead;
171
+ position += bytesRead;
172
+ }
173
+ if (total > limit)
174
+ throw new FileReadLimitError(path, limit);
175
+ const after = await handle.stat();
176
+ verifyOpenedRegularFileSync(path, after, {
177
+ action: "read private Hara state",
178
+ rejectHardLinks: true,
179
+ protectSensitive: false,
180
+ });
181
+ if (after.dev !== before.dev
182
+ || after.ino !== before.ino
183
+ || after.size !== before.size
184
+ || after.mtimeMs !== before.mtimeMs
185
+ || after.ctimeMs !== before.ctimeMs)
186
+ throw new Error(`private Hara state file changed while reading: '${path}'`);
187
+ return {
188
+ text: Buffer.concat(chunks, total).toString("utf8"),
189
+ dev: after.dev,
190
+ ino: after.ino,
191
+ mode: after.mode & 0o777,
192
+ nlink: after.nlink,
193
+ size: after.size,
194
+ mtimeMs: after.mtimeMs,
195
+ ctimeMs: after.ctimeMs,
196
+ };
197
+ }
198
+ finally {
199
+ await verified.handle.close().catch(() => { });
200
+ }
201
+ }
202
+ /** Remove only the exact single-link inode previously read from the same still-bound private directory. */
203
+ export function removePrivateStateFile(path, expected, directory) {
204
+ verifyPrivateDirectory(directory);
205
+ if (resolve(path) !== join(directory.path, basename(path))) {
206
+ throw new Error(`private Hara state file is outside '${directory.path}'`);
207
+ }
208
+ const current = lstatSync(path);
209
+ if (!current.isFile()
210
+ || current.isSymbolicLink()
211
+ || current.nlink !== 1
212
+ || current.dev !== expected.dev
213
+ || current.ino !== expected.ino
214
+ || (current.mode & 0o777) !== expected.mode
215
+ || current.size !== expected.size
216
+ || current.mtimeMs !== expected.mtimeMs
217
+ || current.ctimeMs !== expected.ctimeMs)
218
+ throw new Error(`private Hara state file changed before removal: '${path}'`);
219
+ verifyPrivateDirectory(directory);
220
+ unlinkSync(path);
221
+ }
222
+ function consumeBudget(path, budget) {
223
+ budget.seen++;
224
+ if (budget.seen > budget.cap) {
225
+ throw new Error(`private Hara state migration exceeded ${budget.cap} entries at '${path}'; ` +
226
+ "refusing to cache an incomplete permission repair");
227
+ }
228
+ }
229
+ function tightenTree(root, budget) {
230
+ const stack = [root];
231
+ while (stack.length) {
232
+ const path = stack.pop();
233
+ consumeBudget(path, budget);
234
+ let info;
235
+ try {
236
+ info = lstatSync(path);
237
+ }
238
+ catch (error) {
239
+ if (isMissing(error))
240
+ continue; // a concurrently removed derived file needs no repair
241
+ throw error;
242
+ }
243
+ if (info.isSymbolicLink())
244
+ continue; // never chmod a target chosen through a replaceable link
245
+ if (info.isDirectory()) {
246
+ chmodPrivate(path, 0o700);
247
+ const entries = readdirSync(path);
248
+ for (const entry of entries)
249
+ stack.push(join(path, entry));
250
+ }
251
+ else if (info.isFile()) {
252
+ chmodPrivate(path, 0o600);
253
+ }
254
+ }
255
+ }
256
+ /** Repair one Hara home. Exported with an injected path for offline tests. */
257
+ export function tightenPrivateHaraState(home = homedir(), cap = DEFAULT_MIGRATION_CAP) {
258
+ if (!Number.isSafeInteger(cap) || cap < 1)
259
+ throw new TypeError("private Hara state migration cap must be a positive integer");
260
+ const root = join(home, ".hara");
261
+ let rootInfo;
262
+ try {
263
+ rootInfo = lstatSync(root);
264
+ }
265
+ catch (error) {
266
+ if (!isMissing(error))
267
+ throw error;
268
+ mkdirSync(root, { recursive: true, mode: 0o700 });
269
+ rootInfo = lstatSync(root);
270
+ }
271
+ // `chmod` follows symlinks. Reject the control-plane root before touching it so ~/.hara cannot be
272
+ // redirected to an unrelated user directory by a local link/race left from an older installation.
273
+ if (rootInfo.isSymbolicLink())
274
+ throw new Error(`refusing private Hara state migration: '${root}' is a symbolic link`);
275
+ if (!rootInfo.isDirectory())
276
+ throw new Error(`refusing private Hara state migration: '${root}' is not a directory`);
277
+ chmodPrivate(root, 0o700);
278
+ const budget = { seen: 0, cap };
279
+ const entries = readdirSync(root, { withFileTypes: true });
280
+ for (const entry of entries) {
281
+ const path = join(root, entry.name);
282
+ // Re-check with lstat inside tightenTree instead of trusting a possibly stale Dirent. It also skips
283
+ // symlinks, including top-level file links, without following their targets.
284
+ if (entry.isFile() || (entry.isDirectory() && PRIVATE_TREES.has(entry.name)))
285
+ tightenTree(path, budget);
286
+ }
287
+ }
288
+ /** Process-once startup migration; repeated loadConfig calls stay cheap. */
289
+ export function ensurePrivateHaraState(home = homedir(), cap = DEFAULT_MIGRATION_CAP) {
290
+ if (tightenedHomes.has(home))
291
+ return;
292
+ tightenPrivateHaraState(home, cap);
293
+ // Cache only after the whole migration succeeds. Cap/permission failures are therefore retryable.
294
+ tightenedHomes.add(home);
295
+ }
296
+ /** @internal test helper. */
297
+ export function resetPrivateHaraStateForTests() {
298
+ tightenedHomes.clear();
299
+ }
@@ -0,0 +1,6 @@
1
+ // One process-wide, launch-time trust decision for repository-controlled configuration and identity pins.
2
+ // Capture at module evaluation so later code (including extensions) cannot widen trust by mutating process.env.
3
+ const trustedAtStartup = process.env.HARA_TRUST_PROJECT_CONFIG === "1";
4
+ export function projectRepositoryTrustedAtStartup() {
5
+ return trustedAtStartup;
6
+ }
@@ -1,6 +1,8 @@
1
1
  /** Shared secret redaction for anything that may leave the live model context (session JSON, public
2
2
  * feedback, logs). Deliberately conservative: a false positive hides a value in a local transcript;
3
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)`;
4
6
  const PATTERNS = [
5
7
  {
6
8
  label: "private-key",
@@ -9,6 +11,11 @@ const PATTERNS = [
9
11
  },
10
12
  { label: "sk-key", re: /\bsk-[A-Za-z0-9_-]{8,}\b/g, replace: "sk-***" },
11
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-***" },
12
19
  { label: "aws-key", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, replace: "AWS-KEY-***" },
13
20
  { label: "jwt", re: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: "JWT-***" },
14
21
  {
@@ -16,6 +23,33 @@ const PATTERNS = [
16
23
  re: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
17
24
  replace: "Bearer ***",
18
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
+ },
19
53
  {
20
54
  // Generic all-caps environment variables such as OPENAI_KEY / SOME_SERVICE_PRIVATE_KEY. Keep this
21
55
  // case-sensitive so ordinary prose/code identifiers ending in "key" are not over-redacted.
@@ -27,18 +61,18 @@ const PATTERNS = [
27
61
  // FEISHU_APP_SECRET=… · apiKey: "…" · "access_token": "…". The optional quote immediately
28
62
  // after the key handles JSON without consuming the opening quote of the value.
29
63
  label: "credential-assignment",
30
- re: /(\b(?:[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_.-]*)\b["']?\s*[:=]\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
31
- replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
64
+ re: new RegExp(`(\\b${CREDENTIAL_NAME}\\b["']?\\s*[:=]\\s*)(?!["'])([^\\s"',;}\\]]{6,})`, "gi"),
65
+ replace: (_match, prefix) => `${prefix}***`,
32
66
  },
33
67
  {
34
68
  label: "authorization",
35
- re: /(\bAuthorization\s*[:=]\s*)(?!Bearer\s+)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
36
- replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
69
+ re: /(\bAuthorization\s*[:=]\s*)(?!["']|Bearer\s+)([^\s"',;}\]]{6,})/gi,
70
+ replace: (_match, prefix) => `${prefix}***`,
37
71
  },
38
72
  {
39
73
  label: "credential-flag",
40
- re: /(--?(?:api[-_]?key|token|secret|password|passwd)(?:\s+|=))(["']?)([^\s"']{6,})(["']?)/gi,
41
- replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
74
+ re: new RegExp(`(${CREDENTIAL_FLAG}(?:\\s+|=))(?!["'])([^\\s"']{6,})`, "gi"),
75
+ replace: (_match, prefix) => `${prefix}***`,
42
76
  },
43
77
  ];
44
78
  export function redactSensitiveText(text) {
@@ -53,21 +87,62 @@ export function redactSensitiveText(text) {
53
87
  }
54
88
  return { text: out, redactions };
55
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
+ }
56
105
  /** Deep-copy a JSON-shaped value while redacting every string. Session history contains secrets not only
57
106
  * in user text but also in assistant tool inputs and tool results, so a top-level content-only pass is
58
107
  * insufficient. The live value is never mutated. */
59
108
  export function redactSensitiveValue(value) {
60
109
  const hits = [];
110
+ const seen = new WeakMap();
61
111
  const walk = (v) => {
62
112
  if (typeof v === "string") {
63
113
  const r = redactSensitiveText(v);
64
114
  hits.push(...r.redactions);
65
115
  return r.text;
66
116
  }
67
- if (Array.isArray(v))
68
- return v.map(walk);
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
+ }
69
127
  if (v && typeof v === "object") {
70
- return Object.fromEntries(Object.entries(v).map(([k, child]) => [k, walk(child)]));
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;
71
146
  }
72
147
  return v;
73
148
  };