@nathapp/nax 0.42.6 → 0.42.7
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/dist/nax.js +53 -40
- package/package.json +1 -1
- package/src/agents/acp/spawn-client.ts +3 -1
- package/src/execution/crash-signals.ts +3 -2
- package/src/execution/lock.ts +12 -14
- package/src/utils/path-security.ts +21 -4
package/dist/nax.js
CHANGED
|
@@ -19075,6 +19075,7 @@ class SpawnAcpClient {
|
|
|
19075
19075
|
model;
|
|
19076
19076
|
cwd;
|
|
19077
19077
|
timeoutSeconds;
|
|
19078
|
+
permissionMode;
|
|
19078
19079
|
env;
|
|
19079
19080
|
pidRegistry;
|
|
19080
19081
|
constructor(cmdStr, cwd, timeoutSeconds, pidRegistry) {
|
|
@@ -19088,6 +19089,7 @@ class SpawnAcpClient {
|
|
|
19088
19089
|
this.agentName = lastToken;
|
|
19089
19090
|
this.cwd = cwd || process.cwd();
|
|
19090
19091
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
19092
|
+
this.permissionMode = "approve-reads";
|
|
19091
19093
|
this.env = buildAllowedEnv2();
|
|
19092
19094
|
this.pidRegistry = pidRegistry;
|
|
19093
19095
|
}
|
|
@@ -19126,7 +19128,7 @@ class SpawnAcpClient {
|
|
|
19126
19128
|
cwd: this.cwd,
|
|
19127
19129
|
model: this.model,
|
|
19128
19130
|
timeoutSeconds: this.timeoutSeconds,
|
|
19129
|
-
permissionMode:
|
|
19131
|
+
permissionMode: this.permissionMode,
|
|
19130
19132
|
env: this.env,
|
|
19131
19133
|
pidRegistry: this.pidRegistry
|
|
19132
19134
|
});
|
|
@@ -20200,14 +20202,27 @@ var init_chain = __esm(() => {
|
|
|
20200
20202
|
});
|
|
20201
20203
|
|
|
20202
20204
|
// src/utils/path-security.ts
|
|
20203
|
-
import {
|
|
20205
|
+
import { realpathSync } from "fs";
|
|
20206
|
+
import { dirname, isAbsolute, join as join5, normalize, resolve } from "path";
|
|
20207
|
+
function safeRealpath(p) {
|
|
20208
|
+
try {
|
|
20209
|
+
return realpathSync(p);
|
|
20210
|
+
} catch {
|
|
20211
|
+
try {
|
|
20212
|
+
const parent = realpathSync(dirname(p));
|
|
20213
|
+
return join5(parent, p.split("/").pop() ?? "");
|
|
20214
|
+
} catch {
|
|
20215
|
+
return p;
|
|
20216
|
+
}
|
|
20217
|
+
}
|
|
20218
|
+
}
|
|
20204
20219
|
function validateModulePath(modulePath, allowedRoots) {
|
|
20205
20220
|
if (!modulePath) {
|
|
20206
20221
|
return { valid: false, error: "Module path is empty" };
|
|
20207
20222
|
}
|
|
20208
|
-
const normalizedRoots = allowedRoots.map((r) => resolve(r));
|
|
20223
|
+
const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
|
|
20209
20224
|
if (isAbsolute(modulePath)) {
|
|
20210
|
-
const absoluteTarget = normalize(modulePath);
|
|
20225
|
+
const absoluteTarget = safeRealpath(normalize(modulePath));
|
|
20211
20226
|
const isWithin = normalizedRoots.some((root) => {
|
|
20212
20227
|
return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
|
|
20213
20228
|
});
|
|
@@ -20216,7 +20231,7 @@ function validateModulePath(modulePath, allowedRoots) {
|
|
|
20216
20231
|
}
|
|
20217
20232
|
} else {
|
|
20218
20233
|
for (const root of normalizedRoots) {
|
|
20219
|
-
const absoluteTarget = resolve(join5(root, modulePath));
|
|
20234
|
+
const absoluteTarget = safeRealpath(resolve(join5(root, modulePath)));
|
|
20220
20235
|
if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
|
|
20221
20236
|
return { valid: true, absolutePath: absoluteTarget };
|
|
20222
20237
|
}
|
|
@@ -20496,7 +20511,7 @@ function isPlainObject2(value) {
|
|
|
20496
20511
|
}
|
|
20497
20512
|
|
|
20498
20513
|
// src/config/path-security.ts
|
|
20499
|
-
import { existsSync as existsSync4, lstatSync, realpathSync } from "fs";
|
|
20514
|
+
import { existsSync as existsSync4, lstatSync, realpathSync as realpathSync2 } from "fs";
|
|
20500
20515
|
import { isAbsolute as isAbsolute2, normalize as normalize2, resolve as resolve3 } from "path";
|
|
20501
20516
|
function validateDirectory(dirPath, baseDir) {
|
|
20502
20517
|
const resolved = resolve3(dirPath);
|
|
@@ -20505,7 +20520,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20505
20520
|
}
|
|
20506
20521
|
let realPath;
|
|
20507
20522
|
try {
|
|
20508
|
-
realPath =
|
|
20523
|
+
realPath = realpathSync2(resolved);
|
|
20509
20524
|
} catch (error48) {
|
|
20510
20525
|
throw new Error(`Failed to resolve path: ${dirPath} (${error48.message})`);
|
|
20511
20526
|
}
|
|
@@ -20519,7 +20534,7 @@ function validateDirectory(dirPath, baseDir) {
|
|
|
20519
20534
|
}
|
|
20520
20535
|
if (baseDir) {
|
|
20521
20536
|
const resolvedBase = resolve3(baseDir);
|
|
20522
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20537
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20523
20538
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20524
20539
|
throw new Error(`Path is outside allowed directory: ${dirPath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20525
20540
|
}
|
|
@@ -20543,19 +20558,19 @@ function validateFilePath(filePath, baseDir) {
|
|
|
20543
20558
|
if (!existsSync4(resolved)) {
|
|
20544
20559
|
const parent = resolve3(resolved, "..");
|
|
20545
20560
|
if (existsSync4(parent)) {
|
|
20546
|
-
const realParent =
|
|
20561
|
+
const realParent = realpathSync2(parent);
|
|
20547
20562
|
realPath = resolve3(realParent, filePath.split("/").pop() || "");
|
|
20548
20563
|
} else {
|
|
20549
20564
|
realPath = resolved;
|
|
20550
20565
|
}
|
|
20551
20566
|
} else {
|
|
20552
|
-
realPath =
|
|
20567
|
+
realPath = realpathSync2(resolved);
|
|
20553
20568
|
}
|
|
20554
20569
|
} catch (error48) {
|
|
20555
20570
|
throw new Error(`Failed to resolve path: ${filePath} (${error48.message})`);
|
|
20556
20571
|
}
|
|
20557
20572
|
const resolvedBase = resolve3(baseDir);
|
|
20558
|
-
const realBase = existsSync4(resolvedBase) ?
|
|
20573
|
+
const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
|
|
20559
20574
|
if (!isWithinDirectory(realPath, realBase)) {
|
|
20560
20575
|
throw new Error(`Path is outside allowed directory: ${filePath} (resolved to ${realPath}, base: ${realBase})`);
|
|
20561
20576
|
}
|
|
@@ -21899,7 +21914,7 @@ var package_default;
|
|
|
21899
21914
|
var init_package = __esm(() => {
|
|
21900
21915
|
package_default = {
|
|
21901
21916
|
name: "@nathapp/nax",
|
|
21902
|
-
version: "0.42.
|
|
21917
|
+
version: "0.42.7",
|
|
21903
21918
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
21904
21919
|
type: "module",
|
|
21905
21920
|
bin: {
|
|
@@ -21972,8 +21987,8 @@ var init_version = __esm(() => {
|
|
|
21972
21987
|
NAX_VERSION = package_default.version;
|
|
21973
21988
|
NAX_COMMIT = (() => {
|
|
21974
21989
|
try {
|
|
21975
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
21976
|
-
return "
|
|
21990
|
+
if (/^[0-9a-f]{6,10}$/.test("e8a2a25"))
|
|
21991
|
+
return "e8a2a25";
|
|
21977
21992
|
} catch {}
|
|
21978
21993
|
try {
|
|
21979
21994
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -24452,7 +24467,7 @@ var init_constitution = __esm(() => {
|
|
|
24452
24467
|
});
|
|
24453
24468
|
|
|
24454
24469
|
// src/pipeline/stages/constitution.ts
|
|
24455
|
-
import { dirname } from "path";
|
|
24470
|
+
import { dirname as dirname2 } from "path";
|
|
24456
24471
|
var constitutionStage;
|
|
24457
24472
|
var init_constitution2 = __esm(() => {
|
|
24458
24473
|
init_constitution();
|
|
@@ -24462,7 +24477,7 @@ var init_constitution2 = __esm(() => {
|
|
|
24462
24477
|
enabled: (ctx) => ctx.config.constitution.enabled,
|
|
24463
24478
|
async execute(ctx) {
|
|
24464
24479
|
const logger = getLogger();
|
|
24465
|
-
const ngentDir = ctx.featureDir ?
|
|
24480
|
+
const ngentDir = ctx.featureDir ? dirname2(dirname2(ctx.featureDir)) : `${ctx.workdir}/nax`;
|
|
24466
24481
|
const result = await loadConstitution(ngentDir, ctx.config.constitution);
|
|
24467
24482
|
if (result) {
|
|
24468
24483
|
ctx.constitution = result;
|
|
@@ -25259,6 +25274,7 @@ var init_story_context = __esm(() => {
|
|
|
25259
25274
|
});
|
|
25260
25275
|
|
|
25261
25276
|
// src/execution/lock.ts
|
|
25277
|
+
import { unlink } from "fs/promises";
|
|
25262
25278
|
import path8 from "path";
|
|
25263
25279
|
function getSafeLogger3() {
|
|
25264
25280
|
try {
|
|
@@ -25292,7 +25308,7 @@ async function acquireLock(workdir) {
|
|
|
25292
25308
|
});
|
|
25293
25309
|
const fs2 = await import("fs/promises");
|
|
25294
25310
|
await fs2.unlink(lockPath).catch(() => {});
|
|
25295
|
-
lockData2 =
|
|
25311
|
+
lockData2 = null;
|
|
25296
25312
|
}
|
|
25297
25313
|
if (lockData2) {
|
|
25298
25314
|
const lockPid = lockData2.pid;
|
|
@@ -25330,18 +25346,14 @@ async function acquireLock(workdir) {
|
|
|
25330
25346
|
async function releaseLock(workdir) {
|
|
25331
25347
|
const lockPath = path8.join(workdir, "nax.lock");
|
|
25332
25348
|
try {
|
|
25333
|
-
|
|
25334
|
-
const exists = await file2.exists();
|
|
25335
|
-
if (exists) {
|
|
25336
|
-
const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
|
|
25337
|
-
await proc.exited;
|
|
25338
|
-
await Bun.sleep(10);
|
|
25339
|
-
}
|
|
25349
|
+
await unlink(lockPath);
|
|
25340
25350
|
} catch (error48) {
|
|
25341
|
-
|
|
25342
|
-
|
|
25343
|
-
|
|
25344
|
-
|
|
25351
|
+
if (error48.code !== "ENOENT") {
|
|
25352
|
+
const logger = getSafeLogger3();
|
|
25353
|
+
logger?.warn("execution", "Failed to release lock", {
|
|
25354
|
+
error: error48.message
|
|
25355
|
+
});
|
|
25356
|
+
}
|
|
25345
25357
|
}
|
|
25346
25358
|
}
|
|
25347
25359
|
var init_lock = __esm(() => {
|
|
@@ -25684,17 +25696,17 @@ async function autoCommitIfDirty(workdir, stage, role, storyId) {
|
|
|
25684
25696
|
});
|
|
25685
25697
|
const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
|
|
25686
25698
|
await topLevelProc.exited;
|
|
25687
|
-
const { realpathSync:
|
|
25699
|
+
const { realpathSync: realpathSync4 } = await import("fs");
|
|
25688
25700
|
const realWorkdir = (() => {
|
|
25689
25701
|
try {
|
|
25690
|
-
return
|
|
25702
|
+
return realpathSync4(workdir);
|
|
25691
25703
|
} catch {
|
|
25692
25704
|
return workdir;
|
|
25693
25705
|
}
|
|
25694
25706
|
})();
|
|
25695
25707
|
const realGitRoot = (() => {
|
|
25696
25708
|
try {
|
|
25697
|
-
return
|
|
25709
|
+
return realpathSync4(gitRoot);
|
|
25698
25710
|
} catch {
|
|
25699
25711
|
return gitRoot;
|
|
25700
25712
|
}
|
|
@@ -26986,7 +26998,7 @@ var init_session_runner = __esm(() => {
|
|
|
26986
26998
|
});
|
|
26987
26999
|
|
|
26988
27000
|
// src/tdd/verdict-reader.ts
|
|
26989
|
-
import { unlink } from "fs/promises";
|
|
27001
|
+
import { unlink as unlink2 } from "fs/promises";
|
|
26990
27002
|
import path9 from "path";
|
|
26991
27003
|
function isValidVerdict(obj) {
|
|
26992
27004
|
if (!obj || typeof obj !== "object")
|
|
@@ -27186,7 +27198,7 @@ async function readVerdict(workdir) {
|
|
|
27186
27198
|
async function cleanupVerdict(workdir) {
|
|
27187
27199
|
const verdictPath = path9.join(workdir, VERDICT_FILE);
|
|
27188
27200
|
try {
|
|
27189
|
-
await
|
|
27201
|
+
await unlink2(verdictPath);
|
|
27190
27202
|
} catch {}
|
|
27191
27203
|
}
|
|
27192
27204
|
var VERDICT_FILE = ".nax-verifier-verdict.json";
|
|
@@ -30912,14 +30924,15 @@ function installSignalHandlers(ctx) {
|
|
|
30912
30924
|
process.on("SIGINT", sigintHandler);
|
|
30913
30925
|
process.on("SIGHUP", sighupHandler);
|
|
30914
30926
|
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
30915
|
-
|
|
30927
|
+
const rejectionWrapper = (reason) => unhandledRejectionHandler(reason);
|
|
30928
|
+
process.on("unhandledRejection", rejectionWrapper);
|
|
30916
30929
|
logger?.debug("crash-recovery", "Signal handlers installed");
|
|
30917
30930
|
return () => {
|
|
30918
30931
|
process.removeListener("SIGTERM", sigtermHandler);
|
|
30919
30932
|
process.removeListener("SIGINT", sigintHandler);
|
|
30920
30933
|
process.removeListener("SIGHUP", sighupHandler);
|
|
30921
30934
|
process.removeListener("uncaughtException", uncaughtExceptionHandler);
|
|
30922
|
-
process.removeListener("unhandledRejection",
|
|
30935
|
+
process.removeListener("unhandledRejection", rejectionWrapper);
|
|
30923
30936
|
logger?.debug("crash-recovery", "Signal handlers unregistered");
|
|
30924
30937
|
};
|
|
30925
30938
|
}
|
|
@@ -33695,7 +33708,7 @@ var init_sequential_executor = __esm(() => {
|
|
|
33695
33708
|
});
|
|
33696
33709
|
|
|
33697
33710
|
// src/execution/status-file.ts
|
|
33698
|
-
import { rename, unlink as
|
|
33711
|
+
import { rename, unlink as unlink3 } from "fs/promises";
|
|
33699
33712
|
import { resolve as resolve8 } from "path";
|
|
33700
33713
|
function countProgress(prd) {
|
|
33701
33714
|
const stories = prd.userStories;
|
|
@@ -33744,7 +33757,7 @@ async function writeStatusFile(filePath, status) {
|
|
|
33744
33757
|
}
|
|
33745
33758
|
const tmpPath = `${resolvedPath}.tmp`;
|
|
33746
33759
|
try {
|
|
33747
|
-
await
|
|
33760
|
+
await unlink3(tmpPath);
|
|
33748
33761
|
} catch {}
|
|
33749
33762
|
await Bun.write(tmpPath, JSON.stringify(status, null, 2));
|
|
33750
33763
|
await rename(tmpPath, resolvedPath);
|
|
@@ -66140,7 +66153,7 @@ import { join as join13 } from "path";
|
|
|
66140
66153
|
// src/commands/common.ts
|
|
66141
66154
|
init_path_security2();
|
|
66142
66155
|
init_errors3();
|
|
66143
|
-
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as
|
|
66156
|
+
import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync3 } from "fs";
|
|
66144
66157
|
import { join as join11, resolve as resolve6 } from "path";
|
|
66145
66158
|
function resolveProject(options = {}) {
|
|
66146
66159
|
const { dir, feature } = options;
|
|
@@ -66148,7 +66161,7 @@ function resolveProject(options = {}) {
|
|
|
66148
66161
|
let naxDir;
|
|
66149
66162
|
let configPath;
|
|
66150
66163
|
if (dir) {
|
|
66151
|
-
projectRoot =
|
|
66164
|
+
projectRoot = realpathSync3(resolve6(dir));
|
|
66152
66165
|
naxDir = join11(projectRoot, "nax");
|
|
66153
66166
|
if (!existsSync10(naxDir)) {
|
|
66154
66167
|
throw new NaxError(`Directory does not contain a nax project: ${projectRoot}
|
|
@@ -66207,7 +66220,7 @@ function findProjectRoot(startDir) {
|
|
|
66207
66220
|
const naxDir = join11(current, "nax");
|
|
66208
66221
|
const configPath = join11(naxDir, "config.json");
|
|
66209
66222
|
if (existsSync10(configPath)) {
|
|
66210
|
-
return
|
|
66223
|
+
return realpathSync3(current);
|
|
66211
66224
|
}
|
|
66212
66225
|
const parent = join11(current, "..");
|
|
66213
66226
|
if (parent === current) {
|
package/package.json
CHANGED
|
@@ -254,6 +254,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
254
254
|
private readonly model: string;
|
|
255
255
|
private readonly cwd: string;
|
|
256
256
|
private readonly timeoutSeconds: number;
|
|
257
|
+
private readonly permissionMode: string;
|
|
257
258
|
private readonly env: Record<string, string | undefined>;
|
|
258
259
|
private readonly pidRegistry?: PidRegistry;
|
|
259
260
|
|
|
@@ -270,6 +271,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
270
271
|
this.agentName = lastToken;
|
|
271
272
|
this.cwd = cwd || process.cwd();
|
|
272
273
|
this.timeoutSeconds = timeoutSeconds || 1800;
|
|
274
|
+
this.permissionMode = "approve-reads";
|
|
273
275
|
this.env = buildAllowedEnv();
|
|
274
276
|
this.pidRegistry = pidRegistry;
|
|
275
277
|
}
|
|
@@ -326,7 +328,7 @@ export class SpawnAcpClient implements AcpClient {
|
|
|
326
328
|
cwd: this.cwd,
|
|
327
329
|
model: this.model,
|
|
328
330
|
timeoutSeconds: this.timeoutSeconds,
|
|
329
|
-
permissionMode:
|
|
331
|
+
permissionMode: this.permissionMode,
|
|
330
332
|
env: this.env,
|
|
331
333
|
pidRegistry: this.pidRegistry,
|
|
332
334
|
});
|
|
@@ -134,7 +134,8 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
|
|
|
134
134
|
process.on("SIGINT", sigintHandler);
|
|
135
135
|
process.on("SIGHUP", sighupHandler);
|
|
136
136
|
process.on("uncaughtException", uncaughtExceptionHandler);
|
|
137
|
-
|
|
137
|
+
const rejectionWrapper = (reason: unknown) => unhandledRejectionHandler(reason);
|
|
138
|
+
process.on("unhandledRejection", rejectionWrapper);
|
|
138
139
|
|
|
139
140
|
logger?.debug("crash-recovery", "Signal handlers installed");
|
|
140
141
|
|
|
@@ -143,7 +144,7 @@ export function installSignalHandlers(ctx: SignalHandlerContext): () => void {
|
|
|
143
144
|
process.removeListener("SIGINT", sigintHandler);
|
|
144
145
|
process.removeListener("SIGHUP", sighupHandler);
|
|
145
146
|
process.removeListener("uncaughtException", uncaughtExceptionHandler);
|
|
146
|
-
process.removeListener("unhandledRejection",
|
|
147
|
+
process.removeListener("unhandledRejection", rejectionWrapper);
|
|
147
148
|
logger?.debug("crash-recovery", "Signal handlers unregistered");
|
|
148
149
|
};
|
|
149
150
|
}
|
package/src/execution/lock.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Prevents concurrent runs in the same directory.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { getLogger } from "../logger";
|
|
10
11
|
|
|
@@ -49,7 +50,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
49
50
|
if (exists) {
|
|
50
51
|
// Read lock data
|
|
51
52
|
const lockContent = await lockFile.text();
|
|
52
|
-
let lockData: { pid: number };
|
|
53
|
+
let lockData: { pid: number } | null;
|
|
53
54
|
try {
|
|
54
55
|
lockData = JSON.parse(lockContent);
|
|
55
56
|
} catch {
|
|
@@ -61,7 +62,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
61
62
|
const fs = await import("node:fs/promises");
|
|
62
63
|
await fs.unlink(lockPath).catch(() => {});
|
|
63
64
|
// Fall through to create a new lock
|
|
64
|
-
lockData =
|
|
65
|
+
lockData = null;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
if (lockData) {
|
|
@@ -88,6 +89,7 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
88
89
|
pid: process.pid,
|
|
89
90
|
timestamp: Date.now(),
|
|
90
91
|
};
|
|
92
|
+
// NOTE: Node.js fs used intentionally — Bun.file()/Bun.write() lacks O_CREAT|O_EXCL atomic exclusive create
|
|
91
93
|
const fs = await import("node:fs");
|
|
92
94
|
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o644);
|
|
93
95
|
fs.writeSync(fd, JSON.stringify(lockData));
|
|
@@ -114,18 +116,14 @@ export async function acquireLock(workdir: string): Promise<boolean> {
|
|
|
114
116
|
export async function releaseLock(workdir: string): Promise<void> {
|
|
115
117
|
const lockPath = path.join(workdir, "nax.lock");
|
|
116
118
|
try {
|
|
117
|
-
|
|
118
|
-
const exists = await file.exists();
|
|
119
|
-
if (exists) {
|
|
120
|
-
const proc = Bun.spawn(["rm", lockPath], { stdout: "pipe" });
|
|
121
|
-
await proc.exited;
|
|
122
|
-
// Wait a bit for filesystem to sync (prevents race in tests)
|
|
123
|
-
await Bun.sleep(10);
|
|
124
|
-
}
|
|
119
|
+
await unlink(lockPath);
|
|
125
120
|
} catch (error) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
// Ignore ENOENT (already gone), log others
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
123
|
+
const logger = getSafeLogger();
|
|
124
|
+
logger?.warn("execution", "Failed to release lock", {
|
|
125
|
+
error: (error as Error).message,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
130
128
|
}
|
|
131
129
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Path security utilities for nax (SEC-1, SEC-2).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Result of a path validation.
|
|
@@ -23,16 +24,32 @@ export interface PathValidationResult {
|
|
|
23
24
|
* @param allowedRoots - Array of absolute paths that are allowed as roots
|
|
24
25
|
* @returns Validation result
|
|
25
26
|
*/
|
|
27
|
+
/** Resolve symlinks for a path that may not exist yet (fall back to parent dir). */
|
|
28
|
+
function safeRealpath(p: string): string {
|
|
29
|
+
try {
|
|
30
|
+
return realpathSync(p);
|
|
31
|
+
} catch {
|
|
32
|
+
// Path doesn't exist — resolve the parent directory instead
|
|
33
|
+
try {
|
|
34
|
+
const parent = realpathSync(dirname(p));
|
|
35
|
+
return join(parent, p.split("/").pop() ?? "");
|
|
36
|
+
} catch {
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export function validateModulePath(modulePath: string, allowedRoots: string[]): PathValidationResult {
|
|
27
43
|
if (!modulePath) {
|
|
28
44
|
return { valid: false, error: "Module path is empty" };
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
// Resolve symlinks in each root
|
|
48
|
+
const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
|
|
32
49
|
|
|
33
50
|
// If absolute, just check against roots
|
|
34
51
|
if (isAbsolute(modulePath)) {
|
|
35
|
-
const absoluteTarget = normalize(modulePath);
|
|
52
|
+
const absoluteTarget = safeRealpath(normalize(modulePath));
|
|
36
53
|
const isWithin = normalizedRoots.some((root) => {
|
|
37
54
|
return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
|
|
38
55
|
});
|
|
@@ -42,7 +59,7 @@ export function validateModulePath(modulePath: string, allowedRoots: string[]):
|
|
|
42
59
|
} else {
|
|
43
60
|
// If relative, check if it's within any root when resolved relative to that root
|
|
44
61
|
for (const root of normalizedRoots) {
|
|
45
|
-
const absoluteTarget = resolve(join(root, modulePath));
|
|
62
|
+
const absoluteTarget = safeRealpath(resolve(join(root, modulePath)));
|
|
46
63
|
if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
|
|
47
64
|
return { valid: true, absolutePath: absoluteTarget };
|
|
48
65
|
}
|