@nathapp/nax 0.18.5 → 0.19.0

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.
Files changed (49) hide show
  1. package/.gitlab-ci.yml +3 -3
  2. package/CHANGELOG.md +7 -0
  3. package/docs/ROADMAP.md +2 -1
  4. package/nax/features/nax-compliance/prd.json +52 -0
  5. package/nax/features/nax-compliance/progress.txt +1 -0
  6. package/nax/features/v0.19.0-hardening/plan.md +7 -0
  7. package/nax/features/v0.19.0-hardening/prd.json +84 -0
  8. package/nax/features/v0.19.0-hardening/progress.txt +7 -0
  9. package/nax/features/v0.19.0-hardening/spec.md +18 -0
  10. package/nax/features/v0.19.0-hardening/tasks.md +8 -0
  11. package/nax/status.json +27 -0
  12. package/package.json +2 -2
  13. package/src/acceptance/fix-generator.ts +6 -2
  14. package/src/acceptance/generator.ts +3 -1
  15. package/src/acceptance/types.ts +3 -1
  16. package/src/agents/claude-plan.ts +6 -5
  17. package/src/agents/claude.ts +19 -9
  18. package/src/cli/analyze.ts +1 -0
  19. package/src/cli/init.ts +7 -6
  20. package/src/config/defaults.ts +1 -0
  21. package/src/config/types.ts +2 -0
  22. package/src/context/injector.ts +18 -18
  23. package/src/execution/crash-recovery.ts +7 -10
  24. package/src/execution/lifecycle/acceptance-loop.ts +1 -0
  25. package/src/execution/lifecycle/index.ts +0 -1
  26. package/src/execution/lifecycle/precheck-runner.ts +1 -1
  27. package/src/execution/lifecycle/run-setup.ts +14 -14
  28. package/src/execution/parallel.ts +1 -1
  29. package/src/execution/runner.ts +1 -19
  30. package/src/execution/sequential-executor.ts +1 -1
  31. package/src/hooks/runner.ts +2 -2
  32. package/src/interaction/plugins/auto.ts +2 -2
  33. package/src/logger/logger.ts +3 -5
  34. package/src/plugins/loader.ts +36 -9
  35. package/src/routing/batch-route.ts +32 -0
  36. package/src/routing/index.ts +1 -0
  37. package/src/routing/loader.ts +7 -0
  38. package/src/tui/hooks/usePty.ts +20 -9
  39. package/src/utils/path-security.ts +56 -0
  40. package/src/verification/executor.ts +6 -13
  41. package/test/integration/plugins/config-resolution.test.ts +3 -3
  42. package/test/integration/plugins/loader.test.ts +3 -1
  43. package/test/integration/precheck-integration.test.ts +18 -11
  44. package/test/integration/security-loader.test.ts +83 -0
  45. package/test/unit/formatters.test.ts +2 -3
  46. package/test/unit/hooks/shell-security.test.ts +40 -0
  47. package/test/unit/utils/path-security.test.ts +47 -0
  48. package/src/execution/lifecycle/run-lifecycle.ts +0 -312
  49. package/test/unit/run-lifecycle.test.ts +0 -140
