@nanhara/hara 0.122.0 → 0.122.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
@@ -0,0 +1,723 @@
1
+ // Hard read boundary for files that routinely contain credentials. This policy is evaluated before
2
+ // approval/full-auto: an autonomous agent must not turn a read-only tool into a secret-exfiltration path.
3
+ // Users who intentionally need a secret file in model context must opt in BEFORE launching hara with
4
+ // HARA_ALLOW_SENSITIVE_FILES=1; a model/tool call cannot grant itself that exception mid-run.
5
+ import { existsSync, lstatSync, opendirSync, realpathSync, statSync, } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
9
+ const SAFE_ENV_TEMPLATES = new Set(["example", "sample", "template", "dist", "defaults"]);
10
+ const PRIVATE_BASENAMES = new Set([
11
+ ".envrc",
12
+ ".netrc",
13
+ ".npmrc",
14
+ ".pypirc",
15
+ ".git-credentials",
16
+ ".dockerconfigjson",
17
+ "application_default_credentials.json",
18
+ ]);
19
+ const PRIVATE_DATA_NAME = /^(?:credentials?|secrets?|service[-_]?account)(?:\.(?:json|ya?ml|toml|ini|cfg|conf))?$/i;
20
+ const PRIVATE_KEY_NAME = /^(?:id_(?:rsa|ed25519|ecdsa)|.*\.(?:pem|key|p12|pfx|keystore))$/i;
21
+ const HARA_PRIVATE_FILES = new Set([
22
+ "config.json",
23
+ "qwen-oauth.json",
24
+ "profiles.json",
25
+ "serve.json",
26
+ "desk.json",
27
+ "desk-collector.json",
28
+ "org.json",
29
+ "org.json.legacy",
30
+ "flows.json",
31
+ "flows-log.jsonl",
32
+ "flows-pending.json",
33
+ "permissions.json",
34
+ ]);
35
+ const HARA_PRIVATE_DIRS = new Set(["sessions", "checkpoints", "index", "gateway", "cron"]);
36
+ const HARA_AGENT_CONTENT_DIRS = new Set([
37
+ "workspace", "plugins", "skills", "roles", "org-roles", "memory", "code-assets", "bin", "tts",
38
+ ]);
39
+ const WALK_IGNORE = new Set([
40
+ ".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
41
+ "coverage", ".venv", "venv", "__pycache__", "target", ".turbo", "vendor",
42
+ ]);
43
+ const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
44
+ const SSH_PUBLIC_STATE = /^(?:config|known_hosts(?:\.old)?|authorized_keys|.*\.pub)$/i;
45
+ const MAX_SCAN_ENTRIES = 250_000;
46
+ const toPosix = (value) => (sep === "/" ? value : value.split(sep).join("/"));
47
+ /** Windows resolves trailing dots/spaces and NTFS ADS aliases before opening a path. Apply that identity
48
+ * normalization to EVERY component on every OS so lexical checks cannot be bypassed before Windows sees it. */
49
+ const normalizedSecurityComponent = (name) => {
50
+ const withoutStream = /^[A-Za-z]:$/u.test(name) ? name : name.replace(/:.*$/u, "");
51
+ return withoutStream.replace(/[. ]+$/u, "").toLowerCase();
52
+ };
53
+ const normalizedSecurityBasename = normalizedSecurityComponent;
54
+ function securityPathComponents(path) {
55
+ return toPosix(resolve(path)).split("/").map(normalizedSecurityComponent);
56
+ }
57
+ function securityRelativeComponents(path, root) {
58
+ const candidate = securityPathComponents(path);
59
+ const base = securityPathComponents(root);
60
+ if (candidate.length < base.length || base.some((part, index) => candidate[index] !== part))
61
+ return null;
62
+ return candidate.slice(base.length);
63
+ }
64
+ function hasSecuritySuffix(path, suffix) {
65
+ const components = securityPathComponents(path);
66
+ const normalizedSuffix = suffix.map(normalizedSecurityComponent);
67
+ return components.length >= normalizedSuffix.length
68
+ && normalizedSuffix.every((part, index) => components[components.length - normalizedSuffix.length + index] === part);
69
+ }
70
+ export function sensitiveFilesAllowed(env = process.env) {
71
+ return env.HARA_ALLOW_SENSITIVE_FILES === "1";
72
+ }
73
+ function envFileReason(name) {
74
+ // NTFS alternate data streams (`.env::$DATA`) and trailing-dot/space aliases resolve to the same
75
+ // underlying file on Windows. Normalize them on every OS so the policy is testable everywhere.
76
+ const lower = normalizedSecurityBasename(name);
77
+ if (lower === ".env")
78
+ return "environment file";
79
+ if (!lower.startsWith(".env."))
80
+ return null;
81
+ const suffix = lower.slice(5);
82
+ const finalSuffix = suffix.split(".").at(-1) ?? suffix;
83
+ return SAFE_ENV_TEMPLATES.has(finalSuffix) ? null : "environment file";
84
+ }
85
+ function hasSegment(path, segment) {
86
+ return securityPathComponents(path).includes(normalizedSecurityComponent(segment));
87
+ }
88
+ function within(path, root) {
89
+ const rel = relative(root, path);
90
+ if (!rel)
91
+ return "";
92
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
93
+ return null;
94
+ return toPosix(rel);
95
+ }
96
+ function homeCredentialStateReason(path) {
97
+ const absolute = resolve(path);
98
+ const home = homedir();
99
+ if (securityRelativeComponents(absolute, join(home, ".aws")) !== null)
100
+ return "AWS credential state";
101
+ if (securityRelativeComponents(absolute, join(home, ".docker")) !== null)
102
+ return "Docker credential state";
103
+ if (securityRelativeComponents(absolute, join(home, ".kube")) !== null)
104
+ return "Kubernetes credential state";
105
+ const sshRel = securityRelativeComponents(absolute, join(home, ".ssh"));
106
+ if (sshRel !== null && sshRel.length > 0 && !SSH_PUBLIC_STATE.test(sshRel.at(-1) ?? ""))
107
+ return "SSH private key state";
108
+ return null;
109
+ }
110
+ function haraStateReason(path) {
111
+ const absolute = resolve(path);
112
+ // Apply the same Windows alias normalization to every Hara-state component, not only the final
113
+ // basename. `config.json::$DATA`, `sessions:stream/...`, and trailing-dot/space variants all address
114
+ // protected state on NTFS and must retain that identity on every platform where the policy is tested.
115
+ const parts = securityPathComponents(absolute);
116
+ const haraAt = parts.lastIndexOf(".hara");
117
+ if (haraAt >= 0) {
118
+ const after = parts.slice(haraAt + 1);
119
+ if (after.length === 1 && HARA_PRIVATE_FILES.has(after[0]))
120
+ return "private Hara state";
121
+ if (HARA_PRIVATE_DIRS.has(after[0]))
122
+ return "private Hara state";
123
+ if (after.at(-1)?.endsWith(".context-tokens.json") || after.at(-1)?.endsWith(".cursor"))
124
+ return "private Hara token state";
125
+ if (after.at(-1)?.startsWith("desk-enroll-key"))
126
+ return "private Hara enrollment key";
127
+ }
128
+ // ~/.hara is a runtime control plane, not a general project. Keep the explicitly agent-authored content
129
+ // surfaces readable and protect every other top-level/runtime entry by default. This catches future token
130
+ // files without waiting for another basename patch.
131
+ const homeState = join(homedir(), ".hara");
132
+ const segments = securityRelativeComponents(absolute, homeState);
133
+ if (segments !== null && segments.length > 0) {
134
+ const top = segments[0];
135
+ if (HARA_AGENT_CONTENT_DIRS.has(top))
136
+ return null;
137
+ if (segments[1] === "media")
138
+ return null; // authorized gateway attachments are an agent input surface
139
+ return "private Hara state";
140
+ }
141
+ return null;
142
+ }
143
+ /** Lexical policy. Exported for walkers/search workers that must filter a path without opening it. */
144
+ export function lexicalSensitiveFileReason(path) {
145
+ const name = basename(path);
146
+ const lower = normalizedSecurityBasename(name);
147
+ const envReason = envFileReason(name);
148
+ if (envReason)
149
+ return envReason;
150
+ if (PRIVATE_BASENAMES.has(lower))
151
+ return "credential file";
152
+ if (PRIVATE_DATA_NAME.test(lower))
153
+ return "credential data file";
154
+ if (PRIVATE_KEY_NAME.test(lower) && !lower.endsWith(".pub"))
155
+ return "private key file";
156
+ const components = securityPathComponents(path);
157
+ if (components.some((segment) => envFileReason(segment) !== null))
158
+ return "environment file state";
159
+ if (hasSecuritySuffix(path, [".aws", "credentials"]))
160
+ return "AWS credential file";
161
+ if (hasSecuritySuffix(path, [".docker", "config.json"]))
162
+ return "Docker credential file";
163
+ if (hasSecuritySuffix(path, [".kube", "config"]))
164
+ return "Kubernetes credential file";
165
+ if (hasSegment(path, ".direnv"))
166
+ return "direnv secret state";
167
+ if (lower === ".hara-profile")
168
+ return "private Hara routing state";
169
+ const credentialState = homeCredentialStateReason(path);
170
+ if (credentialState)
171
+ return credentialState;
172
+ const stateReason = haraStateReason(path);
173
+ if (stateReason)
174
+ return stateReason;
175
+ return null;
176
+ }
177
+ /**
178
+ * Resolve a prospective path through its nearest existing ancestor. Unlike `realpath(dirname(path))`,
179
+ * this also handles multiple missing tail components: `alias/missing/deep/file` is rebuilt below the
180
+ * canonical target of `alias`, so an existing parent symlink cannot hide a protected destination.
181
+ */
182
+ export function canonicalizeProspectivePath(path) {
183
+ let current = resolve(path);
184
+ const missingTail = [];
185
+ for (let depth = 0; depth < 128; depth++) {
186
+ try {
187
+ const ancestor = realpathSync.native(current);
188
+ return missingTail.length ? join(ancestor, ...missingTail) : ancestor;
189
+ }
190
+ catch (error) {
191
+ // ENOENT covers an absent component (including a path below one); ENOTDIR lets the caller receive
192
+ // the eventual filesystem error while still deriving a stable candidate for the security policy.
193
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR")
194
+ throw error;
195
+ }
196
+ const parent = dirname(current);
197
+ if (parent === current)
198
+ throw new Error(`cannot canonicalize prospective path '${path}'`);
199
+ missingTail.unshift(basename(current));
200
+ current = parent;
201
+ }
202
+ throw new Error(`prospective path exceeds 128 components: '${path}'`);
203
+ }
204
+ /** Check both the requested name and its canonical/prospective target, matching Codex/CC's symlink-safe deny-read. */
205
+ function baseSensitiveFileReason(path) {
206
+ const lexical = lexicalSensitiveFileReason(path);
207
+ if (lexical)
208
+ return lexical;
209
+ try {
210
+ const canonical = canonicalizeProspectivePath(path);
211
+ return lexicalSensitiveFileReason(canonical);
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ function ancestorDirs(start, cap = 128) {
218
+ const out = [];
219
+ let current = resolve(start);
220
+ for (let i = 0; i < cap; i++) {
221
+ out.push(current);
222
+ const parent = dirname(current);
223
+ if (parent === current)
224
+ return out;
225
+ current = parent;
226
+ }
227
+ throw new Error(`protected-file ancestor scan exceeded ${cap} directories`);
228
+ }
229
+ function canonicalOrResolved(path) {
230
+ try {
231
+ return realpathSync.native(path);
232
+ }
233
+ catch {
234
+ return resolve(path);
235
+ }
236
+ }
237
+ /** Prefer the enclosing VCS root; otherwise use the nearest recognizable project marker. The caller also
238
+ * checks direct entries in every ancestor, so a repo-root `.env` stays masked when a command runs in `src/`. */
239
+ function projectScanRootFrom(start) {
240
+ const ancestors = ancestorDirs(start);
241
+ let nearestMarker = null;
242
+ for (const dir of ancestors) {
243
+ if (existsSync(join(dir, ".git")))
244
+ return dir;
245
+ if (!nearestMarker && PROJECT_MARKERS.slice(1).some((marker) => existsSync(join(dir, marker))))
246
+ nearestMarker = dir;
247
+ }
248
+ return nearestMarker ?? resolve(start);
249
+ }
250
+ function projectScanRoots(cwd) {
251
+ return [...new Set([
252
+ projectScanRootFrom(resolve(cwd)),
253
+ projectScanRootFrom(canonicalOrResolved(cwd)),
254
+ ])];
255
+ }
256
+ function countEntry(budget) {
257
+ budget.entries++;
258
+ if (budget.entries > budget.maxEntries) {
259
+ throw new Error(`protected-file scan exceeded ${budget.maxEntries} filesystem entries; refusing an incomplete mask`);
260
+ }
261
+ }
262
+ function readDirBounded(dir, budget) {
263
+ let handle;
264
+ try {
265
+ // `readdirSync` allocates the entire directory before a caller can enforce a cap. Reading one Dirent at
266
+ // a time makes the filesystem-entry budget a real memory bound even for adversarial huge directories.
267
+ handle = opendirSync(dir, { encoding: "utf8" });
268
+ const entries = [];
269
+ for (;;) {
270
+ const entry = handle.readSync();
271
+ if (!entry)
272
+ break;
273
+ countEntry(budget);
274
+ entries.push(entry);
275
+ }
276
+ return entries;
277
+ }
278
+ catch (error) {
279
+ if (error instanceof Error && error.message.includes("protected-file scan exceeded"))
280
+ throw error;
281
+ throw new Error(`cannot safely scan protected files under '${dir}': ${error instanceof Error ? error.message : String(error)}`);
282
+ }
283
+ finally {
284
+ try {
285
+ handle?.closeSync();
286
+ }
287
+ catch { /* best effort */ }
288
+ }
289
+ }
290
+ function protectedDirectoryReason(path) {
291
+ const absolute = resolve(path);
292
+ const parts = securityPathComponents(absolute);
293
+ const haraAt = parts.lastIndexOf(".hara");
294
+ if (haraAt >= 0 && HARA_PRIVATE_DIRS.has(parts[haraAt + 1] ?? ""))
295
+ return "private Hara state";
296
+ if (normalizedSecurityBasename(basename(absolute)) === ".direnv")
297
+ return "direnv secret state";
298
+ for (const name of [".aws", ".docker", ".kube"]) {
299
+ if (securityRelativeComponents(absolute, join(homedir(), name)) !== null)
300
+ return `${name.slice(1)} credential state`;
301
+ }
302
+ return null;
303
+ }
304
+ /** Compare device+inode without opening either file. This closes the ordinary hard-link alias bypass for
305
+ * protected files discovered inside the same project/home boundary. */
306
+ export function sameFileIdentity(left, right) {
307
+ try {
308
+ const a = statSync(left, { bigint: true });
309
+ const b = statSync(right, { bigint: true });
310
+ return a.isFile() && b.isFile() && a.dev === b.dev && a.ino === b.ino;
311
+ }
312
+ catch {
313
+ return false;
314
+ }
315
+ }
316
+ function fileIdentityKey(path) {
317
+ try {
318
+ const info = statSync(path, { bigint: true });
319
+ return info.isFile() ? `${info.dev}:${info.ino}` : null;
320
+ }
321
+ catch {
322
+ return null;
323
+ }
324
+ }
325
+ function discoverSensitiveMasks(cwd, maxEntries = MAX_SCAN_ENTRIES) {
326
+ const found = new Set();
327
+ const directories = new Set();
328
+ const writeContainers = new Set();
329
+ const hardlinkCandidates = [];
330
+ const budget = { entries: 0, maxEntries };
331
+ const addExisting = (set, path) => {
332
+ if (!existsSync(path))
333
+ return;
334
+ set.add(resolve(path));
335
+ try {
336
+ set.add(realpathSync.native(path));
337
+ }
338
+ catch { /* lexical path remains useful */ }
339
+ };
340
+ const addLexical = (path) => {
341
+ if (baseSensitiveFileReason(path))
342
+ found.add(resolve(path));
343
+ };
344
+ const visitFile = (path, regular) => {
345
+ addLexical(path);
346
+ if (!regular)
347
+ return;
348
+ try {
349
+ if (statSync(path).nlink > 1)
350
+ hardlinkCandidates.push(resolve(path));
351
+ }
352
+ catch { /* raced away */ }
353
+ };
354
+ const walk = (root, skipContent = false) => {
355
+ const stack = [resolve(root)];
356
+ while (stack.length) {
357
+ const dir = stack.pop();
358
+ const entries = readDirBounded(dir, budget);
359
+ for (const entry of entries) {
360
+ const path = join(dir, entry.name);
361
+ if (entry.isDirectory()) {
362
+ if (WALK_IGNORE.has(entry.name))
363
+ continue;
364
+ if (entry.name.toLowerCase() === ".hara")
365
+ addExisting(writeContainers, path);
366
+ if (protectedDirectoryReason(path)) {
367
+ addExisting(directories, path);
368
+ continue; // represented by a Seatbelt subpath rule
369
+ }
370
+ if (skipContent && dir === resolve(root) && HARA_AGENT_CONTENT_DIRS.has(entry.name.toLowerCase()))
371
+ continue;
372
+ if (skipContent && entry.name === "media")
373
+ continue;
374
+ stack.push(path);
375
+ }
376
+ else if (entry.isFile() || entry.isSymbolicLink()) {
377
+ visitFile(path, entry.isFile());
378
+ }
379
+ }
380
+ }
381
+ };
382
+ const roots = projectScanRoots(cwd);
383
+ for (const root of roots)
384
+ walk(root);
385
+ // Also cover direct sensitive files above the selected project root (for example a monorepo or `$HOME`
386
+ // `.env`) without recursively walking the entire home/filesystem.
387
+ const rootKeys = roots.map((root) => resolve(root));
388
+ const ancestorStarts = new Set([resolve(cwd), canonicalOrResolved(cwd)]);
389
+ const scannedAncestors = new Set();
390
+ for (const start of ancestorStarts) {
391
+ for (const ancestor of ancestorDirs(start)) {
392
+ if (scannedAncestors.has(ancestor))
393
+ continue;
394
+ scannedAncestors.add(ancestor);
395
+ if (rootKeys.some((rootKey) => ancestor === rootKey || within(ancestor, rootKey) !== null))
396
+ continue;
397
+ for (const entry of readDirBounded(ancestor, budget)) {
398
+ if (entry.isFile() || entry.isSymbolicLink())
399
+ visitFile(join(ancestor, entry.name), entry.isFile());
400
+ }
401
+ }
402
+ }
403
+ const homeState = join(homedir(), ".hara");
404
+ if (existsSync(homeState)) {
405
+ addExisting(writeContainers, homeState);
406
+ addExisting(writeContainers, join(homeState, "weixin"));
407
+ walk(homeState, true);
408
+ }
409
+ // A command can run from /tmp or another checkout, so home-level `.env.*` and custom SSH key names
410
+ // cannot rely on the project-ancestor pass above.
411
+ if (existsSync(homedir())) {
412
+ for (const entry of readDirBounded(homedir(), budget)) {
413
+ if (entry.isFile() || entry.isSymbolicLink())
414
+ visitFile(join(homedir(), entry.name), entry.isFile());
415
+ }
416
+ }
417
+ const sshState = join(homedir(), ".ssh");
418
+ if (existsSync(sshState)) {
419
+ addExisting(writeContainers, sshState);
420
+ walk(sshState);
421
+ }
422
+ for (const path of [
423
+ join(homeState, "sessions"),
424
+ join(homeState, "checkpoints"),
425
+ join(homeState, "index"),
426
+ join(homeState, "gateway"),
427
+ join(homeState, "cron"),
428
+ join(homedir(), ".aws"),
429
+ join(homedir(), ".docker"),
430
+ join(homedir(), ".kube"),
431
+ ])
432
+ addExisting(directories, path);
433
+ for (const path of [
434
+ join(homedir(), ".env"),
435
+ join(homedir(), ".netrc"),
436
+ join(homedir(), ".npmrc"),
437
+ join(homedir(), ".pypirc"),
438
+ join(homedir(), ".git-credentials"),
439
+ join(homedir(), ".aws/credentials"),
440
+ join(homedir(), ".docker/config.json"),
441
+ join(homedir(), ".kube/config"),
442
+ join(homedir(), ".config/gcloud/application_default_credentials.json"),
443
+ ])
444
+ addLexical(path);
445
+ // Include every in-project alias of a protected inode in the Seatbelt literal mask. Hard links do not
446
+ // have a canonical "target", so pathname/realpath checks alone cannot close this bypass.
447
+ const protectedInodes = new Set([...found].map(fileIdentityKey).filter((key) => key !== null));
448
+ for (const candidate of hardlinkCandidates) {
449
+ if (found.has(candidate))
450
+ continue;
451
+ const key = fileIdentityKey(candidate);
452
+ if (key && protectedInodes.has(key))
453
+ found.add(candidate);
454
+ }
455
+ return { files: [...found], directories: [...directories], writeContainers: [...writeContainers] };
456
+ }
457
+ function sensitiveHardlinkReason(path) {
458
+ let info;
459
+ try {
460
+ info = statSync(path);
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ if (!info.isFile() || info.nlink < 2)
466
+ return null;
467
+ let candidates;
468
+ try {
469
+ candidates = discoverSensitiveMasks(dirname(path), 50_000).files;
470
+ }
471
+ catch {
472
+ // An incomplete inode search cannot prove a multi-linked file safe. This affects only hard-linked
473
+ // paths, so ordinary large repositories do not pay a compatibility penalty.
474
+ return "unverified hard-linked file in an incomplete protected-file scan";
475
+ }
476
+ for (const candidate of candidates) {
477
+ if (resolve(candidate) !== resolve(path) && sameFileIdentity(path, candidate))
478
+ return "hard link to protected credential file";
479
+ }
480
+ return null;
481
+ }
482
+ export function sensitiveFileReason(path) {
483
+ return baseSensitiveFileReason(path) ?? sensitiveHardlinkReason(path);
484
+ }
485
+ export function isSensitiveFilePath(path, env = process.env) {
486
+ return !sensitiveFilesAllowed(env) && sensitiveFileReason(path) !== null;
487
+ }
488
+ export function sensitiveFileError(path, action = "access", env = process.env) {
489
+ if (sensitiveFilesAllowed(env))
490
+ return null;
491
+ const reason = sensitiveFileReason(path);
492
+ if (!reason)
493
+ return null;
494
+ return (`Blocked: refusing to ${action} protected ${reason} '${path}'. ` +
495
+ "Hara's built-in file, context, and search paths block this before approval; trusted external extensions " +
496
+ "and non-macOS arbitrary shells are separate trust boundaries. " +
497
+ "If you intentionally want its contents sent to the model, restart hara with HARA_ALLOW_SENSITIVE_FILES=1.");
498
+ }
499
+ /** Broad-search exclusions. Templates are intentionally omitted from recursive grep as a safe bias; users
500
+ * can still read/search a specific .env.example directly. A post-filter remains in search.ts as defense. */
501
+ export const SENSITIVE_SEARCH_GLOBS = [
502
+ "**/.env",
503
+ "**/.env.*",
504
+ "**/.envrc",
505
+ "**/.direnv/**",
506
+ "**/.netrc",
507
+ "**/.npmrc",
508
+ "**/.pypirc",
509
+ "**/.git-credentials",
510
+ "**/credentials.json",
511
+ "**/credentials.yaml",
512
+ "**/credentials.yml",
513
+ "**/secrets.json",
514
+ "**/secrets.yaml",
515
+ "**/secrets.yml",
516
+ "**/service-account.json",
517
+ "**/service_account.json",
518
+ "**/application_default_credentials.json",
519
+ "**/id_rsa",
520
+ "**/id_ed25519",
521
+ "**/id_ecdsa",
522
+ "**/*.pem",
523
+ "**/*.key",
524
+ "**/*.p12",
525
+ "**/*.pfx",
526
+ "**/*.keystore",
527
+ "**/.hara/config.json",
528
+ "**/.hara/permissions.json",
529
+ "**/.hara/sessions/**",
530
+ "**/.hara/checkpoints/**",
531
+ "**/.hara/index/**",
532
+ "**/.hara/gateway/**",
533
+ "**/.hara/cron/**",
534
+ "**/.hara/weixin/creds.json",
535
+ "**/.hara/weixin/*.cursor",
536
+ "**/.hara/weixin/*.context-tokens.json",
537
+ ];
538
+ function expandShellCandidate(raw, cwd) {
539
+ let value = raw.replace(/^[<>]+|[,:]+$/g, "");
540
+ if (value === "~")
541
+ value = homedir();
542
+ else if (value.startsWith("~/"))
543
+ value = join(homedir(), value.slice(2));
544
+ return isAbsolute(value) ? value : resolve(cwd, value);
545
+ }
546
+ /** `ps` has two incompatible option dialects: lowercase `-e` selects every process, while a bare BSD
547
+ * option containing `e` or uppercase `-E` appends each process environment. Only the latter are secret
548
+ * disclosure modes. Exported so readonly auto-approval uses the exact same classification. */
549
+ export function psArgumentsExposeEnvironment(args) {
550
+ return args.some((word) => {
551
+ const arg = word.replace(/^['"]|['"]$/g, "");
552
+ if (/^-[^-]*E/u.test(arg))
553
+ return true;
554
+ return !arg.startsWith("-") && /^[A-Za-z]*e[A-Za-z]*$/u.test(arg);
555
+ });
556
+ }
557
+ function environmentDumpReason(command) {
558
+ const segments = command.split(/(?:&&|\|\||[;|\n])/);
559
+ for (const segment of segments) {
560
+ const words = segment.trim().replace(/^[({]+|[)}]+$/g, "").split(/\s+/).filter(Boolean);
561
+ if (!words.length)
562
+ continue;
563
+ let commandAt = words.findIndex((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
564
+ if (commandAt < 0)
565
+ continue;
566
+ while (["command", "builtin", "exec", "nohup"].includes(basename(words[commandAt]?.replace(/^['"]|['"]$/g, "")))) {
567
+ commandAt++;
568
+ while (words[commandAt]?.startsWith("-"))
569
+ commandAt++;
570
+ }
571
+ let prog = basename(words[commandAt]?.replace(/^['"]|['"]$/g, "") ?? "");
572
+ let rest = words.slice(commandAt + 1);
573
+ if (prog === "env") {
574
+ const nested = rest.filter((word) => !word.startsWith("-") && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word));
575
+ if (!nested.length)
576
+ return "env exposes the process environment";
577
+ prog = basename(nested[0].replace(/^['"]|['"]$/g, ""));
578
+ rest = nested.slice(1);
579
+ }
580
+ if (prog === "printenv")
581
+ return "printenv exposes the process environment";
582
+ if (prog === "set" && rest.length === 0)
583
+ return "set exposes shell variables";
584
+ if (prog === "export" && (rest.length === 0 || rest.some((word) => /^-p$/.test(word))))
585
+ return "export exposes the process environment";
586
+ if (["declare", "typeset"].includes(prog) && (rest.length === 0 || rest.some((word) => /^-[^\s]*x/.test(word)))) {
587
+ return `${prog} exposes exported shell variables`;
588
+ }
589
+ if (prog === "ps" && psArgumentsExposeEnvironment(rest))
590
+ return "ps exposes process environments";
591
+ }
592
+ if (/\/(?:proc\/[^/]+|dev\/fd\/[^/]+)\/environ\b/.test(command))
593
+ return "process environment pseudo-file";
594
+ if (/\bjq\b[^\n;&|]*(?:\benv\b|\$ENV\b)/.test(command))
595
+ return "jq environment access";
596
+ return null;
597
+ }
598
+ /** Conservative shell preflight. It is a hard deny, not an approval hint, so full-auto cannot bypass it. */
599
+ export function sensitiveShellCommandReason(command, cwd, env = process.env) {
600
+ if (sensitiveFilesAllowed(env))
601
+ return null;
602
+ const dump = environmentDumpReason(command);
603
+ if (dump)
604
+ return dump;
605
+ // Tokenize only literal path-like chunks; command substitution/variable tricks are separately forbidden
606
+ // by the system contract and the macOS OS-level read mask. Ordinary code searches for `\.env` retain the
607
+ // backslash and do not match this literal-file check.
608
+ const chunks = command.match(/[^\s'"`=<>|;&(),]+/g) ?? [];
609
+ for (const raw of chunks) {
610
+ if (raw.startsWith("-") || raw.includes("\\.env"))
611
+ continue;
612
+ const candidate = expandShellCandidate(raw, cwd);
613
+ const reason = sensitiveFileReason(candidate);
614
+ if (reason)
615
+ return `${reason}: ${raw}`;
616
+ // Git revision pathspecs (`HEAD:.env`) do not look like filesystem paths until git resolves them.
617
+ const colon = raw.lastIndexOf(":");
618
+ if (colon > 0 && !/^\w+:\/\//.test(raw)) {
619
+ const revisionPath = raw.slice(colon + 1);
620
+ const revisionReason = sensitiveFileReason(resolve(cwd, revisionPath));
621
+ if (revisionReason)
622
+ return `${revisionReason} in revision path: ${raw}`;
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+ /** Guard explicit path/file arguments passed to an opaque extension tool. This cannot make a configured
628
+ * MCP server trustworthy (it may read files at startup), but it prevents the ordinary filesystem-MCP path
629
+ * bypass and keeps the limitation explicit rather than silently treating MCP as an in-process read tool. */
630
+ export function sensitiveStructuredInputReason(input, cwd) {
631
+ const visit = (value, key = "", depth = 0) => {
632
+ if (depth > 8)
633
+ return "input nesting exceeds protected-path inspection depth";
634
+ if (typeof value === "string" && /(?:path|file|dir|root|cwd|uri|location)/i.test(key)) {
635
+ let raw = value.trim();
636
+ if (/^file:\/\//i.test(raw)) {
637
+ try {
638
+ raw = fileURLToPath(raw);
639
+ }
640
+ catch { /* keep the original */ }
641
+ }
642
+ const reason = sensitiveFileReason(expandShellCandidate(raw, cwd));
643
+ if (reason)
644
+ return `${reason}: ${key || "path"}`;
645
+ }
646
+ if (Array.isArray(value)) {
647
+ for (const item of value) {
648
+ const reason = visit(item, key, depth + 1);
649
+ if (reason)
650
+ return reason;
651
+ }
652
+ }
653
+ else if (value && typeof value === "object") {
654
+ for (const [childKey, child] of Object.entries(value)) {
655
+ const reason = visit(child, childKey, depth + 1);
656
+ if (reason)
657
+ return reason;
658
+ }
659
+ }
660
+ return null;
661
+ };
662
+ return sensitiveFilesAllowed() ? null : visit(input);
663
+ }
664
+ export function existingSensitiveSeatbeltMasks(cwd, fileCap = 4096, maxEntries = MAX_SCAN_ENTRIES) {
665
+ if (sensitiveFilesAllowed())
666
+ return { files: [], directories: [], writeContainers: [] };
667
+ const discovery = discoverSensitiveMasks(cwd, maxEntries);
668
+ const found = new Set();
669
+ const add = (path) => {
670
+ if (!existsSync(path))
671
+ return;
672
+ try {
673
+ const info = lstatSync(path);
674
+ if (!info.isFile() && !info.isSymbolicLink())
675
+ return;
676
+ const aliases = new Set([resolve(path)]);
677
+ try {
678
+ aliases.add(realpathSync.native(path));
679
+ }
680
+ catch { /* lexical path is still useful */ }
681
+ const additions = [...aliases].filter((alias) => !found.has(alias));
682
+ if (found.size + additions.length > fileCap) {
683
+ throw new Error(`protected-file scan exceeded ${fileCap} files; refusing to build an incomplete read mask`);
684
+ }
685
+ for (const alias of additions)
686
+ found.add(alias);
687
+ }
688
+ catch (error) {
689
+ if (error instanceof Error && error.message.startsWith("protected-file scan exceeded"))
690
+ throw error;
691
+ /* race/unreadable path: direct policy still fails closed when named */
692
+ }
693
+ };
694
+ // Discovery starts at the enclosing project root, checks direct ancestor entries, and covers every
695
+ // private `~/.hara` runtime file plus the standard user credential stores. Traversal itself is bounded;
696
+ // exceeding either cap throws, because launching Seatbelt with an incomplete deny mask would be unsafe.
697
+ for (const path of discovery.files)
698
+ add(path);
699
+ return { files: [...found], directories: discovery.directories, writeContainers: discovery.writeContainers };
700
+ }
701
+ export function existingSensitiveReadPaths(cwd, cap = 4096) {
702
+ return existingSensitiveSeatbeltMasks(cwd, cap).files;
703
+ }
704
+ /** Private state directories that Seatbelt can deny as a whole, including old checkpoint/index blobs whose
705
+ * filenames are hashes and therefore cannot be recognized by a basename matcher. */
706
+ export function existingSensitiveReadDirectories(cwd, maxEntries = MAX_SCAN_ENTRIES) {
707
+ return existingSensitiveSeatbeltMasks(cwd, 4096, maxEntries).directories;
708
+ }
709
+ /** Directory entries whose rename would relocate protected descendants to an unmasked pathname. These are
710
+ * write-only masks: `~/.hara/workspace` and public SSH metadata remain readable, while the containing
711
+ * control-plane/key directory itself cannot be renamed or unlinked by a shell command. */
712
+ export function existingSensitiveWriteContainerPaths(cwd, maxEntries = MAX_SCAN_ENTRIES) {
713
+ return existingSensitiveSeatbeltMasks(cwd, 4096, maxEntries).writeContainers;
714
+ }
715
+ /** Useful in tests and diagnostics without revealing values. */
716
+ export function describeSensitivePath(path, cwd = process.cwd()) {
717
+ const absolute = isAbsolute(path) ? path : resolve(cwd, path);
718
+ const reason = sensitiveFileReason(absolute);
719
+ if (!reason)
720
+ return null;
721
+ const rel = relative(cwd, absolute);
722
+ return `${reason}: ${rel && !rel.startsWith("..") ? rel : basename(absolute)}`;
723
+ }