@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
package/dist/fs-read.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // Bounded streaming line reader for files too large to load as one string. It stores only the requested
2
2
  // window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
3
- import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
3
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync, } from "node:fs";
4
4
  import { open } from "node:fs/promises";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import { sensitiveFileError } from "./security/sensitive-files.js";
5
7
  export class BinaryFileError extends Error {
6
8
  constructor(path) {
7
9
  super(`${path} appears to be binary (NUL byte detected)`);
@@ -31,6 +33,306 @@ const MAX_SLICE_LINES = 2_000;
31
33
  export const MAX_EDIT_READ_BYTES = 64 * 1024 * 1024;
32
34
  const READ_CHUNK_BYTES = 64 * 1024;
33
35
  const MAX_PREFIX_CHARS = 1_000_000;
36
+ /** A context-loader rejected a protected path before any bytes could enter a model prompt. */
37
+ export class ProtectedContextFileError extends Error {
38
+ code = "HARA_PROTECTED_CONTEXT_FILE";
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "ProtectedContextFileError";
42
+ }
43
+ }
44
+ export class HardLinkedFileError extends Error {
45
+ code = "HARA_HARD_LINKED_FILE";
46
+ constructor(path) {
47
+ super(`${path} has multiple hard links; its protected identity cannot be established safely`);
48
+ this.name = "HardLinkedFileError";
49
+ }
50
+ }
51
+ /** Resolve a user-facing path once, checking both its lexical name and canonical target. Direct tools then
52
+ * open this canonical path with O_NOFOLLOW, so a safe symlink remains usable without a retarget race. */
53
+ export function resolveVerifiedModelPath(path, action = "read") {
54
+ const denied = sensitiveFileError(path, action);
55
+ if (denied)
56
+ throw new ProtectedContextFileError(denied);
57
+ const canonical = realpathSync.native(path);
58
+ const targetDenied = sensitiveFileError(canonical, action);
59
+ if (targetDenied)
60
+ throw new ProtectedContextFileError(targetDenied);
61
+ return canonical;
62
+ }
63
+ /**
64
+ * Common post-open identity check for security-sensitive readers. `opened` MUST be the fstat result for an
65
+ * O_NOFOLLOW descriptor. It verifies that the current lexical/canonical name still identifies that inode,
66
+ * rejects hard-link aliases by default, and applies the central protected-file policy to the actual target.
67
+ */
68
+ export function verifyOpenedRegularFileSync(path, opened, options = {}) {
69
+ if (!opened.isFile())
70
+ throw new NonRegularFileError(path);
71
+ if (options.rejectHardLinks !== false && opened.nlink > 1)
72
+ throw new HardLinkedFileError(path);
73
+ const canonical = realpathSync.native(path);
74
+ if (options.protectSensitive !== false) {
75
+ const targetDenied = sensitiveFileError(canonical, options.action ?? "read");
76
+ if (targetDenied)
77
+ throw new ProtectedContextFileError(targetDenied);
78
+ }
79
+ const currentLink = lstatSync(path);
80
+ const currentTarget = statSync(canonical);
81
+ if (currentLink.isSymbolicLink()
82
+ || currentLink.dev !== opened.dev
83
+ || currentLink.ino !== opened.ino
84
+ || currentTarget.dev !== opened.dev
85
+ || currentTarget.ino !== opened.ino) {
86
+ throw new Error(`refusing to access ${path}: path changed while opening it`);
87
+ }
88
+ return canonical;
89
+ }
90
+ /** Open + validate a regular file for direct tools that need to keep reading the verified descriptor. */
91
+ export async function openVerifiedRegularFileNoFollow(path, options = {}) {
92
+ if (options.protectSensitive !== false) {
93
+ const denied = sensitiveFileError(path, options.action ?? "read");
94
+ if (denied)
95
+ throw new ProtectedContextFileError(denied);
96
+ }
97
+ const before = lstatSync(path);
98
+ if (before.isSymbolicLink())
99
+ throw new NonRegularFileError(path);
100
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
101
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
102
+ try {
103
+ const info = await handle.stat();
104
+ const canonicalPath = verifyOpenedRegularFileSync(path, info, options);
105
+ return { handle, info, canonicalPath };
106
+ }
107
+ catch (error) {
108
+ await handle.close().catch(() => { });
109
+ throw error;
110
+ }
111
+ }
112
+ /** Materialize a direct-tool pre-read from a verified descriptor. Safe symlinks should be canonicalized by
113
+ * `resolveVerifiedModelPath` first; hard-linked aliases are rejected because their protected origin cannot be
114
+ * inferred from the opened pathname alone. */
115
+ export async function readVerifiedRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES, action = "read") {
116
+ const limit = checkedContextLimit(maxBytes);
117
+ const verified = await openVerifiedRegularFileNoFollow(path, { action, rejectHardLinks: true, protectSensitive: true });
118
+ try {
119
+ const { handle, info } = verified;
120
+ if (info.size > limit)
121
+ throw new FileReadLimitError(path, limit);
122
+ const chunks = [];
123
+ let total = 0;
124
+ let position = 0;
125
+ while (total <= limit) {
126
+ const want = Math.min(READ_CHUNK_BYTES, limit + 1 - total);
127
+ const buffer = Buffer.allocUnsafe(want);
128
+ const { bytesRead } = await handle.read(buffer, 0, want, position);
129
+ if (!bytesRead)
130
+ break;
131
+ chunks.push(buffer.subarray(0, bytesRead));
132
+ total += bytesRead;
133
+ position += bytesRead;
134
+ }
135
+ if (total > limit)
136
+ throw new FileReadLimitError(path, limit);
137
+ const latest = await handle.stat();
138
+ verifyOpenedRegularFileSync(path, latest, { action, rejectHardLinks: true, protectSensitive: true });
139
+ if (latest.dev !== info.dev
140
+ || latest.ino !== info.ino
141
+ || latest.size !== info.size
142
+ || latest.mtimeMs !== info.mtimeMs
143
+ || latest.ctimeMs !== info.ctimeMs)
144
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
145
+ return {
146
+ text: Buffer.concat(chunks, total).toString("utf8"),
147
+ dev: info.dev,
148
+ ino: info.ino,
149
+ mode: info.mode & 0o777,
150
+ nlink: info.nlink,
151
+ };
152
+ }
153
+ finally {
154
+ await verified.handle.close().catch(() => { });
155
+ }
156
+ }
157
+ /** Synchronous counterpart for startup/configuration paths that cannot make their public API async. It
158
+ * validates and reads the same O_NOFOLLOW descriptor, rejects aliases with multiple hard links, bounds the
159
+ * allocation, and verifies identity/metadata again after the read. Internal state readers may explicitly
160
+ * disable the model-facing protected-file policy while retaining every filesystem identity check. */
161
+ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_BYTES, options = {}) {
162
+ const action = options.action ?? "read";
163
+ if (options.protectSensitive !== false) {
164
+ const denied = sensitiveFileError(path, action);
165
+ if (denied)
166
+ throw new ProtectedContextFileError(denied);
167
+ }
168
+ const before = lstatSync(path);
169
+ if (before.isSymbolicLink())
170
+ throw new NonRegularFileError(path);
171
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
172
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
173
+ try {
174
+ const info = fstatSync(fd);
175
+ verifyOpenedRegularFileSync(path, info, {
176
+ action,
177
+ rejectHardLinks: options.rejectHardLinks !== false,
178
+ protectSensitive: options.protectSensitive !== false,
179
+ });
180
+ const limit = checkedContextLimit(maxBytes);
181
+ if (info.size > limit)
182
+ throw new FileReadLimitError(path, limit);
183
+ const bytes = readFdBytesSync(fd, Math.min(limit + 1, info.size + 1));
184
+ if (bytes.length > limit)
185
+ throw new FileReadLimitError(path, limit);
186
+ const latest = fstatSync(fd);
187
+ verifyOpenedRegularFileSync(path, latest, {
188
+ action,
189
+ rejectHardLinks: options.rejectHardLinks !== false,
190
+ protectSensitive: options.protectSensitive !== false,
191
+ });
192
+ if (latest.dev !== info.dev
193
+ || latest.ino !== info.ino
194
+ || latest.size !== info.size
195
+ || latest.mtimeMs !== info.mtimeMs
196
+ || latest.ctimeMs !== info.ctimeMs)
197
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
198
+ return {
199
+ text: bytes.toString("utf8"),
200
+ dev: info.dev,
201
+ ino: info.ino,
202
+ mode: info.mode & 0o777,
203
+ nlink: info.nlink,
204
+ };
205
+ }
206
+ finally {
207
+ closeSync(fd);
208
+ }
209
+ }
210
+ /**
211
+ * Open a model-context source without following its final component, validate the SAME descriptor, and
212
+ * re-check the canonical target against the central protected-file policy. The identity comparison closes
213
+ * the ordinary validate-then-open race: if the pathname is exchanged while it is being inspected, callers
214
+ * get an error rather than bytes from an unverified inode.
215
+ *
216
+ * This is intentionally synchronous because AGENTS/skills/roles/memory are assembled synchronously before
217
+ * a provider turn. Keep the reader callback small and bounded.
218
+ */
219
+ function withVerifiedContextFdSync(path, read) {
220
+ const denied = sensitiveFileError(path, "load into model context");
221
+ if (denied)
222
+ throw new ProtectedContextFileError(denied);
223
+ // O_NOFOLLOW is not exposed on every platform. The before/after lstat checks retain fail-closed symlink
224
+ // behaviour there; on POSIX O_NOFOLLOW makes the critical open itself atomic.
225
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
226
+ const before = lstatSync(path);
227
+ if (before.isSymbolicLink())
228
+ throw new NonRegularFileError(path);
229
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
230
+ try {
231
+ const info = fstatSync(fd);
232
+ verifyOpenedRegularFileSync(path, info, {
233
+ action: "load into model context",
234
+ rejectHardLinks: true,
235
+ protectSensitive: true,
236
+ });
237
+ const result = read(fd, info.size);
238
+ const latest = fstatSync(fd);
239
+ verifyOpenedRegularFileSync(path, latest, {
240
+ action: "load into model context",
241
+ rejectHardLinks: true,
242
+ protectSensitive: true,
243
+ });
244
+ if (latest.dev !== info.dev
245
+ || latest.ino !== info.ino
246
+ || latest.size !== info.size
247
+ || latest.mtimeMs !== info.mtimeMs
248
+ || latest.ctimeMs !== info.ctimeMs)
249
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
250
+ return result;
251
+ }
252
+ finally {
253
+ closeSync(fd);
254
+ }
255
+ }
256
+ function checkedContextLimit(maxBytes) {
257
+ const requested = Number.isFinite(maxBytes) ? Math.floor(maxBytes) : MAX_EDIT_READ_BYTES;
258
+ return Math.min(MAX_EDIT_READ_BYTES, Math.max(1, requested));
259
+ }
260
+ function readFdBytesSync(fd, count) {
261
+ const out = Buffer.allocUnsafe(count);
262
+ let offset = 0;
263
+ while (offset < count) {
264
+ const n = readSync(fd, out, offset, count - offset, offset);
265
+ if (n === 0)
266
+ break;
267
+ offset += n;
268
+ }
269
+ return out.subarray(0, offset);
270
+ }
271
+ /** Safely materialize a bounded UTF-8 context file. Oversized or binary inputs fail closed. */
272
+ export function readModelContextFileSync(path, maxBytes) {
273
+ const limit = checkedContextLimit(maxBytes);
274
+ return withVerifiedContextFdSync(path, (fd, size) => {
275
+ if (size > limit)
276
+ throw new FileReadLimitError(path, limit);
277
+ // Read one byte past the stated size/limit so concurrent growth is never silently included or ignored.
278
+ const bytes = readFdBytesSync(fd, Math.min(limit + 1, size + 1));
279
+ if (bytes.length > limit)
280
+ throw new FileReadLimitError(path, limit);
281
+ if (bytes.includes(0))
282
+ throw new BinaryFileError(path);
283
+ return bytes.toString("utf8");
284
+ });
285
+ }
286
+ /** Safe bounded-prefix counterpart for @file expansion; it never materializes the remainder of a huge file. */
287
+ export function readModelContextPrefixSync(path, maxChars) {
288
+ const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
289
+ const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
290
+ return withVerifiedContextFdSync(path, (fd, size) => {
291
+ const byteLimit = Math.min(size, chars * 4 + 4);
292
+ const bytes = readFdBytesSync(fd, byteLimit);
293
+ const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
294
+ const decoded = binary ? "" : bytes.toString("utf8");
295
+ return {
296
+ text: decoded.slice(0, chars),
297
+ truncated: size > bytes.length || decoded.length > chars,
298
+ binary,
299
+ };
300
+ });
301
+ }
302
+ function utf8BytePrefix(value, maxBytes) {
303
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
304
+ return value;
305
+ let used = 0;
306
+ let end = 0;
307
+ for (const char of value) {
308
+ const bytes = Buffer.byteLength(char, "utf8");
309
+ if (used + bytes > maxBytes)
310
+ break;
311
+ used += bytes;
312
+ end += char.length;
313
+ }
314
+ return value.slice(0, end);
315
+ }
316
+ /**
317
+ * Verified byte-budgeted prefix reader for project instructions. Unlike `readModelContextFileSync`, an
318
+ * oversized text file keeps its useful beginning instead of disappearing from model context. The returned
319
+ * text is always at most `maxBytes` UTF-8 bytes and never ends with half of a multibyte code point.
320
+ */
321
+ export function readModelContextBytePrefixSync(path, maxBytes) {
322
+ const limit = checkedContextLimit(maxBytes);
323
+ return withVerifiedContextFdSync(path, (fd, size) => {
324
+ const bytes = readFdBytesSync(fd, Math.min(size, limit));
325
+ const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
326
+ if (binary)
327
+ return { text: "", truncated: size > bytes.length, binary: true };
328
+ // StringDecoder buffers an incomplete UTF-8 suffix when the file continues past this prefix. This
329
+ // avoids injecting U+FFFD merely because the byte budget happened to split a multibyte character.
330
+ const decoder = new StringDecoder("utf8");
331
+ const decoded = size > bytes.length ? decoder.write(bytes) : decoder.end(bytes);
332
+ const text = utf8BytePrefix(decoded, limit);
333
+ return { text, truncated: size > bytes.length || text.length < decoded.length, binary: false };
334
+ });
335
+ }
34
336
  /** Open without blocking on a FIFO, validate the SAME descriptor, then read at most maxBytes from it.
35
337
  * Path-level stat→readFile is unsafe because the path can be exchanged for a pipe/device between calls. */
