@nathapp/nax 0.42.6 → 0.42.8

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 CHANGED
@@ -18992,6 +18992,11 @@ class SpawnAcpSession {
18992
18992
  "--file",
18993
18993
  "-"
18994
18994
  ];
18995
+ getSafeLogger()?.info("acp-adapter", "Sending prompt", {
18996
+ session: this.sessionName,
18997
+ permission: this.permissionMode,
18998
+ cmd: cmd.join(" ")
18999
+ });
18995
19000
  getSafeLogger()?.debug("acp-adapter", `Sending prompt to session: ${this.sessionName}`);
18996
19001
  const proc = _spawnClientDeps.spawn(cmd, {
18997
19002
  cwd: this.cwd,
@@ -19075,6 +19080,7 @@ class SpawnAcpClient {
19075
19080
  model;
19076
19081
  cwd;
19077
19082
  timeoutSeconds;
19083
+ permissionMode;
19078
19084
  env;
19079
19085
  pidRegistry;
19080
19086
  constructor(cmdStr, cwd, timeoutSeconds, pidRegistry) {
@@ -19088,6 +19094,7 @@ class SpawnAcpClient {
19088
19094
  this.agentName = lastToken;
19089
19095
  this.cwd = cwd || process.cwd();
19090
19096
  this.timeoutSeconds = timeoutSeconds || 1800;
19097
+ this.permissionMode = "approve-reads";
19091
19098
  this.env = buildAllowedEnv2();
19092
19099
  this.pidRegistry = pidRegistry;
19093
19100
  }
@@ -19126,7 +19133,7 @@ class SpawnAcpClient {
19126
19133
  cwd: this.cwd,
19127
19134
  model: this.model,
19128
19135
  timeoutSeconds: this.timeoutSeconds,
19129
- permissionMode: "approve-all",
19136
+ permissionMode: this.permissionMode,
19130
19137
  env: this.env,
19131
19138
  pidRegistry: this.pidRegistry
19132
19139
  });
@@ -19403,6 +19410,11 @@ class AcpAgentAdapter {
19403
19410
  }
19404
19411
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
19405
19412
  const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
19413
+ getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
19414
+ permission: permissionMode,
19415
+ dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19416
+ stage: options.featureName ? "run" : "plan"
19417
+ });
19406
19418
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
19407
19419
  if (options.featureName && options.storyId) {
19408
19420
  await saveAcpSession(options.workdir, options.featureName, options.storyId, sessionName);
@@ -20200,14 +20212,27 @@ var init_chain = __esm(() => {
20200
20212
  });
20201
20213
 
20202
20214
  // src/utils/path-security.ts
20203
- import { isAbsolute, join as join5, normalize, resolve } from "path";
20215
+ import { realpathSync } from "fs";
20216
+ import { dirname, isAbsolute, join as join5, normalize, resolve } from "path";
20217
+ function safeRealpath(p) {
20218
+ try {
20219
+ return realpathSync(p);
20220
+ } catch {
20221
+ try {
20222
+ const parent = realpathSync(dirname(p));
20223
+ return join5(parent, p.split("/").pop() ?? "");
20224
+ } catch {
20225
+ return p;
20226
+ }
20227
+ }
20228
+ }
20204
20229
  function validateModulePath(modulePath, allowedRoots) {
20205
20230
  if (!modulePath) {
20206
20231
  return { valid: false, error: "Module path is empty" };
20207
20232
  }
20208
- const normalizedRoots = allowedRoots.map((r) => resolve(r));
20233
+ const normalizedRoots = allowedRoots.map((r) => safeRealpath(resolve(r)));
20209
20234
  if (isAbsolute(modulePath)) {
20210
- const absoluteTarget = normalize(modulePath);
20235
+ const absoluteTarget = safeRealpath(normalize(modulePath));
20211
20236
  const isWithin = normalizedRoots.some((root) => {
20212
20237
  return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
20213
20238
  });
@@ -20216,7 +20241,7 @@ function validateModulePath(modulePath, allowedRoots) {
20216
20241
  }
20217
20242
  } else {
20218
20243
  for (const root of normalizedRoots) {
20219
- const absoluteTarget = resolve(join5(root, modulePath));
20244
+ const absoluteTarget = safeRealpath(resolve(join5(root, modulePath)));
20220
20245
  if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
20221
20246
  return { valid: true, absolutePath: absoluteTarget };
20222
20247
  }
@@ -20496,7 +20521,7 @@ function isPlainObject2(value) {
20496
20521
  }
20497
20522
 
20498
20523
  // src/config/path-security.ts
20499
- import { existsSync as existsSync4, lstatSync, realpathSync } from "fs";
20524
+ import { existsSync as existsSync4, lstatSync, realpathSync as realpathSync2 } from "fs";
20500
20525
  import { isAbsolute as isAbsolute2, normalize as normalize2, resolve as resolve3 } from "path";
20501
20526
  function validateDirectory(dirPath, baseDir) {
20502
20527
  const resolved = resolve3(dirPath);
@@ -20505,7 +20530,7 @@ function validateDirectory(dirPath, baseDir) {
20505
20530
  }
20506
20531
  let realPath;
20507
20532
  try {
20508
- realPath = realpathSync(resolved);
20533
+ realPath = realpathSync2(resolved);
20509
20534
  } catch (error48) {
20510
20535
  throw new Error(`Failed to resolve path: ${dirPath} (${error48.message})`);
20511
20536
  }
@@ -20519,7 +20544,7 @@ function validateDirectory(dirPath, baseDir) {
20519
20544
  }
20520
20545
  if (baseDir) {
20521
20546
  const resolvedBase = resolve3(baseDir);
20522
- const realBase = existsSync4(resolvedBase) ? realpathSync(resolvedBase) : resolvedBase;
20547
+ const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
20523
20548
  if (!isWithinDirectory(realPath, realBase)) {
20524
20549
  throw new Error(`Path is outside allowed directory: ${dirPath} (resolved to ${realPath}, base: ${realBase})`);
20525
20550
  }
@@ -20543,19 +20568,19 @@ function validateFilePath(filePath, baseDir) {
20543
20568
  if (!existsSync4(resolved)) {
20544
20569
  const parent = resolve3(resolved, "..");
20545
20570
  if (existsSync4(parent)) {
20546
- const realParent = realpathSync(parent);
20571
+ const realParent = realpathSync2(parent);
20547
20572
  realPath = resolve3(realParent, filePath.split("/").pop() || "");
20548
20573
  } else {
20549
20574
  realPath = resolved;
20550
20575
  }
20551
20576
  } else {
20552
- realPath = realpathSync(resolved);
20577
+ realPath = realpathSync2(resolved);
20553
20578
  }
20554
20579
  } catch (error48) {
20555
20580
  throw new Error(`Failed to resolve path: ${filePath} (${error48.message})`);
20556
20581
  }
20557
20582
  const resolvedBase = resolve3(baseDir);
20558
- const realBase = existsSync4(resolvedBase) ? realpathSync(resolvedBase) : resolvedBase;
20583
+ const realBase = existsSync4(resolvedBase) ? realpathSync2(resolvedBase) : resolvedBase;
20559
20584
  if (!isWithinDirectory(realPath, realBase)) {
20560
20585
  throw new Error(`Path is outside allowed directory: ${filePath} (resolved to ${realPath}, base: ${realBase})`);
20561
20586
  }
@@ -21899,7 +21924,7 @@ var package_default;
21899
21924
  var init_package = __esm(() => {
21900
21925
  package_default = {
21901
21926
  name: "@nathapp/nax",
21902
- version: "0.42.6",
21927
+ version: "0.42.8",
21903
21928
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21904
21929
  type: "module",
21905
21930
  bin: {
@@ -21972,8 +21997,8 @@ var init_version = __esm(() => {
21972
21997
  NAX_VERSION = package_default.version;
21973
21998
  NAX_COMMIT = (() => {
21974
21999
  try {
21975
- if (/^[0-9a-f]{6,10}$/.test("deb8333"))
21976
- return "deb8333";
22000
+ if (/^[0-9a-f]{6,10}$/.test("5dc5b37"))
22001
+ return "5dc5b37";
21977
22002
  } catch {}
21978
22003
  try {
21979
22004
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -24452,7 +24477,7 @@ var init_constitution = __esm(() => {
24452
24477
  });
24453
24478
 
24454
24479
  // src/pipeline/stages/constitution.ts
24455
- import { dirname } from "path";
24480
+ import { dirname as dirname2 } from "path";
24456
24481
  var constitutionStage;
24457
24482
  var init_constitution2 = __esm(() => {
24458
24483
  init_constitution();
@@ -24462,7 +24487,7 @@ var init_constitution2 = __esm(() => {
24462
24487
  enabled: (ctx) => ctx.config.constitution.enabled,
24463
24488
  async execute(ctx) {
24464
24489
  const logger = getLogger();
24465
- const ngentDir = ctx.featureDir ? dirname(dirname(ctx.featureDir)) : `${ctx.workdir}/nax`;
24490
+ const ngentDir = ctx.featureDir ? dirname2(dirname2(ctx.featureDir)) : `${ctx.workdir}/nax`;
24466
24491
  const result = await loadConstitution(ngentDir, ctx.config.constitution);
24467
24492
  if (result) {
24468
24493
  ctx.constitution = result;
@@ -25259,6 +25284,7 @@ var init_story_context = __esm(() => {
25259
25284
  });
25260
25285
 
25261
25286
  // src/execution/lock.ts
25287
+ import { unlink } from "fs/promises";
25262
25288
  import path8 from "path";
25263
25289
  function getSafeLogger3() {
25264
25290
  try {
@@ -25292,7 +25318,7 @@ async function acquireLock(workdir) {
25292
25318
  });
25293
25319
  const fs2 = await import("fs/promises");
25294
25320
  await fs2.unlink(lockPath).catch(() => {});
25295
- lockData2 = undefined;
25321
+ lockData2 = null;
25296
25322
  }
25297
25323
  if (lockData2) {
25298
25324
  const lockPid = lockData2.pid;
@@ -25330,18 +25356,14 @@ async function acquireLock(workdir) {
25330
25356
  async function releaseLock(workdir) {
25331
25357
  const lockPath = path8.join(workdir, "nax.lock");
25332
25358
  try {
25333
- const file2 = Bun.file(lockPath);
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
- }
25359
+ await unlink(lockPath);
25340
25360
  } catch (error48) {
25341
- const logger = getSafeLogger3();
25342
- logger?.warn("execution", "Failed to release lock", {
25343
- error: error48.message
25344
- });
25361
+ if (error48.code !== "ENOENT") {
25362
+ const logger = getSafeLogger3();
25363
+ logger?.warn("execution", "Failed to release lock", {
25364
+ error: error48.message
25365
+ });
25366
+ }
25345
25367
  }
25346
25368
  }
25347
25369
  var init_lock = __esm(() => {
@@ -25684,17 +25706,17 @@ async function autoCommitIfDirty(workdir, stage, role, storyId) {
25684
25706
  });
25685
25707
  const gitRoot = (await new Response(topLevelProc.stdout).text()).trim();
25686
25708
  await topLevelProc.exited;
25687
- const { realpathSync: realpathSync3 } = await import("fs");
25709
+ const { realpathSync: realpathSync4 } = await import("fs");
25688
25710
  const realWorkdir = (() => {
25689
25711
  try {
25690
- return realpathSync3(workdir);
25712
+ return realpathSync4(workdir);
25691
25713
  } catch {
25692
25714
  return workdir;
25693
25715
  }
25694
25716
  })();
25695
25717
  const realGitRoot = (() => {
25696
25718
  try {
25697
- return realpathSync3(gitRoot);
25719
+ return realpathSync4(gitRoot);
25698
25720
  } catch {
25699
25721
  return gitRoot;
25700
25722
  }
@@ -26986,7 +27008,7 @@ var init_session_runner = __esm(() => {
26986
27008
  });
26987
27009
 
26988
27010
  // src/tdd/verdict-reader.ts
26989
- import { unlink } from "fs/promises";
27011
+ import { unlink as unlink2 } from "fs/promises";
26990
27012
  import path9 from "path";
26991
27013
  function isValidVerdict(obj) {
26992
27014
  if (!obj || typeof obj !== "object")
@@ -27186,7 +27208,7 @@ async function readVerdict(workdir) {
27186
27208
  async function cleanupVerdict(workdir) {
27187
27209
  const verdictPath = path9.join(workdir, VERDICT_FILE);
27188
27210
  try {
27189
- await unlink(verdictPath);
27211
+ await unlink2(verdictPath);
27190
27212
  } catch {}
27191
27213
  }
27192
27214
  var VERDICT_FILE = ".nax-verifier-verdict.json";
@@ -30912,14 +30934,15 @@ function installSignalHandlers(ctx) {
30912
30934
  process.on("SIGINT", sigintHandler);
30913
30935
  process.on("SIGHUP", sighupHandler);
30914
30936
  process.on("uncaughtException", uncaughtExceptionHandler);
30915
- process.on("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
30937
+ const rejectionWrapper = (reason) => unhandledRejectionHandler(reason);
30938
+ process.on("unhandledRejection", rejectionWrapper);
30916
30939
  logger?.debug("crash-recovery", "Signal handlers installed");
30917
30940
  return () => {
30918
30941
  process.removeListener("SIGTERM", sigtermHandler);
30919
30942
  process.removeListener("SIGINT", sigintHandler);
30920
30943
  process.removeListener("SIGHUP", sighupHandler);
30921
30944
  process.removeListener("uncaughtException", uncaughtExceptionHandler);
30922
- process.removeListener("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
30945
+ process.removeListener("unhandledRejection", rejectionWrapper);
30923
30946
  logger?.debug("crash-recovery", "Signal handlers unregistered");
30924
30947
  };
30925
30948
  }
@@ -33695,7 +33718,7 @@ var init_sequential_executor = __esm(() => {
33695
33718
  });
33696
33719
 
33697
33720
  // src/execution/status-file.ts
33698
- import { rename, unlink as unlink2 } from "fs/promises";
33721
+ import { rename, unlink as unlink3 } from "fs/promises";
33699
33722
  import { resolve as resolve8 } from "path";
33700
33723
  function countProgress(prd) {
33701
33724
  const stories = prd.userStories;
@@ -33744,7 +33767,7 @@ async function writeStatusFile(filePath, status) {
33744
33767
  }
33745
33768
  const tmpPath = `${resolvedPath}.tmp`;
33746
33769
  try {
33747
- await unlink2(tmpPath);
33770
+ await unlink3(tmpPath);
33748
33771
  } catch {}
33749
33772
  await Bun.write(tmpPath, JSON.stringify(status, null, 2));
33750
33773
  await rename(tmpPath, resolvedPath);
@@ -65836,7 +65859,18 @@ async function planCommand(workdir, config2, options) {
65836
65859
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65837
65860
  const interactionBridge = createCliInteractionBridge();
65838
65861
  const pidRegistry = new PidRegistry(workdir);
65839
- logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
65862
+ const dangerouslySkipPermissions = config2?.execution?.dangerouslySkipPermissions ?? false;
65863
+ const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
65864
+ const resolvedModel = config2?.plan?.model ?? "balanced";
65865
+ logger?.info("plan", "Starting interactive planning session", {
65866
+ agent: agentName,
65867
+ model: resolvedModel,
65868
+ permission: permissionMode,
65869
+ workdir,
65870
+ feature: options.feature,
65871
+ timeoutSeconds
65872
+ });
65873
+ const planStartTime = Date.now();
65840
65874
  try {
65841
65875
  await adapter.plan({
65842
65876
  prompt,
@@ -65845,15 +65879,15 @@ async function planCommand(workdir, config2, options) {
65845
65879
  timeoutSeconds,
65846
65880
  interactionBridge,
65847
65881
  config: config2,
65848
- modelTier: config2?.plan?.model ?? "balanced",
65849
- dangerouslySkipPermissions: config2?.execution?.dangerouslySkipPermissions ?? false,
65882
+ modelTier: resolvedModel,
65883
+ dangerouslySkipPermissions,
65850
65884
  maxInteractionTurns: config2?.agent?.maxInteractionTurns,
65851
65885
  featureName: options.feature,
65852
65886
  pidRegistry
65853
65887
  });
65854
65888
  } finally {
65855
65889
  await pidRegistry.killAll().catch(() => {});
65856
- logger?.info("plan", "Interactive session ended");
65890
+ logger?.info("plan", "Interactive session ended", { durationMs: Date.now() - planStartTime });
65857
65891
  }
65858
65892
  if (!_deps2.existsSync(outputPath)) {
65859
65893
  throw new Error(`[plan] Agent did not write PRD to ${outputPath}. Check agent logs for errors.`);
@@ -66140,7 +66174,7 @@ import { join as join13 } from "path";
66140
66174
  // src/commands/common.ts
66141
66175
  init_path_security2();
66142
66176
  init_errors3();
66143
- import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync2 } from "fs";
66177
+ import { existsSync as existsSync10, readdirSync as readdirSync2, realpathSync as realpathSync3 } from "fs";
66144
66178
  import { join as join11, resolve as resolve6 } from "path";
66145
66179
  function resolveProject(options = {}) {
66146
66180
  const { dir, feature } = options;
@@ -66148,7 +66182,7 @@ function resolveProject(options = {}) {
66148
66182
  let naxDir;
66149
66183
  let configPath;
66150
66184
  if (dir) {
66151
- projectRoot = realpathSync2(resolve6(dir));
66185
+ projectRoot = realpathSync3(resolve6(dir));
66152
66186
  naxDir = join11(projectRoot, "nax");
66153
66187
  if (!existsSync10(naxDir)) {
66154
66188
  throw new NaxError(`Directory does not contain a nax project: ${projectRoot}
@@ -66207,7 +66241,7 @@ function findProjectRoot(startDir) {
66207
66241
  const naxDir = join11(current, "nax");
66208
66242
  const configPath = join11(naxDir, "config.json");
66209
66243
  if (existsSync10(configPath)) {
66210
- return realpathSync2(current);
66244
+ return realpathSync3(current);
66211
66245
  }
66212
66246
  const parent = join11(current, "..");
66213
66247
  if (parent === current) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.6",
3
+ "version": "0.42.8",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -453,6 +453,11 @@ export class AcpAgentAdapter implements AgentAdapter {
453
453
 
454
454
  // 2. Permission mode follows dangerouslySkipPermissions, default is "approve-reads". or should --deny-all be the default?
455
455
  const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
456
+ getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
457
+ permission: permissionMode,
458
+ dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
459
+ stage: options.featureName ? "run" : "plan",
460
+ });
456
461
 
457
462
  // 3. Ensure session (resume existing or create new)
458
463
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
@@ -138,6 +138,11 @@ class SpawnAcpSession implements AcpSession {
138
138
  "-",
139
139
  ];
140
140
 
141
+ getSafeLogger()?.info("acp-adapter", "Sending prompt", {
142
+ session: this.sessionName,
143
+ permission: this.permissionMode,
144
+ cmd: cmd.join(" "),
145
+ });
141
146
  getSafeLogger()?.debug("acp-adapter", `Sending prompt to session: ${this.sessionName}`);
142
147
 
143
148
  const proc = _spawnClientDeps.spawn(cmd, {
@@ -254,6 +259,7 @@ export class SpawnAcpClient implements AcpClient {
254
259
  private readonly model: string;
255
260
  private readonly cwd: string;
256
261
  private readonly timeoutSeconds: number;
262
+ private readonly permissionMode: string;
257
263
  private readonly env: Record<string, string | undefined>;
258
264
  private readonly pidRegistry?: PidRegistry;
259
265
 
@@ -270,6 +276,7 @@ export class SpawnAcpClient implements AcpClient {
270
276
  this.agentName = lastToken;
271
277
  this.cwd = cwd || process.cwd();
272
278
  this.timeoutSeconds = timeoutSeconds || 1800;
279
+ this.permissionMode = "approve-reads";
273
280
  this.env = buildAllowedEnv();
274
281
  this.pidRegistry = pidRegistry;
275
282
  }
@@ -326,7 +333,7 @@ export class SpawnAcpClient implements AcpClient {
326
333
  cwd: this.cwd,
327
334
  model: this.model,
328
335
  timeoutSeconds: this.timeoutSeconds,
329
- permissionMode: "approve-all", // Default for resumed sessions
336
+ permissionMode: this.permissionMode,
330
337
  env: this.env,
331
338
  pidRegistry: this.pidRegistry,
332
339
  });
package/src/cli/plan.ts CHANGED
@@ -125,7 +125,18 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
125
125
  if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
126
126
  const interactionBridge = createCliInteractionBridge();
127
127
  const pidRegistry = new PidRegistry(workdir);
128
- logger?.info("plan", "Starting interactive planning session...", { agent: agentName });
128
+ const dangerouslySkipPermissions = config?.execution?.dangerouslySkipPermissions ?? false;
129
+ const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
130
+ const resolvedModel = config?.plan?.model ?? "balanced";
131
+ logger?.info("plan", "Starting interactive planning session", {
132
+ agent: agentName,
133
+ model: resolvedModel,
134
+ permission: permissionMode,
135
+ workdir,
136
+ feature: options.feature,
137
+ timeoutSeconds,
138
+ });
139
+ const planStartTime = Date.now();
129
140
  try {
130
141
  await adapter.plan({
131
142
  prompt,
@@ -134,15 +145,15 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
134
145
  timeoutSeconds,
135
146
  interactionBridge,
136
147
  config,
137
- modelTier: config?.plan?.model ?? "balanced",
138
- dangerouslySkipPermissions: config?.execution?.dangerouslySkipPermissions ?? false,
148
+ modelTier: resolvedModel,
149
+ dangerouslySkipPermissions,
139
150
  maxInteractionTurns: config?.agent?.maxInteractionTurns,
140
151
  featureName: options.feature,
141
152
  pidRegistry,
142
153
  });
143
154
  } finally {
144
155
  await pidRegistry.killAll().catch(() => {});
145
- logger?.info("plan", "Interactive session ended");
156
+ logger?.info("plan", "Interactive session ended", { durationMs: Date.now() - planStartTime });
146
157
  }
147
158
  // Read back from file written by agent
148
159
  if (!_deps.existsSync(outputPath)) {
@@ -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
- process.on("unhandledRejection", (reason) => unhandledRejectionHandler(reason));
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", (reason) => unhandledRejectionHandler(reason));
147
+ process.removeListener("unhandledRejection", rejectionWrapper);
147
148
  logger?.debug("crash-recovery", "Signal handlers unregistered");
148
149
  };
149
150
  }
@@ -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 = undefined as unknown as { pid: number };
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
- const file = Bun.file(lockPath);
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
- const logger = getSafeLogger();
127
- logger?.warn("execution", "Failed to release lock", {
128
- error: (error as Error).message,
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 { isAbsolute, join, normalize, resolve } from "node:path";
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
- const normalizedRoots = allowedRoots.map((r) => resolve(r));
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
  }