@nanhara/hara 0.122.0 → 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.
- package/CHANGELOG.md +54 -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
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|