36
338
  async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
@@ -180,10 +482,13 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
180
482
  };
181
483
  // Keep validation and streaming on the same non-blocking descriptor. This closes the stat→open race
182
484
  // where an attacker/local generator exchanges a regular path for a FIFO after validation.
183
- const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
485
+ const verified = options.protectSensitive
486
+ ? await openVerifiedRegularFileNoFollow(path, { action: "read", rejectHardLinks: true, protectSensitive: true })
487
+ : null;
488
+ const handle = verified?.handle ?? await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
184
489
  let stream;
185
490
  try {
186
- const info = await handle.stat();
491
+ const info = verified?.info ?? await handle.stat();
187
492
  if (!info.isFile())
188
493
  throw new NonRegularFileError(path);
189
494
  // `end` is an inclusive byte offset. It makes the scan ceiling a true fd-level byte bound (not just
@@ -226,6 +531,16 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
226
531
  finishLine(true);
227
532
  }
228
533
  }
534
+ if (verified) {
535
+ const latest = await handle.stat();
536
+ verifyOpenedRegularFileSync(path, latest, { action: "read", rejectHardLinks: true, protectSensitive: true });
537
+ if (latest.dev !== info.dev
538
+ || latest.ino !== info.ino
539
+ || latest.size !== info.size
540
+ || latest.mtimeMs !== info.mtimeMs
541
+ || latest.ctimeMs !== info.ctimeMs)
542
+ throw new Error(`refusing to read ${path}: file changed while reading it`);
543
+ }
229
544
  }
