@nanhara/hara 0.124.0 → 0.124.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 +19 -0
- package/dist/cron/store.js +4 -3
- package/dist/fs-identity.js +11 -0
- package/dist/fs-open-flags.js +14 -0
- package/dist/fs-read.js +12 -15
- package/dist/fs-write.js +4 -3
- package/dist/gateway/outbound-files.js +7 -8
- package/dist/gateway/runtime-state.js +11 -20
- package/dist/org/review-chain.js +2 -2
- package/dist/security/permissions.js +4 -3
- package/dist/security/private-state.js +55 -22
- package/dist/serve/server.js +6 -4
- package/dist/tools/search.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.124.1 — 2026-07-16 — Windows native private-state and file-identity portability
|
|
9
|
+
|
|
10
|
+
- **Windows private state works in both Node and the native Bun executable.** Exclusive staging creation now
|
|
11
|
+
uses the portable `wx`/`CREATE_NEW` contract instead of passing Bun numeric flags that could surface a false
|
|
12
|
+
`ENOENT`. Config, credentials, gateway state, discovery records, and other private atomic writers retain
|
|
13
|
+
create-if-absent publication, hard-link fencing, fsync, and fail-closed alias checks.
|
|
14
|
+
- **Descriptor-to-path identity checks follow the host's real guarantees.** Windows Node may report a
|
|
15
|
+
volume-derived `dev` from `fstat` but `dev=0` from `lstat` for the exact same NTFS file. Hara now correlates
|
|
16
|
+
an already-open descriptor with its already-bounded path using the stable file id and link count, while
|
|
17
|
+
POSIX still requires device, inode, and owner-only mode. Arbitrary path-to-path protected-file detection
|
|
18
|
+
continues to require device + inode, so the portability fix does not weaken hard-link protection.
|
|
19
|
+
- **The release gate now exercises the reported failure on a real Windows runtime.** CI installs the pinned
|
|
20
|
+
Bun 1.3.9 x64-baseline runtime, runs portable file-state contracts, compiles `hara.exe`, and executes the
|
|
21
|
+
hostile-cwd standalone smoke including an isolated `hara doctor`. Bounded failure annotations keep future
|
|
22
|
+
Windows-only regressions diagnosable without exposing credentials. Production dependency audits retry a
|
|
23
|
+
transient registry failure only within a fixed bound and still require a real successful audit result.
|
|
24
|
+
- Windows users on 0.124.0 should upgrade with `npm i -g @nanhara/hara@0.124.1`; Desktop releases that bundle
|
|
25
|
+
a sidecar should pin this patch or newer.
|
|
26
|
+
|
|
8
27
|
## 0.124.0 — 2026-07-16 — task-safe interaction, bounded context, and private-state hardening
|
|
9
28
|
|
|
10
29
|
- **Idle conversation no longer hijacks an unfinished task.** Ordinary input starts a new execution while
|
package/dist/cron/store.js
CHANGED
|
@@ -7,6 +7,8 @@ import { chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lsta
|
|
|
7
7
|
import { cronMatches, parseCron, validTz } from "./schedule.js";
|
|
8
8
|
import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
|
|
9
9
|
import { sleepSync } from "../sync-sleep.js";
|
|
10
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
11
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
10
12
|
/** Hard persistence bounds: outages must apply backpressure instead of growing jobs.json forever. */
|
|
11
13
|
export const MAX_CRON_PENDING_NOTIFICATIONS = 64;
|
|
12
14
|
const CRON_RUN_NOTIFICATION_RESERVE = 2;
|
|
@@ -43,11 +45,10 @@ function readStoreLockSnapshot(path) {
|
|
|
43
45
|
const before = lstatSync(path);
|
|
44
46
|
let raw = null;
|
|
45
47
|
if (before.isFile() && before.size <= MAX_STORE_LOCK_BYTES) {
|
|
46
|
-
const
|
|
47
|
-
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
48
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
48
49
|
try {
|
|
49
50
|
const opened = fstatSync(fd);
|
|
50
|
-
if (opened.isFile() && opened
|
|
51
|
+
if (opened.isFile() && sameOpenedFileIdentity(opened, before) && opened.size <= MAX_STORE_LOCK_BYTES) {
|
|
51
52
|
const buffer = Buffer.alloc(opened.size);
|
|
52
53
|
let offset = 0;
|
|
53
54
|
while (offset < buffer.length) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlate an already-open descriptor with a stat of the same bounded path. Node on Windows may report
|
|
3
|
+
* a volume-derived `dev` for fstat and `0` for lstat/stat of that exact NTFS file. Its file id (`ino`) stays
|
|
4
|
+
* stable, and the caller has already fixed the path/parent boundary, so requiring `dev` there rejects valid
|
|
5
|
+
* files without adding an identity fence. POSIX retains the ordinary device + inode comparison.
|
|
6
|
+
*
|
|
7
|
+
* Do not use this to decide whether two arbitrary Windows paths are hard links across unrelated roots.
|
|
8
|
+
*/
|
|
9
|
+
export function sameOpenedFileIdentity(left, right, platform = process.platform) {
|
|
10
|
+
return left.ino === right.ino && (platform === "win32" || left.dev === right.dev);
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Bun's Windows compatibility layer may expose POSIX-only fs constants even though CreateFileW cannot
|
|
4
|
+
* consume them. Node documents that Windows supports neither O_DIRECTORY, O_NOFOLLOW nor O_NONBLOCK;
|
|
5
|
+
* passing Bun's numeric values can turn an otherwise valid create/open into a misleading ENOENT.
|
|
6
|
+
*
|
|
7
|
+
* Windows callers retain their lstat/fstat/identity checks and O_CREAT|O_EXCL no-replace semantics.
|
|
8
|
+
*/
|
|
9
|
+
export function optionalPosixOpenFlag(name, platform = process.platform) {
|
|
10
|
+
if (platform === "win32")
|
|
11
|
+
return 0;
|
|
12
|
+
const value = constants[name];
|
|
13
|
+
return typeof value === "number" ? value : 0;
|
|
14
|
+
}
|
package/dist/fs-read.js
CHANGED
|
@@ -4,6 +4,8 @@ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpat
|
|
|
4
4
|
import { open } from "node:fs/promises";
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
6
|
import { sensitiveFileError } from "./security/sensitive-files.js";
|
|
7
|
+
import { optionalPosixOpenFlag } from "./fs-open-flags.js";
|
|
8
|
+
import { sameOpenedFileIdentity } from "./fs-identity.js";
|
|
7
9
|
export class BinaryFileError extends Error {
|
|
8
10
|
constructor(path) {
|
|
9
11
|
super(`${path} appears to be binary (NUL byte detected)`);
|
|
@@ -100,10 +102,8 @@ export function verifyOpenedRegularFileSync(path, opened, options = {}) {
|
|
|
100
102
|
const currentLink = lstatSync(path);
|
|
101
103
|
const currentTarget = statSync(canonical);
|
|
102
104
|
if (currentLink.isSymbolicLink()
|
|
103
|
-
|| currentLink
|
|
104
|
-
||
|
|
105
|
-
|| currentTarget.dev !== opened.dev
|
|
106
|
-
|| currentTarget.ino !== opened.ino) {
|
|
105
|
+
|| !sameOpenedFileIdentity(currentLink, opened)
|
|
106
|
+
|| !sameOpenedFileIdentity(currentTarget, opened)) {
|
|
107
107
|
throw new Error(`refusing to access ${path}: path changed while opening it`);
|
|
108
108
|
}
|
|
109
109
|
return canonical;
|
|
@@ -118,8 +118,7 @@ export async function openVerifiedRegularFileNoFollow(path, options = {}) {
|
|
|
118
118
|
const before = lstatSync(path);
|
|
119
119
|
if (before.isSymbolicLink())
|
|
120
120
|
throw new NonRegularFileError(path);
|
|
121
|
-
const
|
|
122
|
-
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
121
|
+
const handle = await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
123
122
|
try {
|
|
124
123
|
const info = await handle.stat();
|
|
125
124
|
const canonicalPath = verifyOpenedRegularFileSync(path, info, options);
|
|
@@ -189,8 +188,7 @@ export function readVerifiedRegularFileSnapshotSync(path, maxBytes = MAX_EDIT_RE
|
|
|
189
188
|
const before = lstatSync(path);
|
|
190
189
|
if (before.isSymbolicLink())
|
|
191
190
|
throw new NonRegularFileError(path);
|
|
192
|
-
const
|
|
193
|
-
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
191
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
194
192
|
try {
|
|
195
193
|
const info = fstatSync(fd);
|
|
196
194
|
verifyOpenedRegularFileSync(path, info, {
|
|
@@ -243,11 +241,10 @@ function withVerifiedContextFdSync(path, read) {
|
|
|
243
241
|
throw new ProtectedContextFileError(denied);
|
|
244
242
|
// O_NOFOLLOW is not exposed on every platform. The before/after lstat checks retain fail-closed symlink
|
|
245
243
|
// behaviour there; on POSIX O_NOFOLLOW makes the critical open itself atomic.
|
|
246
|
-
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
247
244
|
const before = lstatSync(path);
|
|
248
245
|
if (before.isSymbolicLink())
|
|
249
246
|
throw new NonRegularFileError(path);
|
|
250
|
-
const fd = openSync(path, constants.O_RDONLY |
|
|
247
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
251
248
|
try {
|
|
252
249
|
const info = fstatSync(fd);
|
|
253
250
|
verifyOpenedRegularFileSync(path, info, {
|
|
@@ -387,12 +384,11 @@ async function readRegularFileSnapshotWithFlags(path, maxBytes, flags) {
|
|
|
387
384
|
}
|
|
388
385
|
}
|
|
389
386
|
export async function readRegularFileSnapshot(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
390
|
-
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY |
|
|
387
|
+
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
|
|
391
388
|
}
|
|
392
389
|
/** Quarantine/transaction reader: reject a symlink at open(2), then validate/read that same fd. */
|
|
393
390
|
export async function readRegularFileSnapshotNoFollow(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
394
|
-
|
|
395
|
-
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
391
|
+
return readRegularFileSnapshotWithFlags(path, maxBytes, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
396
392
|
}
|
|
397
393
|
export async function readRegularFileText(path, maxBytes = MAX_EDIT_READ_BYTES) {
|
|
398
394
|
return (await readRegularFileSnapshot(path, maxBytes)).text;
|
|
@@ -402,7 +398,7 @@ export function readTextPrefixSync(path, maxChars) {
|
|
|
402
398
|
const requested = Number.isFinite(maxChars) ? Math.floor(maxChars) : MAX_PREFIX_CHARS;
|
|
403
399
|
const chars = Math.min(MAX_PREFIX_CHARS, Math.max(0, requested));
|
|
404
400
|
// O_NONBLOCK makes opening a FIFO return immediately; fstat on this exact fd then rejects it before read.
|
|
405
|
-
const fd = openSync(path, constants.O_RDONLY |
|
|
401
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
|
|
406
402
|
try {
|
|
407
403
|
const info = fstatSync(fd);
|
|
408
404
|
if (!info.isFile())
|
|
@@ -504,7 +500,8 @@ export async function streamFileSlice(path, offset = 1, limit = 300, options = {
|
|
|
504
500
|
const verified = options.protectSensitive
|
|
505
501
|
? await openVerifiedRegularFileNoFollow(path, { action: "read", rejectHardLinks: true, protectSensitive: true })
|
|
506
502
|
: null;
|
|
507
|
-
const handle = verified?.handle
|
|
503
|
+
const handle = verified?.handle
|
|
504
|
+
?? await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
|
|
508
505
|
let stream;
|
|
509
506
|
try {
|
|
510
507
|
const info = verified?.info ?? await handle.stat();
|
package/dist/fs-write.js
CHANGED
|
@@ -5,6 +5,8 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { constants, linkSync, lstatSync, readlinkSync, realpathSync, renameSync, symlinkSync, unlinkSync } from "node:fs";
|
|
6
6
|
import { lstat, mkdir, open, realpath, rmdir, stat } from "node:fs/promises";
|
|
7
7
|
import { NonRegularFileError, readRegularFileSnapshotNoFollow } from "./fs-read.js";
|
|
8
|
+
import { optionalPosixOpenFlag } from "./fs-open-flags.js";
|
|
9
|
+
import { sameOpenedFileIdentity } from "./fs-identity.js";
|
|
8
10
|
import { canonicalizeProspectivePath, lexicalSensitiveFileReason, sensitiveFileError, } from "./security/sensitive-files.js";
|
|
9
11
|
export class FileChangedError extends Error {
|
|
10
12
|
code = "HARA_FILE_CHANGED";
|
|
@@ -296,7 +298,7 @@ async function syncDirectory(path) {
|
|
|
296
298
|
try {
|
|
297
299
|
// The directory name can be exchanged after rename. O_NONBLOCK plus fstat on this exact descriptor
|
|
298
300
|
// keeps best-effort durability from hanging forever on a replacement FIFO/device.
|
|
299
|
-
const handle = await open(path, constants.O_RDONLY |
|
|
301
|
+
const handle = await open(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK"));
|
|
300
302
|
try {
|
|
301
303
|
if (!(await handle.stat()).isDirectory())
|
|
302
304
|
return;
|
|
@@ -508,8 +510,7 @@ export async function atomicWriteText(path, content, options = {}) {
|
|
|
508
510
|
verifyCommitParent();
|
|
509
511
|
const current = lstatSync(temp);
|
|
510
512
|
if (current.isFile()
|
|
511
|
-
&& current
|
|
512
|
-
&& current.ino === writtenIdentity.ino)
|
|
513
|
+
&& sameOpenedFileIdentity(current, writtenIdentity))
|
|
513
514
|
unlinkSync(temp);
|
|
514
515
|
}
|
|
515
516
|
catch {
|
|
@@ -6,6 +6,8 @@ import { chmodSync, constants, lstatSync, mkdirSync, readdirSync, realpathSync,
|
|
|
6
6
|
import { open } from "node:fs/promises";
|
|
7
7
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
8
8
|
import { openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync } from "../fs-read.js";
|
|
9
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
9
11
|
export const OUTBOUND_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
10
12
|
export const OUTBOUND_BATCH_MAX_BYTES = 20 * 1024 * 1024;
|
|
11
13
|
export const OUTBOUND_BATCH_MAX_FILES = 4;
|
|
@@ -90,17 +92,15 @@ async function appendOutbox(outbox, snapshot, signal) {
|
|
|
90
92
|
if (error?.code !== "ENOENT")
|
|
91
93
|
throw error;
|
|
92
94
|
}
|
|
93
|
-
const
|
|
94
|
-
const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | noFollow, 0o600);
|
|
95
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | optionalPosixOpenFlag("O_NOFOLLOW"), 0o600);
|
|
95
96
|
try {
|
|
96
97
|
const info = await handle.stat();
|
|
97
98
|
const current = lstatSync(path);
|
|
98
99
|
if (!info.isFile()
|
|
99
100
|
|| info.nlink > 1
|
|
100
101
|
|| current.isSymbolicLink()
|
|
101
|
-
|| current
|
|
102
|
-
||
|
|
103
|
-
|| (before && (before.dev !== info.dev || before.ino !== info.ino))
|
|
102
|
+
|| !sameOpenedFileIdentity(current, info)
|
|
103
|
+
|| (before && !sameOpenedFileIdentity(before, info))
|
|
104
104
|
|| !ownedByProcess(path))
|
|
105
105
|
throw new Error(`unsafe gateway outbox: ${outbox}`);
|
|
106
106
|
await handle.chmod(0o600);
|
|
@@ -251,7 +251,7 @@ async function readOutbox(outbox) {
|
|
|
251
251
|
await verified?.handle.close().catch(() => { });
|
|
252
252
|
try {
|
|
253
253
|
const current = lstatSync(resolve(outbox));
|
|
254
|
-
if (verified && !current.isSymbolicLink() && current
|
|
254
|
+
if (verified && !current.isSymbolicLink() && sameOpenedFileIdentity(current, verified.info)) {
|
|
255
255
|
unlinkSync(resolve(outbox));
|
|
256
256
|
}
|
|
257
257
|
}
|
|
@@ -380,8 +380,7 @@ export function cleanupOutboundSnapshot(path) {
|
|
|
380
380
|
&& entry.isFile()
|
|
381
381
|
&& !entry.isSymbolicLink()
|
|
382
382
|
&& entry.nlink === 1
|
|
383
|
-
&& entry
|
|
384
|
-
&& entry.ino === expected.ino
|
|
383
|
+
&& sameOpenedFileIdentity(entry, expected)
|
|
385
384
|
&& ownedByProcess(candidate))
|
|
386
385
|
unlinkSync(candidate);
|
|
387
386
|
if (readdirSync(dir).length === 0)
|
|
@@ -13,6 +13,8 @@ import { atomicWriteText, bindHaraPrivateStateWritePath, FileChangedError } from
|
|
|
13
13
|
import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, } from "../security/private-state.js";
|
|
14
14
|
import { sleepSync } from "../sync-sleep.js";
|
|
15
15
|
import { compareProcessIdentity, defaultProcessIdentity } from "../process-identity.js";
|
|
16
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
17
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
16
18
|
const PLATFORM = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
17
19
|
const LOCK_BYTES = 4 * 1024;
|
|
18
20
|
const LOCK_WAIT_MS = 5_000;
|
|
@@ -100,13 +102,11 @@ function leaseStagingPath(path, record) {
|
|
|
100
102
|
return `${path}.${record.pid}.${record.token}.pending`;
|
|
101
103
|
}
|
|
102
104
|
function readLease(path, platform) {
|
|
103
|
-
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
104
|
-
const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
|
|
105
105
|
let fd;
|
|
106
106
|
try {
|
|
107
107
|
// O_NONBLOCK is inert for regular files and prevents a hostile FIFO/device at the lock path from hanging
|
|
108
108
|
// gateway startup before fstat can reject it.
|
|
109
|
-
fd = openSync(path, constants.O_RDONLY |
|
|
109
|
+
fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NOFOLLOW") | optionalPosixOpenFlag("O_NONBLOCK"));
|
|
110
110
|
}
|
|
111
111
|
catch (error) {
|
|
112
112
|
if (errorCode(error) === "ENOENT")
|
|
@@ -175,8 +175,7 @@ function readLease(path, platform) {
|
|
|
175
175
|
if (!staging.isFile()
|
|
176
176
|
|| staging.isSymbolicLink()
|
|
177
177
|
|| staging.nlink !== 2
|
|
178
|
-
|| staging
|
|
179
|
-
|| staging.ino !== info.ino)
|
|
178
|
+
|| !sameOpenedFileIdentity(staging, info))
|
|
180
179
|
throw new Error(`refusing malformed gateway instance lock: ${path}`);
|
|
181
180
|
return { record, dev: info.dev, ino: info.ino, stagingPath };
|
|
182
181
|
}
|
|
@@ -187,8 +186,7 @@ function readLease(path, platform) {
|
|
|
187
186
|
function sameLease(left, right) {
|
|
188
187
|
return Boolean(left
|
|
189
188
|
&& right
|
|
190
|
-
&& left
|
|
191
|
-
&& left.ino === right.ino
|
|
189
|
+
&& sameOpenedFileIdentity(left, right)
|
|
192
190
|
&& left.record.pid === right.record.pid
|
|
193
191
|
&& left.record.token === right.record.token);
|
|
194
192
|
}
|
|
@@ -198,8 +196,7 @@ function unlinkLease(path, expected) {
|
|
|
198
196
|
|| current.isSymbolicLink()
|
|
199
197
|
|| current.nlink < 1
|
|
200
198
|
|| current.nlink > 2
|
|
201
|
-
|| current
|
|
202
|
-
|| current.ino !== expected.ino)
|
|
199
|
+
|| !sameOpenedFileIdentity(current, expected))
|
|
203
200
|
throw new Error(`gateway instance lock changed before removal: ${path}`);
|
|
204
201
|
if (current.nlink === 2) {
|
|
205
202
|
if (!expected.stagingPath)
|
|
@@ -208,30 +205,26 @@ function unlinkLease(path, expected) {
|
|
|
208
205
|
if (!staging.isFile()
|
|
209
206
|
|| staging.isSymbolicLink()
|
|
210
207
|
|| staging.nlink !== 2
|
|
211
|
-
|| staging
|
|
212
|
-
|| staging.ino !== expected.ino)
|
|
208
|
+
|| !sameOpenedFileIdentity(staging, expected))
|
|
213
209
|
throw new Error(`gateway instance lock changed before removal: ${path}`);
|
|
214
210
|
unlinkSync(expected.stagingPath);
|
|
215
211
|
current = lstatSync(path);
|
|
216
212
|
if (!current.isFile()
|
|
217
213
|
|| current.isSymbolicLink()
|
|
218
214
|
|| current.nlink !== 1
|
|
219
|
-
|| current
|
|
220
|
-
|| current.ino !== expected.ino)
|
|
215
|
+
|| !sameOpenedFileIdentity(current, expected))
|
|
221
216
|
throw new Error(`gateway instance lock changed before removal: ${path}`);
|
|
222
217
|
}
|
|
223
218
|
unlinkSync(path);
|
|
224
219
|
}
|
|
225
220
|
function writeLease(path, record) {
|
|
226
|
-
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
227
|
-
const nonBlock = typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0;
|
|
228
221
|
const stagingPath = leaseStagingPath(path, record);
|
|
229
222
|
let fd;
|
|
230
223
|
let published = false;
|
|
231
224
|
try {
|
|
232
225
|
// Never expose an empty/partial owner record at the canonical path. The private staging inode is fully
|
|
233
226
|
// written + fsynced first; link(2) then publishes it atomically and refuses to replace an existing owner.
|
|
234
|
-
fd = openSync(stagingPath,
|
|
227
|
+
fd = openSync(stagingPath, "wx", 0o600);
|
|
235
228
|
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
236
229
|
fsyncSync(fd);
|
|
237
230
|
closeSync(fd);
|
|
@@ -853,8 +846,7 @@ export class GatewayFlowRunStore {
|
|
|
853
846
|
if (!current.isFile()
|
|
854
847
|
|| current.isSymbolicLink()
|
|
855
848
|
|| current.nlink !== 1
|
|
856
|
-
|| current
|
|
857
|
-
|| current.ino !== snapshot.ino)
|
|
849
|
+
|| !sameOpenedFileIdentity(current, snapshot))
|
|
858
850
|
throw new Error(`gateway flow run changed before removal: ${path}`);
|
|
859
851
|
unlinkSync(path);
|
|
860
852
|
}
|
|
@@ -1172,8 +1164,7 @@ export class GatewayRunOutcomeStore {
|
|
|
1172
1164
|
if (!current.isFile()
|
|
1173
1165
|
|| current.isSymbolicLink()
|
|
1174
1166
|
|| current.nlink !== 1
|
|
1175
|
-
|| current
|
|
1176
|
-
|| current.ino !== snapshot.ino)
|
|
1167
|
+
|| !sameOpenedFileIdentity(current, snapshot))
|
|
1177
1168
|
throw new Error(`gateway run outcome changed before removal: ${path}`);
|
|
1178
1169
|
unlinkSync(path);
|
|
1179
1170
|
}
|
package/dist/org/review-chain.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { verifyOpenedRegularFileSync } from "../fs-read.js";
|
|
11
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
11
12
|
import { sensitiveFileReason } from "../security/sensitive-files.js";
|
|
12
13
|
import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
13
14
|
const MAX_CHANGED_PATHS = 4096;
|
|
@@ -125,8 +126,7 @@ function stagedIndexEntry(cwd, path) {
|
|
|
125
126
|
}
|
|
126
127
|
function verifiedWorktreeBlobOid(cwd, path, algorithm, maxBytes) {
|
|
127
128
|
const absolute = join(cwd, path);
|
|
128
|
-
const
|
|
129
|
-
const fd = openSync(absolute, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
129
|
+
const fd = openSync(absolute, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
130
130
|
try {
|
|
131
131
|
const before = fstatSync(fd);
|
|
132
132
|
verifyOpenedRegularFileSync(absolute, before, {
|
|
@@ -16,6 +16,7 @@ import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
|
|
|
16
16
|
import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
|
|
17
17
|
import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
|
|
18
18
|
import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
|
|
19
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
19
20
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
20
21
|
const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
|
|
21
22
|
const projectPermissionWarnings = new Set();
|
|
@@ -472,7 +473,7 @@ function scaffoldProjectPermissions(cwd) {
|
|
|
472
473
|
fd = undefined;
|
|
473
474
|
verifiedDirectory(parent, parentIdentity);
|
|
474
475
|
const staged = lstatSync(temp);
|
|
475
|
-
if (!staged.isFile() || staged.isSymbolicLink() || staged
|
|
476
|
+
if (!staged.isFile() || staged.isSymbolicLink() || !sameOpenedFileIdentity(staged, tempIdentity)) {
|
|
476
477
|
throw new Error("refusing project permissions write: staging file identity changed");
|
|
477
478
|
}
|
|
478
479
|
verifiedDirectory(parent, parentIdentity);
|
|
@@ -490,7 +491,7 @@ function scaffoldProjectPermissions(cwd) {
|
|
|
490
491
|
}
|
|
491
492
|
verifiedDirectory(parent, parentIdentity);
|
|
492
493
|
const written = lstatSync(target);
|
|
493
|
-
if (!written.isFile() || written.isSymbolicLink() || written
|
|
494
|
+
if (!written.isFile() || written.isSymbolicLink() || !sameOpenedFileIdentity(written, tempIdentity)) {
|
|
494
495
|
throw new Error("refusing project permissions write: committed file identity changed");
|
|
495
496
|
}
|
|
496
497
|
unlinkSync(temp);
|
|
@@ -507,7 +508,7 @@ function scaffoldProjectPermissions(cwd) {
|
|
|
507
508
|
try {
|
|
508
509
|
verifiedDirectory(parent, parentIdentity);
|
|
509
510
|
const current = lstatSync(temp);
|
|
510
|
-
if (current.isFile() && current
|
|
511
|
+
if (current.isFile() && sameOpenedFileIdentity(current, tempIdentity))
|
|
511
512
|
unlinkSync(temp);
|
|
512
513
|
}
|
|
513
514
|
catch {
|
|
@@ -6,6 +6,8 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
8
|
import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
|
|
9
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
10
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
9
11
|
const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
|
|
10
12
|
const tightenedHomes = new Set();
|
|
11
13
|
const DEFAULT_MIGRATION_CAP = 50_000;
|
|
@@ -64,9 +66,10 @@ function verifyAndTightenPrivateDirectory(path) {
|
|
|
64
66
|
// no POSIX ownership mode to repair
|
|
65
67
|
}
|
|
66
68
|
else {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
const fd = openSync(path, constants.O_RDONLY
|
|
70
|
+
| optionalPosixOpenFlag("O_NONBLOCK")
|
|
71
|
+
| optionalPosixOpenFlag("O_NOFOLLOW")
|
|
72
|
+
| optionalPosixOpenFlag("O_DIRECTORY"));
|
|
70
73
|
try {
|
|
71
74
|
const opened = fstatSync(fd);
|
|
72
75
|
if (!opened.isDirectory() || opened.dev !== inspected.dev || opened.ino !== inspected.ino) {
|
|
@@ -173,8 +176,7 @@ export function readPrivateStateFileSnapshotSync(path, maxBytes = MAX_EDIT_READ_
|
|
|
173
176
|
}
|
|
174
177
|
if (before.isSymbolicLink())
|
|
175
178
|
throw new Error(`refusing private Hara state file: '${path}' is a symbolic link`);
|
|
176
|
-
const
|
|
177
|
-
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | noFollow);
|
|
179
|
+
const fd = openSync(path, constants.O_RDONLY | optionalPosixOpenFlag("O_NONBLOCK") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
178
180
|
try {
|
|
179
181
|
let info = fstatSync(fd);
|
|
180
182
|
verifyOpenedRegularFileSync(path, info, {
|
|
@@ -227,10 +229,39 @@ function samePrivateFile(path, expected) {
|
|
|
227
229
|
const info = lstatSync(path);
|
|
228
230
|
return (info.isFile()
|
|
229
231
|
&& !info.isSymbolicLink()
|
|
230
|
-
&&
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
&& privateStateFileIdentityMatches({
|
|
233
|
+
dev: info.dev,
|
|
234
|
+
ino: info.ino,
|
|
235
|
+
mode: info.mode & 0o777,
|
|
236
|
+
nlink: info.nlink,
|
|
237
|
+
}, expected));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Compare the fields Node can use as a stable file identity on the current platform. Windows only exposes
|
|
241
|
+
* owner read/write permission semantics, so its synthetic POSIX mode can differ between descriptor- and
|
|
242
|
+
* path-based stats without identifying a different file. File type, dev/ino and hard-link count remain
|
|
243
|
+
* mandatory there; POSIX additionally requires the exact owner-only mode.
|
|
244
|
+
*/
|
|
245
|
+
export function privateStateFileIdentityMatches(actual, expected, platform = process.platform) {
|
|
246
|
+
return (sameOpenedFileIdentity(actual, expected, platform)
|
|
247
|
+
&& actual.nlink === expected.nlink
|
|
248
|
+
&& (platform === "win32" || actual.mode === expected.mode));
|
|
249
|
+
}
|
|
250
|
+
function privateFileIdentitySummary(path) {
|
|
251
|
+
try {
|
|
252
|
+
const info = lstatSync(path);
|
|
253
|
+
return JSON.stringify({
|
|
254
|
+
file: info.isFile(),
|
|
255
|
+
symlink: info.isSymbolicLink(),
|
|
256
|
+
dev: info.dev,
|
|
257
|
+
ino: info.ino,
|
|
258
|
+
mode: info.mode & 0o777,
|
|
259
|
+
nlink: info.nlink,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return JSON.stringify({ error: error?.code ?? error?.message ?? String(error) });
|
|
264
|
+
}
|
|
234
265
|
}
|
|
235
266
|
function restorePrivateClaim(claimed, target, expected) {
|
|
236
267
|
if (!samePrivateFile(claimed, expected)) {
|
|
@@ -253,9 +284,10 @@ function restorePrivateClaim(claimed, target, expected) {
|
|
|
253
284
|
}
|
|
254
285
|
function syncPrivateDirectory(path) {
|
|
255
286
|
try {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
287
|
+
const fd = openSync(path, constants.O_RDONLY
|
|
288
|
+
| optionalPosixOpenFlag("O_NONBLOCK")
|
|
289
|
+
| optionalPosixOpenFlag("O_NOFOLLOW")
|
|
290
|
+
| optionalPosixOpenFlag("O_DIRECTORY"));
|
|
259
291
|
try {
|
|
260
292
|
if (fstatSync(fd).isDirectory())
|
|
261
293
|
fsyncSync(fd);
|
|
@@ -281,11 +313,12 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
281
313
|
const existing = readPrivateStateFileSnapshotSync(path);
|
|
282
314
|
verifyPrivateDirectory(directory);
|
|
283
315
|
const temp = join(directory.path, `.hara-private-${process.pid}-${randomUUID()}.tmp`);
|
|
284
|
-
const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
|
|
285
316
|
let fd;
|
|
286
317
|
let staged;
|
|
287
318
|
try {
|
|
288
|
-
|
|
319
|
+
// `wx` maps to O_WRONLY|O_CREAT|O_EXCL/CREATE_NEW and refuses an already-present symlink or file.
|
|
320
|
+
// Bun's Windows numeric-open compatibility can otherwise turn this valid create into a false ENOENT.
|
|
321
|
+
fd = openSync(temp, "wx", 0o600);
|
|
289
322
|
writeFileSync(fd, text, "utf8");
|
|
290
323
|
try {
|
|
291
324
|
fchmodSync(fd, 0o600);
|
|
@@ -332,8 +365,7 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
332
365
|
try {
|
|
333
366
|
const claimedSnapshot = readPrivateStateFileSnapshotSync(claimed);
|
|
334
367
|
verifiedClaim = Boolean(claimedSnapshot
|
|
335
|
-
&& claimedSnapshot
|
|
336
|
-
&& claimedSnapshot.ino === existing.ino
|
|
368
|
+
&& sameOpenedFileIdentity(claimedSnapshot, existing)
|
|
337
369
|
&& claimedSnapshot.mode === existing.mode
|
|
338
370
|
&& claimedSnapshot.nlink === existing.nlink
|
|
339
371
|
&& claimedSnapshot.text === existing.text);
|
|
@@ -376,15 +408,17 @@ export function writePrivateStateFileSync(binding, text) {
|
|
|
376
408
|
}
|
|
377
409
|
const linkedStaged = { ...staged, nlink: staged.nlink + 1 };
|
|
378
410
|
if (!samePrivateFile(temp, linkedStaged) || !samePrivateFile(path, linkedStaged)) {
|
|
379
|
-
throw new Error(`private Hara state staging identity changed during commit: '${path}'`
|
|
411
|
+
throw new Error(`private Hara state staging identity changed during commit: '${path}'`
|
|
412
|
+
+ `; expected=${JSON.stringify(linkedStaged)}`
|
|
413
|
+
+ `; staging=${privateFileIdentitySummary(temp)}`
|
|
414
|
+
+ `; target=${privateFileIdentitySummary(path)}`);
|
|
380
415
|
}
|
|
381
416
|
unlinkSync(temp);
|
|
382
417
|
const committed = lstatSync(path);
|
|
383
418
|
if (!staged
|
|
384
419
|
|| !committed.isFile()
|
|
385
420
|
|| committed.isSymbolicLink()
|
|
386
|
-
|| committed
|
|
387
|
-
|| committed.ino !== staged.ino
|
|
421
|
+
|| !sameOpenedFileIdentity(committed, staged)
|
|
388
422
|
|| committed.nlink !== 1
|
|
389
423
|
|| (process.platform !== "win32" && (committed.mode & 0o777) !== 0o600))
|
|
390
424
|
throw new Error(`private Hara state file changed during commit: '${path}'`);
|
|
@@ -489,9 +523,8 @@ export function removePrivateStateFile(path, expected, directory) {
|
|
|
489
523
|
if (!current.isFile()
|
|
490
524
|
|| current.isSymbolicLink()
|
|
491
525
|
|| current.nlink !== 1
|
|
492
|
-
|| current
|
|
493
|
-
|| current.
|
|
494
|
-
|| (current.mode & 0o777) !== expected.mode
|
|
526
|
+
|| !sameOpenedFileIdentity(current, expected)
|
|
527
|
+
|| (process.platform !== "win32" && (current.mode & 0o777) !== expected.mode)
|
|
495
528
|
|| current.size !== expected.size
|
|
496
529
|
|| current.mtimeMs !== expected.mtimeMs
|
|
497
530
|
|| current.ctimeMs !== expected.ctimeMs)
|
package/dist/serve/server.js
CHANGED
|
@@ -32,6 +32,8 @@ import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
|
|
|
32
32
|
import { SessionHub, realStore } from "./sessions.js";
|
|
33
33
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
34
34
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
35
|
+
import { optionalPosixOpenFlag } from "../fs-open-flags.js";
|
|
36
|
+
import { sameOpenedFileIdentity } from "../fs-identity.js";
|
|
35
37
|
import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
|
|
36
38
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
37
39
|
const COMPACT_TIMEOUT_MS = 60_000;
|
|
@@ -54,7 +56,7 @@ const ensurePrivateDiscoveryDir = (dir) => {
|
|
|
54
56
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
55
57
|
let fd;
|
|
56
58
|
try {
|
|
57
|
-
fd = openSync(dir, fsConstants.O_RDONLY |
|
|
59
|
+
fd = openSync(dir, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_DIRECTORY") | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
58
60
|
const st = fstatSync(fd);
|
|
59
61
|
if (!st.isDirectory())
|
|
60
62
|
throw new Error(`${dir} must be a private directory, not a symlink`);
|
|
@@ -151,7 +153,7 @@ const writeDiscovery = async (dir, path, record) => {
|
|
|
151
153
|
const temp = join(dir, `.serve.json.${process.pid}.${record.instanceId}.tmp`);
|
|
152
154
|
let fd;
|
|
153
155
|
try {
|
|
154
|
-
fd = openSync(temp,
|
|
156
|
+
fd = openSync(temp, "wx", 0o600);
|
|
155
157
|
fchmodSync(fd, 0o600);
|
|
156
158
|
writeFileSync(fd, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
157
159
|
fsyncSync(fd);
|
|
@@ -177,7 +179,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
|
|
|
177
179
|
await withDiscoveryLock(dir, record.instanceId, () => {
|
|
178
180
|
let fd;
|
|
179
181
|
try {
|
|
180
|
-
fd = openSync(path, fsConstants.O_RDONLY |
|
|
182
|
+
fd = openSync(path, fsConstants.O_RDONLY | optionalPosixOpenFlag("O_NOFOLLOW"));
|
|
181
183
|
const opened = fstatSync(fd);
|
|
182
184
|
if (!opened.isFile() || opened.size > 64 * 1024)
|
|
183
185
|
return;
|
|
@@ -191,7 +193,7 @@ const removeOwnedDiscovery = async (dir, path, record) => {
|
|
|
191
193
|
// Re-check the directory entry against the already-open, verified inode. Cooperating writers are
|
|
192
194
|
// serialized by the lock; this check also refuses an uncooperative symlink/replacement race.
|
|
193
195
|
const linked = lstatSync(path);
|
|
194
|
-
if (!linked.isFile() || linked.isSymbolicLink() || linked
|
|
196
|
+
if (!linked.isFile() || linked.isSymbolicLink() || !sameOpenedFileIdentity(linked, opened))
|
|
195
197
|
return;
|
|
196
198
|
unlinkSync(path);
|
|
197
199
|
syncDirectory(dir);
|
package/dist/tools/search.js
CHANGED
|
@@ -290,7 +290,8 @@ function execute() {
|
|
|
290
290
|
|
|
291
291
|
const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
|
|
292
292
|
function sameIdentity(info, candidate) {
|
|
293
|
-
return String(info.
|
|
293
|
+
return String(info.ino) === candidate.ino
|
|
294
|
+
&& (process.platform === "win32" || String(info.dev) === candidate.dev);
|
|
294
295
|
}
|
|
295
296
|
function safeReadText(candidate) {
|
|
296
297
|
const absolute = candidate.path;
|
|
@@ -298,7 +299,10 @@ function execute() {
|
|
|
298
299
|
try {
|
|
299
300
|
const before = fs.lstatSync(absolute, { bigint: true });
|
|
300
301
|
if (!before.isFile() || before.nlink !== 1n || !sameIdentity(before, candidate)) return null;
|
|
301
|
-
|
|
302
|
+
const posixFlags = process.platform === "win32"
|
|
303
|
+
? 0
|
|
304
|
+
: (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0);
|
|
305
|
+
fd = fs.openSync(absolute, fs.constants.O_RDONLY | posixFlags);
|
|
302
306
|
const info = fs.fstatSync(fd, { bigint: true });
|
|
303
307
|
if (!info.isFile() || info.nlink !== 1n || !sameIdentity(info, candidate) || info.size > BigInt(cfg.maxFileBytes)) return null;
|
|
304
308
|
let total = 0;
|