@nanhara/hara 0.121.1 → 0.122.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +57 -10
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +169 -31
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +24 -6
  9. package/dist/checkpoints.js +103 -17
  10. package/dist/cli.js +16 -0
  11. package/dist/config.js +173 -34
  12. package/dist/context/agents-md.js +44 -9
  13. package/dist/context/mentions.js +10 -4
  14. package/dist/context/subdir-hints.js +40 -7
  15. package/dist/cron/deliver.js +37 -3
  16. package/dist/cron/runner.js +372 -37
  17. package/dist/cron/store.js +11 -3
  18. package/dist/exec/jobs.js +88 -20
  19. package/dist/feedback.js +3 -2
  20. package/dist/fs-read.js +421 -12
  21. package/dist/fs-walk.js +8 -2
  22. package/dist/fs-write.js +433 -21
  23. package/dist/gateway/dingtalk.js +4 -1
  24. package/dist/gateway/discord.js +53 -20
  25. package/dist/gateway/feishu.js +157 -58
  26. package/dist/gateway/flows-pending.js +727 -0
  27. package/dist/gateway/flows.js +391 -16
  28. package/dist/gateway/matrix.js +81 -18
  29. package/dist/gateway/mattermost.js +44 -34
  30. package/dist/gateway/media.js +659 -0
  31. package/dist/gateway/outbound-files.js +379 -0
  32. package/dist/gateway/serve.js +712 -169
  33. package/dist/gateway/sessions.js +475 -78
  34. package/dist/gateway/signal.js +31 -28
  35. package/dist/gateway/slack.js +28 -21
  36. package/dist/gateway/telegram.js +33 -21
  37. package/dist/gateway/tmux-routes.js +11 -3
  38. package/dist/gateway/wecom.js +38 -31
  39. package/dist/gateway/weixin.js +147 -59
  40. package/dist/hooks.js +41 -23
  41. package/dist/index.js +763 -273
  42. package/dist/mcp/client.js +164 -12
  43. package/dist/memory/store.js +68 -22
  44. package/dist/org/planner.js +36 -10
  45. package/dist/org/projects.js +347 -0
  46. package/dist/org/review-chain.js +360 -24
  47. package/dist/org/roles.js +42 -13
  48. package/dist/profile/profile.js +152 -27
  49. package/dist/recall.js +4 -2
  50. package/dist/runtime.js +37 -0
  51. package/dist/sandbox.js +142 -33
  52. package/dist/search/semindex.js +182 -53
  53. package/dist/search/zvec-store.js +121 -42
  54. package/dist/security/permissions.js +326 -19
  55. package/dist/security/private-state.js +299 -0
  56. package/dist/security/project-trust.js +6 -0
  57. package/dist/security/secrets.js +84 -9
  58. package/dist/security/sensitive-files.js +723 -0
  59. package/dist/security/subprocess-env.js +210 -0
  60. package/dist/serve/server.js +774 -318
  61. package/dist/serve/sessions.js +113 -33
  62. package/dist/session/store.js +298 -47
  63. package/dist/skills/skills.js +16 -7
  64. package/dist/tools/all.js +1 -0
  65. package/dist/tools/builtin.js +77 -49
  66. package/dist/tools/codebase.js +3 -1
  67. package/dist/tools/computer.js +98 -92
  68. package/dist/tools/cron.js +6 -0
  69. package/dist/tools/edit.js +22 -9
  70. package/dist/tools/external_agent.js +110 -16
  71. package/dist/tools/memory.js +38 -8
  72. package/dist/tools/patch.js +253 -34
  73. package/dist/tools/search.js +543 -73
  74. package/dist/tools/send.js +11 -5
  75. package/dist/tools/task.js +453 -0
  76. package/dist/tools/todo.js +67 -16
  77. package/dist/tools/web.js +168 -54
  78. package/dist/undo.js +83 -7
  79. package/package.json +11 -10
  80. package/runtime-bootstrap.cjs +72 -0
@@ -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,13 +1,164 @@
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 { lstat, readFile, unlink } from "node:fs/promises";
4
- import { isAbsolute, resolve } from "node:path";
3
+ import { linkSync, lstatSync, readlinkSync, renameSync, symlinkSync } from "node:fs";
4
+ import { lstat, readlink, rename } from "node:fs/promises";
5
+ import { randomUUID } from "node:crypto";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
7
  import { registerTool } from "./registry.js";
6
8
  import { applyEdits } from "./apply-core.js";