230
545
  finally {
231
546
  stream?.destroy();
package/dist/fs-walk.js CHANGED
@@ -4,6 +4,8 @@ import { readdirSync, statSync } from "node:fs";
4
4
  import { join, relative, sep } from "node:path";
5
5
  import { execSync } from "node:child_process";
6
6
  import { fuzzyRank } from "./fuzzy.js";
7
+ import { isSensitiveFilePath } from "./security/sensitive-files.js";
8
+ import { toolSubprocessEnv } from "./security/subprocess-env.js";
7
9
  export const IGNORE_DIRS = new Set([
8
10
  ".git", "node_modules", "dist", "build", "out", ".next", ".nuxt", ".cache",
9
11
  "coverage", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
@@ -36,7 +38,10 @@ export function walkFiles(root, cap = 8000) {
36
38
  stack.push(join(dir, e.name));
37
39
  }
38
40
  else if (e.isFile()) {
39
- files.push(toPosix(relative(root, join(dir, e.name))));
41
+ const absolute = join(dir, e.name);
42
+ if (isSensitiveFilePath(absolute))
43
+ continue;
44
+ files.push(toPosix(relative(root, absolute)));
40
45
  }
41
46
  }
42
47
  }
@@ -50,6 +55,7 @@ export function listProjectFiles(root, cap = 8000) {
50
55
  try {
51
56
  const out = execSync("git ls-files --cached --others --exclude-standard", {
52
57
  cwd: root,
58
+ env: toolSubprocessEnv(),
53
59
  encoding: "utf8",
54
60
  maxBuffer: 32 * 1024 * 1024,
55
61
  stdio: ["ignore", "pipe", "ignore"],
@@ -59,7 +65,7 @@ export function listProjectFiles(root, cap = 8000) {
59
65
  })
60
66
  .split("\n")
61
67
  .map((s) => s.trim())
62
- .filter(Boolean);
68
+ .filter((path) => Boolean(path) && !isSensitiveFilePath(join(root, path)));
63
69
  if (out.length)
64
70
  return out.slice(0, cap);
65
71
  }
package/dist/fs-write.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
2
2
  // renamed into place so a killed process never leaves a half-written source file.
3
3
  import { randomUUID } from "node:crypto";
4
- import { basename, dirname, join } from "node:path";
5
- import { constants, linkSync, lstatSync, readlinkSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
6
- import { link, lstat, mkdir, open, realpath, rename, rmdir, stat, unlink } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { constants, linkSync, lstatSync, readlinkSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
6
+ import { lstat, mkdir, open, realpath, rmdir, stat } from "node:fs/promises";
7
7
  import { NonRegularFileError, readRegularFileSnapshotNoFollow } from "./fs-read.js";
8
+ import { canonicalizeProspectivePath, lexicalSensitiveFileReason, sensitiveFileError, } from "./security/sensitive-files.js";
8
9
  export class FileChangedError extends Error {
9
10
  code = "HARA_FILE_CHANGED";
10
11
  constructor(path) {
@@ -13,6 +14,145 @@ export class FileChangedError extends Error {
13
14
  }
14
15
  }
15
16
  let tempSequence = 0;
17
+ // Internal Hara control-plane writers need the same identity/CAS guarantees as coding tools, but the
18
+ // public protected-file policy intentionally rejects those destinations. Keep exemptions as object identity
19
+ // rather than a forgeable boolean on AtomicWriteBoundary: only the narrow, destination-specific binders below
20
+ // can mint one.
21
+ const privateStateBoundaries = new WeakSet();
22
+ function protectedWriteError(path, action) {
23
+ const denied = sensitiveFileError(path, action);
24
+ return denied ? new Error(denied) : null;
25
+ }
26
+ function verifyDirectoryIdentity(boundary) {
27
+ const current = lstatSync(boundary.ancestor.path);
28
+ const canonical = realpathSync.native(boundary.ancestor.path);
29
+ if (!current.isDirectory()
30
+ || current.isSymbolicLink()
31
+ || current.dev !== boundary.ancestor.dev
32
+ || current.ino !== boundary.ancestor.ino
33
+ || canonical !== boundary.ancestor.path)
34
+ throw new FileChangedError(boundary.target);
35
+ }
36
+ export function verifyAtomicWriteBoundary(boundary) {
37
+ verifyDirectoryIdentity(boundary);
38
+ if (!privateStateBoundaries.has(boundary)) {
39
+ const denied = protectedWriteError(boundary.target, boundary.action);
40
+ if (denied)
41
+ throw denied;
42
+ }
43
+ }
44
+ /**
45
+ * Bind one internal private-Hara-state file below a directory whose symlink-free construction and mode
46
+ * were already verified by the caller. The exemption cannot be used for `.env`/credential files or an
47
+ * arbitrary descendant: the target must be an immediate child classified specifically as Hara state.
48
+ */
49
+ export function bindHaraPrivateStateWritePath(path, stateDir, action) {
50
+ const dir = resolve(stateDir);
51
+ const target = resolve(path);
52
+ if (dirname(target) !== dir)
53
+ throw new Error(`private Hara state target must be an immediate child of ${dir}`);
54
+ if (lexicalSensitiveFileReason(target) !== "private Hara state") {
55
+ throw new Error(`refusing private Hara state exemption for ${target}`);
56
+ }
57
+ const canonical = realpathSync.native(dir);
58
+ const info = lstatSync(dir);
59
+ if (!info.isDirectory() || info.isSymbolicLink() || canonical !== dir) {
60
+ throw new Error(`refusing private Hara state write: '${dir}' is not a canonical directory`);
61
+ }
62
+ const boundary = {
63
+ target,
64
+ action,
65
+ ancestor: { path: dir, dev: info.dev, ino: info.ino },
66
+ };
67
+ privateStateBoundaries.add(boundary);
68
+ verifyAtomicWriteBoundary(boundary);
69
+ return boundary;
70
+ }
71
+ /** Bind the personal identity pin without granting a general protected-file bypass. The final directory
72
+ * entry is deliberately not resolved: callers can no-follow inspect it and atomic CAS will reject any
73
+ * symlink or replacement that appears after preflight. */
74
+ export function bindProfilePinWritePath(path, action = "write profile pin") {
75
+ const requested = resolve(path);
76
+ if (basename(requested) !== ".hara-profile")
77
+ throw new Error("profile pin target must be named .hara-profile");
78
+ const parent = realpathSync.native(dirname(requested));
79
+ const target = join(parent, basename(requested));
80
+ if (lexicalSensitiveFileReason(target) !== "private Hara routing state") {
81
+ throw new Error(`refusing profile pin exemption for ${target}`);
82
+ }
83
+ const info = lstatSync(parent);
84
+ if (!info.isDirectory() || info.isSymbolicLink())
85
+ throw new Error(`${parent} is not a canonical directory`);
86
+ const boundary = {
87
+ target,
88
+ action,
89
+ ancestor: { path: parent, dev: info.dev, ino: info.ino },
90
+ };
91
+ privateStateBoundaries.add(boundary);
92
+ verifyAtomicWriteBoundary(boundary);
93
+ return boundary;
94
+ }
95
+ /** Bind a directory entry while preserving its final symlink topology (used by transactional deletes). */
96
+ export function bindAtomicParentEntryPath(path, action = "write") {
97
+ const lexicalDenied = protectedWriteError(path, action);
98
+ if (lexicalDenied)
99
+ throw lexicalDenied;
100
+ const requested = resolve(path);
101
+ const parent = realpathSync.native(dirname(requested));
102
+ const target = join(parent, basename(requested));
103
+ const canonicalDenied = protectedWriteError(target, action);
104
+ if (canonicalDenied)
105
+ throw canonicalDenied;
106
+ const info = lstatSync(parent);
107
+ if (!info.isDirectory() || info.isSymbolicLink())
108
+ throw new Error(`${parent} is not a directory`);
109
+ const boundary = {
110
+ target,
111
+ action,
112
+ ancestor: { path: parent, dev: info.dev, ino: info.ino },
113
+ };
114
+ verifyAtomicWriteBoundary(boundary);
115
+ return boundary;
116
+ }
117
+ /**
118
+ * Bind a direct coding-tool path to one canonical candidate and the nearest existing parent directory.
119
+ * Missing tail directories remain below that identity; a parent symlink retarget or directory replacement
120
+ * between preflight and commit therefore fails instead of silently selecting another tree.
121
+ */
122
+ export function bindAtomicWritePath(path, action = "write") {
123
+ const lexicalDenied = protectedWriteError(path, action);
124
+ if (lexicalDenied)
125
+ throw lexicalDenied;
126
+ const target = canonicalizeProspectivePath(path);
127
+ const canonicalDenied = protectedWriteError(target, action);
128
+ if (canonicalDenied)
129
+ throw canonicalDenied;
130
+ let current = dirname(target);
131
+ for (let depth = 0; depth < 128; depth++) {
132
+ try {
133
+ const canonical = realpathSync.native(current);
134
+ const info = lstatSync(canonical);
135
+ if (!info.isDirectory() || info.isSymbolicLink())
136
+ throw new Error(`${canonical} is not a directory`);
137
+ const boundary = {
138
+ target,
139
+ action,
140
+ ancestor: { path: canonical, dev: info.dev, ino: info.ino },
141
+ };
142
+ verifyAtomicWriteBoundary(boundary);
143
+ return boundary;
144
+ }
145
+ catch (error) {
146
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR")
147
+ throw error;
148
+ }
149
+ const parent = dirname(current);
150
+ if (parent === current)
151
+ throw new Error(`cannot bind parent directory for ${path}`);
152
+ current = parent;
153
+ }
154
+ throw new Error(`write path exceeds 128 components: ${path}`);
155
+ }
16
156
  async function writeTarget(path) {
17
157
  try {
18
158
  const info = await lstat(path);
@@ -172,6 +312,11 @@ async function syncDirectory(path) {
172
312
  }
173
313
  /** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
174
314
  export async function atomicWriteText(path, content, options = {}) {
315
+ if (options.boundary) {
316
+ if (resolve(path) !== resolve(options.boundary.target))
317
+ throw new FileChangedError(path);
318
+ verifyAtomicWriteBoundary(options.boundary);
319
+ }
175
320
  let target = await writeTarget(path);
176
321
  let dir = dirname(target);
177
322
  const createdDirs = [];
@@ -181,6 +326,25 @@ export async function atomicWriteText(path, content, options = {}) {
181
326
  // later transaction steps to a different tree.
182
327
  dir = await realpath(dir);
183
328
  target = join(dir, basename(target));
329
+ if (options.boundary) {
330
+ if (target !== options.boundary.target)
331
+ throw new FileChangedError(path);
332
+ verifyAtomicWriteBoundary(options.boundary);
333
+ }
334
+ const parentInfo = await lstat(dir);
335
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink())
336
+ throw new FileChangedError(dir);
337
+ const parentBoundary = {
338
+ target,
339
+ action: options.boundary?.action ?? "write",
340
+ ancestor: { path: dir, dev: parentInfo.dev, ino: parentInfo.ino },
341
+ };
342
+ const verifyCommitParent = () => {
343
+ verifyDirectoryIdentity(parentBoundary);
344
+ if (options.boundary)
345
+ verifyAtomicWriteBoundary(options.boundary);
346
+ };
347
+ verifyCommitParent();
184
348
  // A caller-provided preflight identity is authoritative. Reading mode from a path before the move-claim
185
349
  // would let a temporary same-path replacement influence the mode even when the expected inode is restored
186
350
  // before claim verification.
@@ -209,6 +373,8 @@ export async function atomicWriteText(path, content, options = {}) {
209
373
  const handle = await open(temp, "wx", mode);
210
374
  staged = true;
211
375
  try {
376
+ const opened = await handle.stat();
377
+ writtenIdentity = { dev: opened.dev, ino: opened.ino, mode: opened.mode & 0o777, nlink: opened.nlink };
212
378
  await handle.writeFile(content, "utf8");
213
379
  // open(2) applies the process umask even when replacing an existing file. Restore that existing (or
214
380
  // explicit rollback) mode on the staged fd; brand-new ordinary files still honor the user's umask.
@@ -225,14 +391,17 @@ export async function atomicWriteText(path, content, options = {}) {
225
391
  // link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
226
392
  // appeared after validation, defeating create's no-clobber contract.
227
393
  try {
228
- await link(temp, target);
394
+ // Keep the final parent-identity check and namespace mutation in one JS turn. Node has no portable
395
+ // openat/linkat API; synchronous link is the narrowest available commit boundary.
396
+ verifyCommitParent();
397
+ linkSync(temp, target);
229
398
  }
230
399
  catch (error) {
231
400
  if (error?.code === "EEXIST")
232
401
  throw new FileChangedError(path);
233
402
  throw error;
234
403
  }
235
- await unlink(temp);
404
+ unlinkSync(temp);
236
405
  staged = false;
237
406
  }
238
407
  else if (typeof options.expected === "string") {
@@ -240,7 +409,8 @@ export async function atomicWriteText(path, content, options = {}) {
240
409
  // path leaves a verify→commit race; a concurrent replacement can otherwise be silently destroyed.
241
410
  const claimed = join(dir, `.hara-claim-${process.pid}-${randomUUID()}.tmp`);
242
411
  try {
243
- await rename(target, claimed);
412
+ verifyCommitParent();
413
+ renameSync(target, claimed);
244
414
  }
245
415
  catch (error) {
246
416
  if (error?.code === "ENOENT")
@@ -289,11 +459,13 @@ export async function atomicWriteText(path, content, options = {}) {
289
459
  if (!claimedIdentity)
290
460
  throw new Error(`Failed to identify claimed file for ${path}`);
291
461
  try {
292
- await link(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
462
+ verifyCommitParent();
463
+ linkSync(temp, target); // atomic create-if-absent: never overwrites an entry created after claim.
293
464
  }
294
465
  catch (error) {
295
466
  let recovery = "";
296
467
  try {
468
+ verifyCommitParent();
297
469
  restoreClaimedPath(claimed, target, claimedIdentity);
298
470
  }
299
471
  catch (restoreError) {
@@ -303,9 +475,10 @@ export async function atomicWriteText(path, content, options = {}) {
303
475
  throw new Error(`${new FileChangedError(path).message}${recovery}`);
304
476
  throw new Error(`${error?.message ?? String(error)}${recovery}`, { cause: error });
305
477
  }
306
- await unlink(temp);
478
+ unlinkSync(temp);
307
479
  staged = false;
308
480
  try {
481
+ verifyCommitParent();
309
482
  discardClaimedPath(claimed, claimedIdentity);
310
483
  }
311
484
  catch (error) {
@@ -313,15 +486,28 @@ export async function atomicWriteText(path, content, options = {}) {
313
486
  }
314
487
  }
315
488
  else {
316
- await rename(temp, target);
489
+ verifyCommitParent();
490
+ renameSync(temp, target);
317
491
  staged = false;
318
492
  }
319
493
  await syncDirectory(dir);
320
494
  succeeded = true;
321
495
  }
322
496
  finally {
323
- if (staged)
324
- await unlink(temp).catch(() => { });
497
+ if (staged && writtenIdentity) {
498
+ try {
499
+ verifyCommitParent();
500
+ const current = lstatSync(temp);
501
+ if (current.isFile()
502
+ && current.dev === writtenIdentity.dev
503
+ && current.ino === writtenIdentity.ino)
504
+ unlinkSync(temp);
505
+ }
506
+ catch {
507
+ // A changed parent makes path-based cleanup unsafe. Retaining an unpredictable private staging file
508
+ // is preferable to unlinking an entry supplied by a concurrent actor.
509
+ }
510
+ }
325
511
  if (!succeeded)
326
512
  await removeCreatedDirectories(createdDirs).catch(() => { });
327
513
  }