@@ -1,3 +1,4 @@
1
+ import { appendFileSync } from "node:fs";
1
2
  /**
2
3
  * Crash Recovery — Signal handlers, heartbeat, and exit summary
3
4
  *
@@ -63,9 +64,8 @@ async function writeFatalLog(jsonlFilePath: string | undefined, signal: string,
63
64
  };
64
65
 
65
66
  const line = `${JSON.stringify(fatalEntry)}\n`;
66
- // Use appendFileSync from node:fs to ensure file is created if it doesn't exist
67
- const { appendFileSync } = await import("node:fs");
68
- appendFileSync(jsonlFilePath, line, "utf8");
67
+ // Use Bun.write with append: true
68
+ appendFileSync(jsonlFilePath, line);
69
69
  } catch (err) {
70
70
  console.error("[crash-recovery] Failed to write fatal log:", err);
71
71
  }
@@ -107,8 +107,7 @@ async function writeRunComplete(ctx: CrashRecoveryContext, exitReason: string):
107
107
  };
108
108
 
109
109
  const line = `${JSON.stringify(runCompleteEntry)}\n`;
110
- const { appendFileSync } = await import("node:fs");
111
- appendFileSync(ctx.jsonlFilePath, line, "utf8");
110
+ appendFileSync(ctx.jsonlFilePath, line);
112
111
  logger?.debug("crash-recovery", "run.complete event written", { exitReason });
113
112
  } catch (err) {
114
113
  console.error("[crash-recovery] Failed to write run.complete event:", err);
@@ -279,8 +278,7 @@ export function startHeartbeat(
279
278
  },
280
279
  };
281
280
  const line = `${JSON.stringify(heartbeatEntry)}\n`;
282
- const { appendFileSync } = await import("node:fs");
283
- appendFileSync(jsonlFilePath, line, "utf8");
281
+ appendFileSync(jsonlFilePath, line);
284
282
  } catch (err) {
285
283
  logger?.warn("crash-recovery", "Failed to write heartbeat", { error: (err as Error).message });
286
284
  }
@@ -342,9 +340,8 @@ export async function writeExitSummary(
342
340
  };
343
341
 
344
342
  const line = `${JSON.stringify(summaryEntry)}\n`;
345
- // Use appendFileSync from node:fs to ensure file is created if it doesn't exist
346
- const { appendFileSync } = await import("node:fs");
347
- appendFileSync(jsonlFilePath, line, "utf8");
343
+ // Use Bun.write with append: true
344
+ appendFileSync(jsonlFilePath, line);
348
345
  logger?.debug("crash-recovery", "Exit summary written");
349
346
  } catch (err) {
350
347
  logger?.warn("crash-recovery", "Failed to write exit summary", { error: (err as Error).message });
@@ -93,6 +93,7 @@ async function generateAndAddFixStories(
93
93
  specContent: await loadSpecContent(ctx.featureDir),
94
94
  workdir: ctx.workdir,
95
95
  modelDef,
96
+ config: ctx.config,
96
97
  });
97
98
  if (fixStories.length === 0) {
98
99
  logger?.error("acceptance", "Failed to generate fix stories");
@@ -2,7 +2,6 @@
2
2
  * Lifecycle module exports
3
3
  */
4
4
 
5
- export { RunLifecycle, type SetupResult, type TeardownOptions } from "./run-lifecycle";
6
5
  export { runAcceptanceLoop, type AcceptanceLoopContext, type AcceptanceLoopResult } from "./acceptance-loop";
7
6
  export { emitStoryComplete, type StoryCompleteEvent } from "./story-hooks";
8
7
  export { outputRunHeader, outputRunFooter, type RunHeaderOptions, type RunFooterOptions } from "./headless-formatter";
@@ -62,7 +62,7 @@ export async function runPrecheckValidation(ctx: PrecheckContext): Promise<void>
62
62
  warnings: precheckResult.output.warnings.map((w) => ({ name: w.name, message: w.message })),
63
63
  summary: precheckResult.output.summary,
64
64
  };
65
- appendFileSync(ctx.logFilePath, `${JSON.stringify(precheckLog)}\n`, "utf8");
65
+ require("node:fs").appendFileSync(ctx.logFilePath, `${JSON.stringify(precheckLog)}\n`);
66
66
  }
67
67
 
68
68
  // Handle blockers (Tier 1 failures)
@@ -122,20 +122,6 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
122
122
  getStoriesCompleted: options.getStoriesCompleted,
123
123
  });
124
124
 
125
- // Acquire lock to prevent concurrent execution
126
- const lockAcquired = await acquireLock(workdir);
127
- if (!lockAcquired) {
128
- logger?.error("execution", "Another nax process is already running in this directory");
129
- logger?.error("execution", "If you believe this is an error, remove nax.lock manually");
130
- throw new LockAcquisitionError(workdir);
131
- }
132
-
133
- // Load plugins (before try block so it's accessible in finally)
134
- const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
135
- const projectPluginsDir = path.join(workdir, "nax", "plugins");
136
- const configPlugins = config.plugins || [];
137
- const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
138
-
139
125
  // Load PRD (before try block so it's accessible in finally for onRunEnd)
