@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
@@ -7,9 +7,11 @@
7
7
  // privileged tool — and because fan-out sub-agents only get the read-only allow-list (READONLY_TOOLS), they
8
8
  // never see this tool. Trust tiers (off|gated|full) gate the dangerous bypass/full-access sub-modes.
9
9
  import { spawn, execFileSync } from "node:child_process";
10
+ import { platform } from "node:os";
10
11
  import { registerTool } from "./registry.js";
11
12
  import { capHeadTail } from "./builtin.js";
12
13
  import { loadConfig } from "../config.js";
14
+ import { createToolOutputLineRedactor, redactToolSubprocessOutput, terminateSubprocessTree, toolSubprocessEnv, } from "../security/subprocess-env.js";
13
15
  /** Pure: build the spawn (cmd, args) for a backend, or null if unknown. Maps hara's sandbox/trust → the agent's
14
16
  * own permission flags; the dangerous bypass/full-access modes are only reachable at trust "full". */
15
17
  export function buildExternalArgv(backend, task, o) {
@@ -24,6 +26,20 @@ export function buildExternalArgv(backend, task, o) {
24
26
  return null;
25
27
  }
26
28
  const BUILTIN_BACKENDS = ["claude", "codex"];
29
+ const EXTERNAL_CAPTURE_PER_STREAM = 2 * 1024 * 1024;
30
+ const EXTERNAL_OUTPUT_KILL_LIMIT = 4 * 1024 * 1024;
31
+ export function appendBoundedExternalOutput(current, chunk, limit = EXTERNAL_CAPTURE_PER_STREAM) {
32
+ if (!Number.isSafeInteger(limit) || limit < 1)
33
+ return "";
34
+ const combined = current + chunk;
35
+ if (combined.length <= limit)
36
+ return combined;
37
+ const marker = "\n…[external output truncated]…\n";
38
+ const retained = Math.max(0, limit - marker.length);
39
+ const head = Math.floor(retained / 4);
40
+ const tail = retained - head;
41
+ return combined.slice(0, head) + marker.slice(0, limit - head - tail) + (tail ? combined.slice(-tail) : "");
42
+ }
27
43
  /** Probe a CLI's availability via `<bin> --version` (cached per process). */
28
44
  const availCache = new Map();
29
45
  function available(bin) {
@@ -31,7 +47,7 @@ function available(bin) {
31
47
  return availCache.get(bin);
32
48
  let ok = false;
33
49
  try {
34
- execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
50
+ execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000, env: toolSubprocessEnv() });
35
51
  ok = true;
36
52
  }
37
53
  catch {
@@ -52,6 +68,7 @@ registerTool({
52
68
  "another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
53
69
  "Args: task (required), backend (claude|codex; default = first installed), model (optional).",
54
70
  kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
71
+ trustBoundary: "external",
55
72
  input_schema: {
56
73
  type: "object",
57
74
  properties: {
@@ -63,6 +80,10 @@ registerTool({
63
80
  required: ["task"],
64
81
  },
65
82
  async run(input, ctx) {
83
+ if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
84
+ return ("Blocked: external_agent runs another host coding agent outside Hara's protected-file boundary. " +
85
+ "Use it interactively after approval, or restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that agent.");
86
+ }
66
87
  const trust = resolveTrust();
67
88
  if (trust === "off")
68
89
  return "external_agent is disabled (set externalAgentTrust to gated|full, or HARA_EXTERNAL_AGENT_TRUST).";
@@ -80,37 +101,110 @@ registerTool({
80
101
  return `Unknown backend '${backend}'.`;
81
102
  const timeout = Math.min(Math.max(30_000, Number(input.timeout_ms) || 600_000), 1_800_000);
82
103
  return await new Promise((resolve) => {
83
- const child = spawn(built.cmd, built.args, { cwd: ctx.cwd, env: process.env });
104
+ const processGroup = platform() !== "win32";
105
+ let child;
106
+ try {
107
+ child = spawn(built.cmd, built.args, {
108
+ cwd: ctx.cwd,
109
+ env: toolSubprocessEnv(),
110
+ detached: processGroup,
111
+ });
112
+ }
113
+ catch (error) {
114
+ resolve(redactToolSubprocessOutput(`[${backend}] failed to start: ${error instanceof Error ? error.message : String(error)}`));
115
+ return;
116
+ }
84
117
  let out = "";
85
118
  let err = "";
119
+ let outputBytes = 0;
86
120
  let done = false;
121
+ let stoppingReason = null;
122
+ let forceIssued = false;
123
+ let closeBeforeForce = false;
124
+ let cancelTermination;
125
+ const liveOut = ctx.ui ? createToolOutputLineRedactor((line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))) : null;
126
+ const liveErr = ctx.ui ? createToolOutputLineRedactor((line) => ctx.ui.notice(line.replace(/\r?\n$/, ""))) : null;
87
127
  const finish = (s) => {
88
128
  if (done)
89
129
  return;
90
130
  done = true;
91
131
  clearTimeout(timer);
92
- resolve(capHeadTail(s));
132
+ // A stopped tree must still receive its scheduled group KILL even if the direct shell closed first.
133
+ // The default cancellation removes only the wall-clock fallback, not the force timer.
134
+ cancelTermination?.();
135
+ liveOut?.flush();
136
+ liveErr?.flush();
137
+ resolve(capHeadTail(redactToolSubprocessOutput(s)));
138
+ };
139
+ const stoppedText = () => {
140
+ const text = out.trim() || "(external agent produced no output)";
141
+ return `${stoppingReason}\n${text}${err ? `\n[stderr]\n${err.trim()}` : ""}`;
142
+ };
143
+ const stopTree = (reason) => {
144
+ if (done || stoppingReason)
145
+ return;
146
+ stoppingReason = reason;
147
+ cancelTermination = terminateSubprocessTree(child, {
148
+ processGroup,
149
+ graceMs: 2_000,
150
+ fallbackMs: 3_000,
151
+ onForce: () => {
152
+ forceIssued = true;
153
+ if (closeBeforeForce)
154
+ finish(stoppedText());
155
+ },
156
+ onFallback: () => {
157
+ // A deliberately daemonized descendant may retain the pipes after escaping the owned group.
158
+ child.stdout?.destroy();
159
+ child.stderr?.destroy();
160
+ finish(stoppedText());
161
+ },
162
+ });
93
163
  };
94
164
  const timer = setTimeout(() => {
95
- try {
96
- child.kill("SIGKILL");
165
+ stopTree(`[${backend}] timed out after ${timeout}ms`);
166
+ }, timeout);
167
+ child.stdin?.on("error", () => { });
168
+ child.stdin?.end(); // task is passed via argv
169
+ child.stdout?.on("data", (d) => {
170
+ if (done || stoppingReason)
171
+ return;
172
+ const text = d.toString();
173
+ outputBytes += d.length;
174
+ out = appendBoundedExternalOutput(out, text);
175
+ liveOut?.push(text);
176
+ if (outputBytes > EXTERNAL_OUTPUT_KILL_LIMIT) {
177
+ stopTree(`[${backend}] stopped after exceeding ${EXTERNAL_OUTPUT_KILL_LIMIT} output bytes`);
97
178
  }
98
- catch {
99
- /* already gone */
179
+ });
180
+ child.stderr?.on("data", (d) => {
181
+ if (done || stoppingReason)
182
+ return;
183
+ const text = d.toString();
184
+ outputBytes += d.length;
185
+ err = appendBoundedExternalOutput(err, text);
186
+ liveErr?.push(text);
187
+ if (outputBytes > EXTERNAL_OUTPUT_KILL_LIMIT) {
188
+ stopTree(`[${backend}] stopped after exceeding ${EXTERNAL_OUTPUT_KILL_LIMIT} output bytes`);
100
189
  }
101
- finish(`[${backend}] timed out after ${timeout}ms\n${out}`);
102
- }, timeout);
103
- child.stdin.end(); // task is passed via argv
104
- child.stdout.on("data", (d) => {
105
- out += d.toString();
106
- ctx.ui?.notice?.(d.toString().trimEnd());
107
190
  });
108
- child.stderr.on("data", (d) => {
109
- err += d.toString();
191
+ child.on("error", (e) => {
192
+ if (stoppingReason && !forceIssued) {
193
+ closeBeforeForce = true;
194
+ return;
195
+ }
196
+ finish(stoppingReason ? stoppedText() : `[${backend}] failed to start: ${e.message} (is it installed?)`);
110
197
  });
111
- child.on("error", (e) => finish(`[${backend}] failed to start: ${e.message} (is it installed?)`));
112
198
  child.on("close", (code) => {
113
199
  const text = out.trim() || "(external agent produced no output)";
200
+ if (stoppingReason) {
201
+ if (!forceIssued) {
202
+ closeBeforeForce = true;
203
+ return;
204
+ }
205
+ finish(stoppedText());
206
+ return;
207
+ }
114
208
  finish(code === 0 ? text : `[${backend} exit ${code}]\n${text}${err ? `\n[stderr]\n${err.trim()}` : ""}`);
115
209
  });
116
210
  });
@@ -1,13 +1,16 @@
1
1
  // Memory tools — the agent's interface to durable memory. memory_search/get are read-only
2
2
  // (parallel-safe, never prompt); memory_write/forget are edits (gated by the approval mode).
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
4
- import { isAbsolute, resolve, join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { isAbsolute, resolve, join, relative, sep } from "node:path";
5
5
  import { registerTool } from "./registry.js";
6
6
  import { searchAssets, assetSearchRoots } from "../recall.js";
7
7
  import { searchHybrid } from "../search/hybrid.js";
8
8
  import { memoryRoots, appendMemory, replaceMemory, forgetMemory } from "../memory/store.js";
9
9
  import { scanMemory, redactSecrets, scrubLocal } from "../memory/guard.js";
10
10
  import { globalSkillsDir, skillsDir, invalidateSkillsCache } from "../skills/skills.js";
11
+ import { sensitiveFileError } from "../security/sensitive-files.js";
12
+ import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
13
+ import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
11
14
  const asTarget = (v) => (["memory", "user", "log"].includes(v) ? v : "memory");
12
15
  const asScope = (v) => (v === "global" ? "global" : "project");
13
16
  registerTool({
@@ -34,12 +37,18 @@ registerTool({
34
37
  kind: "read",
35
38
  async run(input, ctx) {
36
39
  const p = isAbsolute(String(input.path)) ? String(input.path) : resolve(ctx.cwd, String(input.path));
37
- if (!memoryRoots(ctx.cwd).some((r) => p.startsWith(r)))
40
+ if (!memoryRoots(ctx.cwd).some((root) => {
41
+ const rel = relative(resolve(root), p);
42
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
43
+ }))
38
44
  return `Error: ${input.path} is outside the memory store.`;
45
+ const denied = sensitiveFileError(p, "read");
46
+ if (denied)
47
+ return denied;
39
48
  if (!existsSync(p))
40
49
  return `Error: no memory file at ${p}.`;
41
50
  try {
42
- return readFileSync(p, "utf8").slice(0, 50_000);
51
+ return readModelContextFileSync(p, 64 * 1024).slice(0, 50_000);
43
52
  }
44
53
  catch (e) {
45
54
  return `Error: ${e.message}`;
@@ -70,7 +79,9 @@ registerTool({
70
79
  return `Blocked: this looks unsafe to store (${scan.hits.join(", ")}). Rephrase without secrets/injection text.`;
71
80
  const scope = asScope(input.scope);
72
81
  const target = asTarget(input.target);
73
- const f = input.mode === "replace" ? replaceMemory(scope, target, content, ctx.cwd) : appendMemory(scope, target, content, ctx.cwd);
82
+ const f = input.mode === "replace"
83
+ ? await replaceMemory(scope, target, content, ctx.cwd)
84
+ : await appendMemory(scope, target, content, ctx.cwd);
74
85
  return `Saved to ${f}`;
75
86
  },
76
87
  });
@@ -117,8 +128,27 @@ registerTool({
117
128
  const f = join(dir, "SKILL.md");
118
129
  // dedup: surface a near-duplicate so the agent updates instead of piling up (lexical signal, not a block)
119
130
  const dups = searchAssets(`${slug} ${description}`, 3, assetSearchRoots(ctx.cwd)).filter((h) => h.path !== f && h.score >= 2);
120
- mkdirSync(dir, { recursive: true });
121
- writeFileSync(f, `---\nname: ${slug}\ndescription: ${description}\n---\n\n${body.trim()}\n`, "utf8");
131
+ const skillText = `---\nname: ${slug}\ndescription: ${description}\n---\n\n${body.trim()}\n`;
132
+ let boundary;
133
+ let snapshot = null;
134
+ try {
135
+ boundary = bindAtomicWritePath(f, "create skill");
136
+ try {
137
+ snapshot = await readVerifiedRegularFileSnapshot(boundary.target, undefined, "create skill");
138
+ }
139
+ catch (error) {
140
+ if (error?.code !== "ENOENT")
141
+ throw error;
142
+ }
143
+ await atomicWriteText(boundary.target, skillText, {
144
+ expected: snapshot?.text ?? null,
145
+ expectedIdentity: snapshot ?? undefined,
146
+ boundary,
147
+ });
148
+ }
149
+ catch (error) {
150
+ return `Error: cannot save skill ${slug}: ${error?.message ?? String(error)} No changes written.`;
151
+ }
122
152
  invalidateSkillsCache();
123
153
  const notes = [
124
154
  redactions.length ? `redacted ${redactions.length} secret(s)` : "",
@@ -141,7 +171,7 @@ registerTool({
141
171
  },
142
172
  kind: "edit",
143
173
  async run(input, ctx) {
144
- const n = forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
174
+ const n = await forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd);
145
175
  return n ? `Removed ${n} line(s).` : "(no matching lines)";
146
176
  },
147
177
  });
@@ -1,6 +1,6 @@
1
1
  // apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
2
2
  // computed in memory first; nothing is written unless every change applies cleanly.
3
- import { linkSync, lstatSync, readlinkSync, symlinkSync } from "node:fs";
3
+ import { linkSync, lstatSync, readlinkSync, renameSync, symlinkSync } from "node:fs";
4
4
  import { lstat, readlink, rename } from "node:fs/promises";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -8,9 +8,10 @@ import { registerTool } from "./registry.js";
8
8
  import { applyEdits } from "./apply-core.js";
9
9
  import { emitDiff } from "../diff.js";
10
10
  import { recordEdit } from "../undo.js";
11
- import { atomicWriteText, discardClaimedPath, FileChangedError, removeCreatedDirectories } from "../fs-write.js";
11
+ import { atomicWriteText, bindAtomicParentEntryPath, bindAtomicWritePath, discardClaimedPath, FileChangedError, removeCreatedDirectories, verifyAtomicWriteBoundary, } from "../fs-write.js";
12
12
  import { invalidateFileCandidates } from "../context/mentions.js";
13
- import { readRegularFileSnapshot, readRegularFileSnapshotNoFollow } from "../fs-read.js";
13
+ import { readRegularFileSnapshot, readRegularFileSnapshotNoFollow, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, } from "../fs-read.js";
14
+ import { sensitiveFileError } from "../security/sensitive-files.js";
14
15
  async function restoreMovedFile(quarantine, target) {
15
16
  let identity;
16
17
  try {
@@ -88,12 +89,15 @@ async function rollbackWrittenPlan(plan) {
88
89
  async function quarantineExpectedDelete(plan) {
89
90
  if (!plan.beforeIdentity || !plan.beforePathIdentity || plan.beforeMode === undefined)
90
91
  throw new Error(`missing preflight identity for ${plan.path}`);
92
+ if (!plan.writeBoundary)
93
+ throw new Error(`missing parent boundary for ${plan.path}`);
91
94
  // Keep the initially moved path private until it has been verified. A directory watcher must not be able
92
95
  // to mistake an unverified staging entry for the durable delete quarantine used by the commit/cleanup
93
96
  // phases. Recording it immediately also lets the outer rollback recover (or accurately report) a failure
94
97
  // that happens after rename but before verification completes.
95
98
  const staging = join(dirname(plan.abs), `.hara-stage-delete-${process.pid}-${randomUUID()}.tmp`);
96
- await rename(plan.abs, staging);
99
+ verifyAtomicWriteBoundary(plan.writeBoundary);
100
+ renameSync(plan.abs, staging);
97
101
  plan.quarantine = staging;
98
102
  const movedPath = await lstat(staging);
99
103
  const samePath = movedPath.dev === plan.beforePathIdentity.dev && movedPath.ino === plan.beforePathIdentity.ino && (movedPath.mode & 0o777) === plan.beforePathIdentity.mode && movedPath.nlink === plan.beforePathIdentity.nlink;
@@ -108,17 +112,19 @@ async function quarantineExpectedDelete(plan) {
108
112
  throw new FileChangedError(plan.path);
109
113
  }
110
114
  const quarantine = join(dirname(plan.abs), `.hara-delete-${process.pid}-${randomUUID()}.tmp`);
111
- await rename(staging, quarantine);
115
+ verifyAtomicWriteBoundary(plan.writeBoundary);
116
+ renameSync(staging, quarantine);
112
117
  plan.quarantine = quarantine;
113
118
  }
114
119
  /** Move a delete quarantine to a fresh name and verify that exact moved path before restore/cleanup. If a
115
120
  * concurrent process replaced the known quarantine name, preserve the unexpected inode and fail closed. */
116
121
  async function claimDeleteQuarantine(plan, purpose) {
117
- if (!plan.quarantine || !plan.beforePathIdentity || !plan.beforeIdentity || plan.beforeMode === undefined) {
122
+ if (!plan.quarantine || !plan.beforePathIdentity || !plan.beforeIdentity || plan.beforeMode === undefined || !plan.writeBoundary) {
118
123
  throw new Error(`missing delete quarantine identity for ${plan.path}`);
119
124
  }
120
125
  const claimed = join(dirname(plan.quarantine), `.hara-${purpose}-${process.pid}-${randomUUID()}.tmp`);
121
- await rename(plan.quarantine, claimed);
126
+ verifyAtomicWriteBoundary(plan.writeBoundary);
127
+ renameSync(plan.quarantine, claimed);
122
128
  try {
123
129
  const pathInfo = await lstat(claimed);
124
130
  const expectedPath = plan.beforePathIdentity;
@@ -205,6 +211,9 @@ registerTool({
205
211
  if (typeof ch.path !== "string" || !ch.path)
206
212
  return `Error: ${tag} is missing a path. Nothing written.`;
207
213
  const p = abs(ch.path);
214
+ const denied = sensitiveFileError(p, "patch");
215
+ if (denied)
216
+ return `Error: ${tag}: ${denied} Nothing written.`;
208
217
  if (plannedPaths.has(p))
209
218
  return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
210
219
  plannedPaths.add(p);
@@ -225,24 +234,28 @@ registerTool({
225
234
  let before;
226
235
  let pathInfo;
227
236
  let beforeLinkTarget;
237
+ let writeBoundary;
228
238
  try {
229
- pathInfo = await lstat(p);
239
+ writeBoundary = bindAtomicParentEntryPath(p, "patch");
240
+ pathInfo = await lstat(writeBoundary.target);
230
241
  if (pathInfo.isSymbolicLink())
231
- beforeLinkTarget = await readlink(p);
232
- before = await readRegularFileSnapshot(p);
242
+ beforeLinkTarget = await readlink(writeBoundary.target);
243
+ const target = resolveVerifiedModelPath(writeBoundary.target, "patch");
244
+ before = await readVerifiedRegularFileSnapshot(target, undefined, "patch");
233
245
  }
234
246
  catch (error) {
235
247
  return `Error: ${tag} delete ${ch.path}: ${error?.message ?? "file not found"}. Nothing written.`;
236
248
  }
237
249
  plans.push({
238
250
  path: ch.path,
239
- abs: p,
251
+ abs: writeBoundary.target,
240
252
  type,
241
253
  before: before.text,
242
254
  beforeMode: before.mode,
243
255
  beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink },
244
256
  beforePathIdentity: { dev: pathInfo.dev, ino: pathInfo.ino, mode: pathInfo.mode & 0o777, nlink: pathInfo.nlink },
245
257
  beforeLinkTarget,
258
+ writeBoundary,
246
259
  after: null,
247
260
  existed: true,
248
261
  });
@@ -250,33 +263,39 @@ registerTool({
250
263
  else if (type === "create") {
251
264
  if (typeof ch.content !== "string")
252
265
  return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
266
+ let writeBoundary;
253
267
  try {
254
- await lstat(p);
268
+ writeBoundary = bindAtomicWritePath(p, "patch");
269
+ await lstat(writeBoundary.target);
255
270
  return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
256
271
  }
257
272
  catch (error) {
258
- if (error?.code !== "ENOENT")
273
+ if (error?.code !== "ENOENT" || !writeBoundary)
259
274
  return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
260
275
  }
261
- plans.push({ path: ch.path, abs: p, type, before: "", after: ch.content, existed: false });
276
+ if (!writeBoundary)
277
+ return `Error: ${tag} create ${ch.path}: cannot bind a stable parent. Nothing written.`;
278
+ plans.push({ path: ch.path, abs: writeBoundary.target, type, before: "", after: ch.content, existed: false, writeBoundary });
262
279
  }
263
280
  else {
264
281
  // update
265
282
  let before;
283
+ let writeBoundary;
266
284
  try {
267
- before = await readRegularFileSnapshot(p);
285
+ writeBoundary = bindAtomicWritePath(p, "patch");
286
+ before = await readVerifiedRegularFileSnapshot(writeBoundary.target, undefined, "patch");
268
287
  }
269
288
  catch (error) {
270
289
  return `Error: ${tag} update ${ch.path}: cannot read (${error?.message ?? "unknown error"}; use type:create for a new file). Nothing written.`;
271
290
  }
272
291
  if (typeof ch.content === "string" && !ch.edits) {
273
- plans.push({ path: ch.path, abs: p, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: ch.content, existed: true });
292
+ plans.push({ path: ch.path, abs: writeBoundary.target, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: ch.content, existed: true, writeBoundary });
274
293
  }
275
294
  else {
276
295
  const res = applyEdits(before.text, ch.edits ?? []);
277
296
  if ("error" in res)
278
297
  return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
279
- plans.push({ path: ch.path, abs: p, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: res.text, existed: true });
298
+ plans.push({ path: ch.path, abs: writeBoundary.target, type, before: before.text, beforeMode: before.mode, beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink }, after: res.text, existed: true, writeBoundary });
280
299
  }
281
300
  }
282
301
  }
@@ -294,6 +313,7 @@ registerTool({
294
313
  pl.committed = await atomicWriteText(pl.abs, pl.after, {
295
314
  expected: pl.existed ? pl.before : null,
296
315
  expectedIdentity: pl.existed ? pl.beforeIdentity : undefined,
316
+ boundary: pl.writeBoundary,
297
317
  });
298
318
  }
299
319
  applied.push(pl);