@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,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
+ }