140
126
  let prd = await loadPRD(prdPath);
141
127
 
@@ -163,6 +149,20 @@ export async function setupRun(options: RunSetupOptions): Promise<RunSetupResult
163
149
  logger?.warn("precheck", "Precheck validations skipped (--skip-precheck)");
164
150
  }
165
151
 
152
+ // Acquire lock to prevent concurrent execution
153
+ const lockAcquired = await acquireLock(workdir);
154
+ if (!lockAcquired) {
155
+ logger?.error("execution", "Another nax process is already running in this directory");
156
+ logger?.error("execution", "If you believe this is an error, remove nax.lock manually");
157
+ throw new LockAcquisitionError(workdir);
158
+ }
159
+
160
+ // Load plugins (before try block so it's accessible in finally)
161
+ const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
162
+ const projectPluginsDir = path.join(workdir, "nax", "plugins");
163
+ const configPlugins = config.plugins || [];
164
+ const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, workdir);
165
+
166
166
  // Log plugins loaded
167
167
  logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
168
168
  plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
@@ -18,7 +18,7 @@ import type { PipelineContext, RoutingResult } from "../pipeline/types";
18
18
  import type { PluginRegistry } from "../plugins/registry";
19
19
  import type { PRD, UserStory } from "../prd";
20
20
  import { markStoryFailed, markStoryPassed, savePRD } from "../prd";
21
- import { routeTask } from "../routing";
21
+ import { routeTask, tryLlmBatchRoute } from "../routing";
22
22
  import { WorktreeManager } from "../worktree/manager";
23
23
  import { MergeEngine, type StoryDependencies } from "../worktree/merge";
24
24
 
@@ -15,6 +15,7 @@ import type { StoryMetrics } from "../metrics";
15
15
  import type { PipelineEventEmitter } from "../pipeline/events";
16
16
  import { countStories, isComplete } from "../prd";
17
17
  import type { UserStory } from "../prd";
18
+ import { tryLlmBatchRoute } from "../routing/batch-route";
18
19
  import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../routing/strategies/llm";
19
20
  import { precomputeBatchPlan } from "./batching";
20
21
  import { stopHeartbeat, writeExitSummary } from "./crash-recovery";
@@ -25,25 +26,6 @@ export { resolveMaxAttemptsOutcome } from "./escalation";
25
26
 
26
27
  /** Run options */
27
28
 
