@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.
- package/CHANGELOG.md +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- 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
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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.
|
|
109
|
-
|
|
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
|
});
|
package/dist/tools/memory.js
CHANGED
|
@@ -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 {
|
|
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((
|
|
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
|
|
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"
|
|
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
|
-
|
|
121
|
-
|
|
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
|
});
|
package/dist/tools/patch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
writeBoundary = bindAtomicParentEntryPath(p, "patch");
|
|
240
|
+
pathInfo = await lstat(writeBoundary.target);
|
|
230
241
|
if (pathInfo.isSymbolicLink())
|
|
231
|
-
beforeLinkTarget = await readlink(
|
|
232
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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);
|