7
9
  import { emitDiff } from "../diff.js";
8
10
  import { recordEdit } from "../undo.js";
9
- import { atomicWriteText, FileChangedError } from "../fs-write.js";
11
+ import { atomicWriteText, bindAtomicParentEntryPath, bindAtomicWritePath, discardClaimedPath, FileChangedError, removeCreatedDirectories, verifyAtomicWriteBoundary, } from "../fs-write.js";
10
12
  import { invalidateFileCandidates } from "../context/mentions.js";
13
+ import { readRegularFileSnapshot, readRegularFileSnapshotNoFollow, readVerifiedRegularFileSnapshot, resolveVerifiedModelPath, } from "../fs-read.js";
14
+ import { sensitiveFileError } from "../security/sensitive-files.js";
15
+ async function restoreMovedFile(quarantine, target) {
16
+ let identity;
17
+ try {
18
+ // Keep source inspection + create-if-absent in one JS turn. The subsequent discard performs another
19
+ // synchronous identity claim, so a watcher cannot turn cleanup into an unlink of an unrelated entry.
20
+ const info = lstatSync(quarantine);
21
+ const linkTarget = info.isSymbolicLink() ? readlinkSync(quarantine) : undefined;
22
+ if (linkTarget !== undefined)
23
+ symlinkSync(linkTarget, target);
24
+ else
25
+ linkSync(quarantine, target);
26
+ identity = {
27
+ dev: info.dev,
28
+ ino: info.ino,
29
+ mode: info.mode & 0o777,
30
+ nlink: info.nlink + (linkTarget === undefined ? 1 : 0),
31
+ linkTarget,
32
+ };
33
+ }
34
+ catch (error) {
35
+ if (error?.code === "EEXIST") {
36
+ throw new Error(`another file appeared at ${target}; the concurrently moved file is preserved at ${quarantine}`);
37
+ }
38
+ throw error;
39
+ }
40
+ discardClaimedPath(quarantine, identity);
41
+ }
42
+ /** Roll back a file we wrote without read→unlink/rename TOCTOU. First atomically move whichever inode is
43
+ * currently at the path aside, then inspect that moved inode. A concurrent replacement is restored with
44
+ * create-if-absent and never deleted/overwritten. */
45
+ async function rollbackWrittenPlan(plan) {
46
+ if (!plan.committed || plan.after === null)
47
+ throw new Error(`missing commit identity for ${plan.path}`);
48
+ const target = plan.committed.target;
49
+ const quarantine = join(dirname(target), `.hara-rollback-${process.pid}-${randomUUID()}.tmp`);
50
+ // atomicWriteText follows a destination symlink and replaces its real target. Roll back that exact returned
51
+ // target path; moving plan.abs would destroy the symlink while leaving the changed target untouched.
52
+ await rename(target, quarantine);
53
+ let current;
54
+ try {
55
+ current = await readRegularFileSnapshotNoFollow(quarantine);
56
+ }
57
+ catch (error) {
58
+ await restoreMovedFile(quarantine, target);
59
+ throw error;
60
+ }
61
+ const owned = current.dev === plan.committed.dev && current.ino === plan.committed.ino && current.mode === plan.committed.mode && current.nlink === plan.committed.nlink && current.text === plan.after;
62
+ if (!owned) {
63
+ await restoreMovedFile(quarantine, target);
64
+ throw new FileChangedError(plan.path);
65
+ }
66
+ if (plan.type === "create") {
67
+ discardClaimedPath(quarantine, plan.committed);
68
+ await removeCreatedDirectories(plan.committed.createdDirs);
69
+ return;
70
+ }
71
+ if (plan.beforeMode === undefined) {
72
+ await restoreMovedFile(quarantine, target);
73
+ throw new Error(`missing preflight mode for ${plan.path}`);
74
+ }
75
+ try {
76
+ await atomicWriteText(target, plan.before, { expected: null, mode: plan.beforeMode });
77
+ }
78
+ catch (error) {
79
+ try {
80
+ await restoreMovedFile(quarantine, target);
81
+ }
82
+ catch (restoreError) {
83
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; could not restore quarantine: ${restoreError?.message ?? String(restoreError)}`);
84
+ }
85
+ throw error;
86
+ }
87
+ discardClaimedPath(quarantine, plan.committed);
88
+ }
89
+ async function quarantineExpectedDelete(plan) {
90
+ if (!plan.beforeIdentity || !plan.beforePathIdentity || plan.beforeMode === undefined)
91
+ throw new Error(`missing preflight identity for ${plan.path}`);
92
+ if (!plan.writeBoundary)
93
+ throw new Error(`missing parent boundary for ${plan.path}`);
94
+ // Keep the initially moved path private until it has been verified. A directory watcher must not be able
95
+ // to mistake an unverified staging entry for the durable delete quarantine used by the commit/cleanup
96
+ // phases. Recording it immediately also lets the outer rollback recover (or accurately report) a failure
97
+ // that happens after rename but before verification completes.
98
+ const staging = join(dirname(plan.abs), `.hara-stage-delete-${process.pid}-${randomUUID()}.tmp`);
99
+ verifyAtomicWriteBoundary(plan.writeBoundary);
100
+ renameSync(plan.abs, staging);
101
+ plan.quarantine = staging;
102
+ const movedPath = await lstat(staging);
103
+ const samePath = movedPath.dev === plan.beforePathIdentity.dev && movedPath.ino === plan.beforePathIdentity.ino && (movedPath.mode & 0o777) === plan.beforePathIdentity.mode && movedPath.nlink === plan.beforePathIdentity.nlink;
104
+ const isExpectedLink = plan.beforeLinkTarget !== undefined;
105
+ const sameLink = movedPath.isSymbolicLink() === isExpectedLink && (!isExpectedLink || await readlink(staging) === plan.beforeLinkTarget);
106
+ if (!samePath || !sameLink)
107
+ throw new FileChangedError(plan.path);
108
+ const moved = isExpectedLink
109
+ ? await readRegularFileSnapshot(staging)
110
+ : await readRegularFileSnapshotNoFollow(staging);
111
+ if (moved.dev !== plan.beforeIdentity.dev || moved.ino !== plan.beforeIdentity.ino || moved.mode !== plan.beforeMode || moved.nlink !== plan.beforeIdentity.nlink || moved.text !== plan.before) {
112
+ throw new FileChangedError(plan.path);
113
+ }
114
+ const quarantine = join(dirname(plan.abs), `.hara-delete-${process.pid}-${randomUUID()}.tmp`);
115
+ verifyAtomicWriteBoundary(plan.writeBoundary);
116
+ renameSync(staging, quarantine);
117
+ plan.quarantine = quarantine;
118
+ }
119
+ /** Move a delete quarantine to a fresh name and verify that exact moved path before restore/cleanup. If a
120
+ * concurrent process replaced the known quarantine name, preserve the unexpected inode and fail closed. */
121
+ async function claimDeleteQuarantine(plan, purpose) {
122
+ if (!plan.quarantine || !plan.beforePathIdentity || !plan.beforeIdentity || plan.beforeMode === undefined || !plan.writeBoundary) {
123
+ throw new Error(`missing delete quarantine identity for ${plan.path}`);
124
+ }
125
+ const claimed = join(dirname(plan.quarantine), `.hara-${purpose}-${process.pid}-${randomUUID()}.tmp`);
126
+ verifyAtomicWriteBoundary(plan.writeBoundary);
127
+ renameSync(plan.quarantine, claimed);
128
+ try {
129
+ const pathInfo = await lstat(claimed);
130
+ const expectedPath = plan.beforePathIdentity;
131
+ const expectedLink = plan.beforeLinkTarget !== undefined;
132
+ if (pathInfo.dev !== expectedPath.dev ||
133
+ pathInfo.ino !== expectedPath.ino ||
134
+ (pathInfo.mode & 0o777) !== expectedPath.mode ||
135
+ pathInfo.nlink !== expectedPath.nlink ||
136
+ pathInfo.isSymbolicLink() !== expectedLink ||
137
+ (expectedLink && await readlink(claimed) !== plan.beforeLinkTarget)) {
138
+ throw new FileChangedError(plan.path);
139
+ }
140
+ if (!expectedLink) {
141
+ const file = await readRegularFileSnapshotNoFollow(claimed);
142
+ if (file.dev !== plan.beforeIdentity.dev ||
143
+ file.ino !== plan.beforeIdentity.ino ||
144
+ file.mode !== plan.beforeMode ||
145
+ file.nlink !== plan.beforeIdentity.nlink ||
146
+ file.text !== plan.before) {
147
+ throw new FileChangedError(plan.path);
148
+ }
149
+ }
150
+ return claimed;
151
+ }
152
+ catch (error) {
153
+ try {
154
+ await restoreMovedFile(claimed, plan.quarantine);
155
+ }
156
+ catch (restoreError) {
157
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; unexpected quarantine is preserved at ${claimed}: ${restoreError?.message ?? String(restoreError)}`);
158
+ }
159
+ throw error;
160
+ }
161
+ }
11
162
  registerTool({
12
163
  name: "apply_patch",
13
164
  description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
@@ -60,6 +211,9 @@ registerTool({
60
211
  if (typeof ch.path !== "string" || !ch.path)
61
212
  return `Error: ${tag} is missing a path. Nothing written.`;
62
213
  const p = abs(ch.path);
214
+ const denied = sensitiveFileError(p, "patch");
215
+ if (denied)
216
+ return `Error: ${tag}: ${denied} Nothing written.`;
63
217
  if (plannedPaths.has(p))
64
218
  return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
65
219
  plannedPaths.add(p);
@@ -78,44 +232,70 @@ registerTool({
78
232
  }
79
233
  if (type === "delete") {
80
234
  let before;
235
+ let pathInfo;
236
+ let beforeLinkTarget;
237
+ let writeBoundary;
81
238
  try {
82
- before = await readFile(p, "utf8");
239
+ writeBoundary = bindAtomicParentEntryPath(p, "patch");
240
+ pathInfo = await lstat(writeBoundary.target);
241
+ if (pathInfo.isSymbolicLink())
242
+ beforeLinkTarget = await readlink(writeBoundary.target);
243
+ const target = resolveVerifiedModelPath(writeBoundary.target, "patch");
244
+ before = await readVerifiedRegularFileSnapshot(target, undefined, "patch");
83
245
  }
84
- catch {
85
- return `Error: ${tag} delete ${ch.path}: file not found. Nothing written.`;
246
+ catch (error) {
247
+ return `Error: ${tag} delete ${ch.path}: ${error?.message ?? "file not found"}. Nothing written.`;
86
248
  }
87
- plans.push({ path: ch.path, abs: p, type, before, after: null, existed: true });
249
+ plans.push({
250
+ path: ch.path,
251
+ abs: writeBoundary.target,
252
+ type,
253
+ before: before.text,
254
+ beforeMode: before.mode,
255
+ beforeIdentity: { dev: before.dev, ino: before.ino, mode: before.mode, nlink: before.nlink },
256
+ beforePathIdentity: { dev: pathInfo.dev, ino: pathInfo.ino, mode: pathInfo.mode & 0o777, nlink: pathInfo.nlink },
257
+ beforeLinkTarget,
258
+ writeBoundary,
259
+ after: null,
260
+ existed: true,
261
+ });
88
262
  }
89
263
  else if (type === "create") {
90
264
  if (typeof ch.content !== "string")
91
265
  return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
266
+ let writeBoundary;
92
267
  try {
93
- await lstat(p);
268
+ writeBoundary = bindAtomicWritePath(p, "patch");
269
+ await lstat(writeBoundary.target);
94
270
  return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
95
271
  }
96
272
  catch (error) {
97
- if (error?.code !== "ENOENT")
273
+ if (error?.code !== "ENOENT" || !writeBoundary)
98
274
  return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
99
275
  }
100
- 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 });
101
279
  }
102
280
  else {
103
281
  // update
104
282
  let before;
283
+ let writeBoundary;
105
284
  try {
106
- before = await readFile(p, "utf8");
285
+ writeBoundary = bindAtomicWritePath(p, "patch");
286
+ before = await readVerifiedRegularFileSnapshot(writeBoundary.target, undefined, "patch");
107
287
  }
108
- catch {
109
- return `Error: ${tag} update ${ch.path}: cannot read (use type:create for a new file). Nothing written.`;
288
+ catch (error) {
289
+ return `Error: ${tag} update ${ch.path}: cannot read (${error?.message ?? "unknown error"}; use type:create for a new file). Nothing written.`;
110
290
  }
111
291
  if (typeof ch.content === "string" && !ch.edits) {
112
- plans.push({ path: ch.path, abs: p, type, before, 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 });
113
293
  }
114
294
  else {
115
- const res = applyEdits(before, ch.edits ?? []);
295
+ const res = applyEdits(before.text, ch.edits ?? []);
116
296
  if ("error" in res)
117
297
  return `Error: ${tag} ${ch.path} — ${res.error}. Nothing written.`;
118
- plans.push({ path: ch.path, abs: p, type, before, 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 });
119
299
  }
120
300
  }
121
301
  }
@@ -125,31 +305,41 @@ registerTool({
125
305
  try {
126
306
  for (const pl of plans) {
127
307
  if (pl.type === "delete") {
128
- let current;
129
- try {
130
- current = await readFile(pl.abs, "utf8");
131
- }
132
- catch {
133
- throw new FileChangedError(pl.path);
134
- }
135
- if (current !== pl.before)
136
- throw new FileChangedError(pl.path);
137
- await unlink(pl.abs);
308
+ // Atomic move-then-inspect: a replacement inode is moved aside and restored, never unlinked.
309
+ // The expected inode remains quarantined until every other patch step has committed.
310
+ await quarantineExpectedDelete(pl);
138
311
  }
139
312
  else {
140
- await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
313
+ pl.committed = await atomicWriteText(pl.abs, pl.after, {
314
+ expected: pl.existed ? pl.before : null,
315
+ expectedIdentity: pl.existed ? pl.beforeIdentity : undefined,
316
+ boundary: pl.writeBoundary,
317
+ });
141
318
  }
142
319
  applied.push(pl);
143
320
  }
144
321
  }
145
322
  catch (e) {
146
323
  const rollbackFailures = [];
147
- for (const pl of applied.reverse()) {
324
+ // A delete becomes a visible mutation at its first rename, before its verification can finish. Include
325
+ // that partially staged plan in rollback instead of claiming that an empty `applied` list means the
326
+ // tree was untouched.
327
+ const rollbackPlans = [...applied];
328
+ for (const pl of plans) {
329
+ if (pl.quarantine && !rollbackPlans.includes(pl))
330
+ rollbackPlans.push(pl);
331
+ }
332
+ for (const pl of rollbackPlans.reverse()) {
148
333
  try {
149
- if (pl.type === "create" && !pl.existed)
150
- await unlink(pl.abs); // remove a file we created
151
- else
152
- await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
334
+ if (pl.type === "delete") {
335
+ if (!pl.quarantine)
336
+ throw new Error(`missing delete quarantine for ${pl.path}`);
337
+ const claimed = await claimDeleteQuarantine(pl, "restore");
338
+ await restoreMovedFile(claimed, pl.abs);
339
+ }
340
+ else {
341
+ await rollbackWrittenPlan(pl);
342
+ }
153
343
  }
154
344
  catch (rollbackError) {
155
345
  rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
@@ -160,13 +350,42 @@ registerTool({
160
350
  }
161
351
  return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
162
352
  }
353
+ // Deletes become final only after every commit step succeeds. Cleanup cannot affect visible paths;
354
+ // report a rare failure rather than rolling back after another quarantine may already be removed.
355
+ const cleanupFailures = [];
356
+ for (const pl of plans) {
357
+ for (const warning of pl.committed?.warnings ?? [])
358
+ cleanupFailures.push(`${pl.path}: ${warning}`);
359
+ }
360
+ for (const pl of plans) {
361
+ if (!pl.quarantine)
362
+ continue;
363
+ try {
364
+ const claimed = await claimDeleteQuarantine(pl, "cleanup");
365
+ if (!pl.beforePathIdentity)
366
+ throw new Error(`missing delete path identity for ${pl.path}`);
367
+ discardClaimedPath(claimed, { ...pl.beforePathIdentity, linkTarget: pl.beforeLinkTarget });
368
+ }
369
+ catch (error) {
370
+ cleanupFailures.push(`${pl.path}: old entry cleanup was refused (${error?.message ?? String(error)})`);
371
+ }
372
+ }
163
373
  // All writes succeeded → now show diffs + record the undo snapshot.
164
374
  const summary = plans.map((pl) => {
165
375
  emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
166
376
  return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
167
377
  });
168
- recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
378
+ recordEdit(plans.map((pl) => ({
379
+ path: pl.path,
380
+ absPath: pl.abs,
381
+ before: pl.existed ? pl.before : null,
382
+ beforeMode: pl.beforeMode,
383
+ linkTarget: pl.type === "delete" ? pl.beforeLinkTarget : undefined,
384
+ removed: pl.type === "delete",
385
+ committed: pl.committed,
386
+ after: pl.after ?? undefined,
387
+ })));
169
388
  invalidateFileCandidates(ctx.cwd);
170
- return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
389
+ return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.` + (cleanupFailures.length ? ` Warning: ${cleanupFailures.join("; ")}` : "");
171
390
  },
172
391
  });