28
- /**
29
- * Try LLM batch routing for ready stories. Logs and swallows errors (falls back to per-story routing).
30
- */
31
- async function tryLlmBatchRoute(config: NaxConfig, stories: UserStory[], label = "routing"): Promise<void> {
32
- const mode = config.routing.llm?.mode ?? "hybrid";
33
- if (config.routing.strategy !== "llm" || mode === "per-story" || stories.length === 0) return;
34
- const logger = getSafeLogger();
35
- try {
36
- logger?.debug("routing", `LLM batch routing: ${label}`, { storyCount: stories.length, mode });
37
- await llmRouteBatch(stories, { config });
38
- logger?.debug("routing", "LLM batch routing complete", { label });
39
- } catch (err) {
40
- logger?.warn("routing", "LLM batch routing failed, falling back to individual routing", {
41
- error: (err as Error).message,
42
- label,
43
- });
44
- }
45
- }
46
-
47
29
  export interface RunOptions {
48
30
  /** Path to prd.json */
49
31
  prdPath: string;
@@ -12,7 +12,7 @@ import type { PipelineContext, RoutingResult } from "../pipeline/types";
12
12
  import type { PluginRegistry } from "../plugins";
13
13
  import { generateHumanHaltSummary, getNextStory, isComplete, isStalled, loadPRD } from "../prd";
14
14
  import type { PRD, UserStory } from "../prd/types";
15
- import { routeTask } from "../routing";
15
+ import { routeTask, tryLlmBatchRoute } from "../routing";
16
16
  import { captureGitRef } from "../utils/git";
17
17
  import type { StoryBatch } from "./batching";
18
18
  import { startHeartbeat, stopHeartbeat, writeExitSummary } from "./crash-recovery";
@@ -106,7 +106,7 @@ function buildEnv(ctx: HookContext): Record<string, string> {
106
106
  * @param command - Command string to check
107
107
  * @returns true if shell operators detected
108
108
  */
109
- function hasShellOperators(command: string): boolean {
109
+ export function hasShellOperators(command: string): boolean {
110
110
  // Check for common shell operators that require shell interpretation
111
111
  const shellOperators = /[|&;$`<>(){}]/;
112
112
  return shellOperators.test(command);
@@ -117,7 +117,7 @@ function hasShellOperators(command: string): boolean {
117
117
  * @param command - Command string to validate
118
118
  * @throws Error if obvious injection pattern detected
119
119
  */
120
- function validateHookCommand(command: string): void {
120
+ export function validateHookCommand(command: string): void {
121
121
  // Reject commands with obvious injection patterns
122
122
  const dangerousPatterns = [
123
123
  /\$\(.*\)/, // Command substitution $(...)
@@ -148,8 +148,8 @@ Stage: ${request.stage}
148
148
  Feature: ${request.featureName}
149
149
  ${request.storyId ? `Story: ${request.storyId}` : ""}
150
150
 
151
- Summary: ${request.summary}
152
- ${request.detail ? `\nDetail: ${request.detail}` : ""}
151
+ Summary: ${request.summary.replace(/`/g, "\\`").replace(/\$/g, "\\$")}
152
+ ${request.detail ? `\nDetail: ${request.detail.replace(/`/g, "\\`").replace(/\$/g, "\\$")}` : ""}
153
153
  `;
154
154
 
155
155
  if (request.options && request.options.length > 0) {
@@ -1,4 +1,4 @@
1
- import { appendFileSync } from "node:fs";
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { type FormatterOptions, type VerbosityMode, formatLogEntry } from "../logging/index.js";
3
3
  import { formatConsole, formatJsonl } from "./formatters.js";
4
4
  import type { LogEntry, LogLevel, LoggerOptions, StoryLogger } from "./types.js";
@@ -64,7 +64,7 @@ export class Logger {
64
64
  try {
65
65
  const dir = this.filePath.substring(0, this.filePath.lastIndexOf("/"));
66
66
  if (dir) {
67
- Bun.spawnSync(["mkdir", "-p", dir]);
67
+ mkdirSync(dir, { recursive: true });
68
68
  }
69
69
  } catch (error) {
70
70
  console.error(`[logger] Failed to create log directory: ${error}`);
@@ -149,9 +149,7 @@ export class Logger {
149
149
  if (!this.filePath) return;
150
150
 
151
151
  try {
152
- const line = `${formatJsonl(entry)}\n`;
153
- // Use Node.js fs for simple synchronous append
154
- appendFileSync(this.filePath, line, "utf8");
152
+ appendFileSync(this.filePath, `${formatJsonl(entry)}\n`);
155
153
  } catch (error) {
156
154
  console.error(`[logger] Failed to write to log file: ${error}`);
157
155
  }
@@ -10,6 +10,7 @@
10
10
  import * as fs from "node:fs/promises";
11
11
  import * as path from "node:path";
12
12
  import { getSafeLogger as _getSafeLoggerFromModule } from "../logger";
13
+ import { validateModulePath } from "../utils/path-security";
13
14
  import { PluginRegistry } from "./registry";
14
15
  import type { NaxPlugin, PluginConfigEntry } from "./types";
15
16
  import { validatePlugin } from "./validator";
@@ -78,12 +79,13 @@ export async function loadPlugins(
78
79
  projectRoot?: string,
79
80
  ): Promise<PluginRegistry> {
80
81
  const loadedPlugins: LoadedPlugin[] = [];
82
+ const effectiveProjectRoot = projectRoot || projectDir;
81
83
  const pluginNames = new Set<string>();
82
84
 
83
85
  // 1. Load plugins from global directory
84
86
  const globalPlugins = await discoverPlugins(globalDir);
85
87
  for (const plugin of globalPlugins) {
86
- const validated = await loadAndValidatePlugin(plugin.path, {});
88
+ const validated = await loadAndValidatePlugin(plugin.path, {}, [globalDir]);
87
89
  if (validated) {
88
90
  if (pluginNames.has(validated.name)) {
89
91
  const logger = getSafeLogger();
@@ -100,7 +102,7 @@ export async function loadPlugins(
100
102
  // 2. Load plugins from project directory
101
103
  const projectPlugins = await discoverPlugins(projectDir);
102
104
  for (const plugin of projectPlugins) {
103
- const validated = await loadAndValidatePlugin(plugin.path, {});
105
+ const validated = await loadAndValidatePlugin(plugin.path, {}, [projectDir]);
104
106
  if (validated) {
105
107
  if (pluginNames.has(validated.name)) {
106
108
  const logger = getSafeLogger();
@@ -116,9 +118,14 @@ export async function loadPlugins(
116
118
 
117
119
  // 3. Load plugins from config entries
118
120
  for (const entry of configPlugins) {
119
- // Resolve module path relative to project root for relative paths
120
- const resolvedModule = resolveModulePath(entry.module, projectRoot);
121
- const validated = await loadAndValidatePlugin(resolvedModule, entry.config ?? {}, entry.module);
121
+ // Resolve module path relative to effective project root for relative paths
122
+ const resolvedModule = resolveModulePath(entry.module, effectiveProjectRoot);
123
+ const validated = await loadAndValidatePlugin(
124
+ resolvedModule,
125
+ entry.config ?? {},
126
+ [globalDir, projectDir, effectiveProjectRoot].filter(Boolean),
127
+ entry.module,
128
+ );
122
129
  if (validated) {
123
130
  if (pluginNames.has(validated.name)) {
124
131
  const logger = getSafeLogger();
@@ -228,12 +235,32 @@ function resolveModulePath(modulePath: string, projectRoot?: string): string {
228
235
  * @returns Validated plugin or null if invalid
229
236
  */
230
237
  async function loadAndValidatePlugin(
231
- modulePath: string,
238
+ initialModulePath: string,
232
239
  config: Record<string, unknown>,
240
+ allowedRoots: string[] = [],
233
241
  originalPath?: string,
234
242
  ): Promise<NaxPlugin | null> {
243
+ let attemptedPath = initialModulePath;
235
244
  try {
245
+ // SEC-1: Validate module path if it's a file path (not an npm package)
246
+ let modulePath = initialModulePath;
247
+ const isFilePath = modulePath.startsWith("/") || modulePath.startsWith("./") || modulePath.startsWith("../");
248
+
249
+ if (isFilePath && allowedRoots.length > 0) {
250
+ const validation = validateModulePath(modulePath, allowedRoots);
251
+ if (!validation.valid) {
252
+ const logger = getSafeLogger();
253
+ logger?.error("plugins", `Security: ${validation.error}`);
254
+ _pluginErrorSink(`[plugins] Security: ${validation.error}`);
255
+ return null;
256
+ }
257
+ // Use the normalized absolute path from the validator
258
+ const validatedPath = validation.absolutePath as string;
259
+ modulePath = validatedPath;
260
+ }
261
+
236
262
  // Import the module
263
+ attemptedPath = modulePath;
237
264
  const imported = await import(modulePath);
238
265
 
239
266
  // Try default export first, then named exports
@@ -258,7 +285,7 @@ async function loadAndValidatePlugin(
258
285
 
259
286
  return validated;
260
287
  } catch (error) {
261
- const displayPath = originalPath || modulePath;
288
+ const displayPath = originalPath || initialModulePath;
262
289
  const errorMsg = error instanceof Error ? error.message : String(error);
263
290
  const logger = getSafeLogger();
264
291
 
@@ -266,14 +293,14 @@ async function loadAndValidatePlugin(
266
293
  if (errorMsg.includes("Cannot find module") || errorMsg.includes("ENOENT")) {
267
294
  const msg = `Failed to load plugin module '${displayPath}'`;
268
295
  logger?.error("plugins", msg);
269
- logger?.error("plugins", `Attempted path: ${modulePath}`);
296
+ logger?.error("plugins", `Attempted path: ${attemptedPath}`);
270
297
  logger?.error(
271
298
  "plugins",
272
299
  "Ensure the module exists and the path is correct (relative paths are resolved from project root)",
273
300
  );
274
301
  // Always emit to sink so tests (and headless mode without logger) can capture output
275
302
  _pluginErrorSink(`[plugins] ${msg}`);
276
- _pluginErrorSink(`[plugins] Attempted path: ${modulePath}`);
303
+ _pluginErrorSink(`[plugins] Attempted path: ${attemptedPath}`);
277
304
  _pluginErrorSink(
278
305
  "[plugins] Ensure the module exists and the path is correct (relative paths are resolved from project root)",
279
306
  );
@@ -0,0 +1,32 @@
1
+ /**
2
+ * LLM Batch Routing Helper
3
+ */
4
+
5
+ import type { NaxConfig } from "../config";
6
+ import { getSafeLogger } from "../logger";
7
+ import type { UserStory } from "../prd";
8
+ import { routeBatch as llmRouteBatch } from "./strategies/llm";
9
+
10
+ /**
11
+ * Attempt to pre-route a batch of stories using LLM to optimize cost and consistency.
12
+ *
13
+ * @param config - Global config
14
+ * @param stories - Stories to route
15
+ * @param label - Label for logging
16
+ */
17
+ export async function tryLlmBatchRoute(config: NaxConfig, stories: UserStory[], label = "routing"): Promise<void> {
18
+ const mode = config.routing.llm?.mode ?? "hybrid";
19
+ if (config.routing.strategy !== "llm" || mode === "per-story" || stories.length === 0) return;
20
+
21
+ const logger = getSafeLogger();
22
+ try {
23
+ logger?.debug("routing", `LLM batch routing: ${label}`, { storyCount: stories.length, mode });
24
+ await llmRouteBatch(stories, { config });
25
+ logger?.debug("routing", "LLM batch routing complete", { label });
26
+ } catch (err) {
27
+ logger?.warn("routing", "LLM batch routing failed, falling back to individual routing", {
28
+ error: (err as Error).message,
29
+ label,
30
+ });
31
+ }
32
+ }
@@ -14,3 +14,4 @@ export { keywordStrategy, llmStrategy, manualStrategy } from "./strategies";
14
14
 
15
15
  // Custom strategy loader
16
16
  export { loadCustomStrategy } from "./loader";
17
+ export { tryLlmBatchRoute } from "./batch-route";
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { resolve } from "node:path";
8
+ import { validateModulePath } from "../utils/path-security";
8
9
  import type { RoutingStrategy } from "./strategy";
9
10
 
10
11
  /**
@@ -27,6 +28,12 @@ import type { RoutingStrategy } from "./strategy";
27
28
  export async function loadCustomStrategy(strategyPath: string, workdir: string): Promise<RoutingStrategy> {
28
29
  const absolutePath = resolve(workdir, strategyPath);
29
30
 
31
+ // SEC-2: Validate path against workdir root
32
+ const validation = validateModulePath(absolutePath, [workdir]);
33
+ if (!validation.valid) {
34
+ throw new Error(`Security: ${validation.error}`);
35
+ }
36
+
30
37
  try {
31
38
  // Dynamic import (works with both .ts and .js files in Bun)
32
39
  const module = await import(absolutePath);
@@ -84,19 +84,25 @@ export function usePty(options: PtySpawnOptions | null): PtyState & { handle: Pt
84
84
  const [ptyProcess, setPtyProcess] = useState<ReturnType<typeof Bun.spawn> | null>(null);
85
85
 
86
86
  // Spawn PTY process
87
+ // BUG-2: Destructure options to prevent infinite respawn loop due to object identity
88
+ const command = options?.command;
89
+ const argsJson = JSON.stringify(options?.args);
90
+ const cwd = options?.cwd;
91
+ const envJson = JSON.stringify(options?.env);
92
+
87
93
  useEffect(() => {
88
- if (!options) {
94
+ if (!command) {
89
95
  return;
90
96
  }
91
97
 
92
98
  // BUN-001: Replaced node-pty with Bun.spawn (piped stdio).
93
99
  // TERM + FORCE_COLOR preserve Claude Code output formatting.
94
- const proc = Bun.spawn([options.command, ...(options.args || [])], {
95
- cwd: options.cwd || process.cwd(),
96
- env: { ...process.env, ...options.env, TERM: "xterm-256color", FORCE_COLOR: "1" },
100
+ const proc = Bun.spawn([command, ...(JSON.parse(argsJson) || [])], {
101
+ cwd: cwd || process.cwd(),
102
+ env: { ...process.env, ...JSON.parse(envJson), TERM: "xterm-256color", FORCE_COLOR: "1" },
97
103
  stdin: "pipe",
98
104
  stdout: "pipe",
99
- stderr: "pipe",
105
+ stderr: "inherit", // MEM-1: Inherit stderr to avoid blocking on unread pipe
100
106
  });
101
107
 
102
108
  setPtyProcess(proc);
@@ -128,9 +134,14 @@ export function usePty(options: PtySpawnOptions | null): PtyState & { handle: Pt
128
134
  })();
129
135
 
130
136
  // Handle exit
131
- proc.exited.then((code) => {
132
- setState((prev) => ({ ...prev, isRunning: false, exitCode: code ?? undefined }));
133
- });
137
+ proc.exited
138
+ .then((code) => {
139
+ setState((prev) => ({ ...prev, isRunning: false, exitCode: code ?? undefined }));
140
+ })
141
+ .catch(() => {
142
+ // BUG-22: Guard against setState throws (e.g. on unmount)
143
+ setState((prev) => ({ ...prev, isRunning: false }));
144
+ });
134
145
 
135
146
  // Create handle
136
147
  const ptyHandle: PtyHandle = {
@@ -152,7 +163,7 @@ export function usePty(options: PtySpawnOptions | null): PtyState & { handle: Pt
152
163
  return () => {
153
164
  proc.kill();
154
165
  };
155
- }, [options]);
166
+ }, [command, argsJson, cwd, envJson]);
156
167
 
157
168
  // Handle terminal resize
158
169
  // resize is a no-op with Bun.spawn (no PTY) — kept for API compatibility
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Path security utilities for nax (SEC-1, SEC-2).
3
+ */
4
+
5
+ import { isAbsolute, join, normalize, resolve } from "node:path";
6
+
7
+ /**
8
+ * Result of a path validation.
9
+ */
10
+ export interface PathValidationResult {
11
+ /** Whether the path is valid and allowed */
12
+ valid: boolean;
13
+ /** The absolute, normalized path (if valid) */
14
+ absolutePath?: string;
15
+ /** Error message if invalid */
16
+ error?: string;
17
+ }
18
+
19
+ /**
20
+ * Validates that a module path is within an allowed root directory.
21
+ *
22
+ * @param modulePath - The user-provided path to validate (relative or absolute)
23
+ * @param allowedRoots - Array of absolute paths that are allowed as roots
24
+ * @returns Validation result
25
+ */
26
+ export function validateModulePath(modulePath: string, allowedRoots: string[]): PathValidationResult {
27
+ if (!modulePath) {
28
+ return { valid: false, error: "Module path is empty" };
29
+ }
30
+
31
+ const normalizedRoots = allowedRoots.map((r) => resolve(r));
32
+
33
+ // If absolute, just check against roots
34
+ if (isAbsolute(modulePath)) {
35
+ const absoluteTarget = normalize(modulePath);
36
+ const isWithin = normalizedRoots.some((root) => {
37
+ return absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root;
38
+ });
39
+ if (isWithin) {
40
+ return { valid: true, absolutePath: absoluteTarget };
41
+ }
42
+ } else {
43
+ // If relative, check if it's within any root when resolved relative to that root
44
+ for (const root of normalizedRoots) {
45
+ const absoluteTarget = resolve(join(root, modulePath));
46
+ if (absoluteTarget.startsWith(`${root}/`) || absoluteTarget === root) {
47
+ return { valid: true, absolutePath: absoluteTarget };
48
+ }
49
+ }
50
+ }
51
+
52
+ return {
53
+ valid: false,
54
+ error: `Path "${modulePath}" is outside allowed roots`,
55
+ };
56
+ }
@@ -85,15 +85,13 @@ export async function executeWithTimeout(
85
85
  });
86
86
 
87
87
  const timeoutMs = timeoutSeconds * 1000;
88
- let timeoutId: Timer | null = null;
88
+
89
89
  let timedOut = false;
90
90
 
91
- const timeoutPromise = new Promise<void>((resolve) => {
92
- timeoutId = setTimeout(() => {
93
- timedOut = true;
94
- resolve();
95
- }, timeoutMs);
96
- });
91
+ const timeoutPromise = (async () => {
92
+ await Bun.sleep(timeoutMs);
93
+ timedOut = true;
94
+ })();
97
95
 
98
96
  const processPromise = proc.exited;
99
97
 
@@ -115,7 +113,7 @@ export async function executeWithTimeout(
115
113
  }
116
114
 
117
115
  // Wait for graceful shutdown
118
- await new Promise((resolve) => setTimeout(resolve, gracePeriodMs));
116
+ await Bun.sleep(gracePeriodMs);
119
117
 
120
118
  // Force SIGKILL entire process group if still running
121
119
  try {
@@ -142,11 +140,6 @@ export async function executeWithTimeout(
142
140
  };
143
141
  }
144
142
 
145
- // Clear timeout if process finished in time
146
- if (timeoutId) {
147
- clearTimeout(timeoutId);
148
- }
149
-
150
143
  const exitCode = raceResult as number;
151
144
  const stdout = await new Response(proc.stdout).text();
152
145
  const stderr = await new Response(proc.stderr).text();
@@ -230,7 +230,7 @@ describe("Plugin config path resolution (US-007)", () => {
230
230
  expect(registry.plugins[0].name).toBe("relative-plugin");
231
231
  });
232
232
 
233
- test("resolves ../relative/path from project root", async () => {
233
+ test("blocks ../ traversal outside project root (SEC-1/SEC-2)", async () => {
234
234
  const projectRoot = path.join(tempDir, "project");
235
235
  const pluginDir = path.join(tempDir, "shared-plugins");
236
236
  await fs.mkdir(projectRoot, { recursive: true });
@@ -271,8 +271,8 @@ describe("Plugin config path resolution (US-007)", () => {
271
271
  projectRoot,
272
272
  );
273
273
 
274
- expect(registry.plugins).toHaveLength(1);
275
- expect(registry.plugins[0].name).toBe("parent-relative-plugin");
274
+ // SEC-1/SEC-2: traversal outside project root is blocked
275
+ expect(registry.plugins).toHaveLength(0);
276
276
  });
277
277
  });
278
278
 
@@ -379,6 +379,7 @@ describe("loadPlugins", () => {
379
379
  path.join(tempDir, "nonexistent"),
380
380
  path.join(tempDir, "nonexistent"),
381
381
  configPlugins,
382
+ tempDir,
382
383
  );
383
384
 
384
385
  expect(registry.plugins).toHaveLength(1);
@@ -428,6 +429,7 @@ export default {
428
429
  path.join(tempDir, "nonexistent"),
429
430
  path.join(tempDir, "nonexistent"),
430
431
  configPlugins,
432
+ tempDir,
431
433
  );
432
434
 
433
435
  expect(registry.plugins).toHaveLength(1);
@@ -574,7 +576,7 @@ export default {
574
576
  },
575
577
  ];
576
578
 
577
- const registry = await loadPlugins(globalDir, projectDir, configPlugins);
579
+ const registry = await loadPlugins(globalDir, projectDir, configPlugins, tempDir);
578
580
 
579
581
  expect(registry.plugins).toHaveLength(3);
580
582
  expect(registry.plugins[0].name).toBe("global");