@nathapp/nax 0.18.6 → 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 (44) hide show
  1. package/nax/features/nax-compliance/prd.json +52 -0
  2. package/nax/features/nax-compliance/progress.txt +1 -0
  3. package/nax/features/v0.19.0-hardening/plan.md +7 -0
  4. package/nax/features/v0.19.0-hardening/prd.json +84 -0
  5. package/nax/features/v0.19.0-hardening/progress.txt +7 -0
  6. package/nax/features/v0.19.0-hardening/spec.md +18 -0
  7. package/nax/features/v0.19.0-hardening/tasks.md +8 -0
  8. package/nax/status.json +27 -0
  9. package/package.json +2 -2
  10. package/src/acceptance/fix-generator.ts +6 -2
  11. package/src/acceptance/generator.ts +3 -1
  12. package/src/acceptance/types.ts +3 -1
  13. package/src/agents/claude-plan.ts +6 -5
  14. package/src/cli/analyze.ts +1 -0
  15. package/src/cli/init.ts +7 -6
  16. package/src/config/defaults.ts +1 -0
  17. package/src/config/types.ts +2 -0
  18. package/src/context/injector.ts +18 -18
  19. package/src/execution/crash-recovery.ts +7 -10
  20. package/src/execution/lifecycle/acceptance-loop.ts +1 -0
  21. package/src/execution/lifecycle/index.ts +0 -1
  22. package/src/execution/lifecycle/precheck-runner.ts +1 -1
  23. package/src/execution/lifecycle/run-setup.ts +14 -14
  24. package/src/execution/parallel.ts +1 -1
  25. package/src/execution/runner.ts +1 -19
  26. package/src/execution/sequential-executor.ts +1 -1
  27. package/src/hooks/runner.ts +2 -2
  28. package/src/interaction/plugins/auto.ts +2 -2
  29. package/src/logger/logger.ts +3 -5
  30. package/src/plugins/loader.ts +36 -9
  31. package/src/routing/batch-route.ts +32 -0
  32. package/src/routing/index.ts +1 -0
  33. package/src/routing/loader.ts +7 -0
  34. package/src/utils/path-security.ts +56 -0
  35. package/src/verification/executor.ts +6 -13
  36. package/test/integration/plugins/config-resolution.test.ts +3 -3
  37. package/test/integration/plugins/loader.test.ts +3 -1
  38. package/test/integration/precheck-integration.test.ts +18 -11
  39. package/test/integration/security-loader.test.ts +83 -0
  40. package/test/unit/formatters.test.ts +2 -3
  41. package/test/unit/hooks/shell-security.test.ts +40 -0
  42. package/test/unit/utils/path-security.test.ts +47 -0
  43. package/src/execution/lifecycle/run-lifecycle.ts +0 -312
  44. package/test/unit/run-lifecycle.test.ts +0 -140
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateModulePath } from "../../../src/utils/path-security";
3
+ import { resolve } from "node:path";
4
+
5
+ describe("path-security utility", () => {
6
+ const projectRoot = "/home/project";
7
+ const globalRoot = "/home/global";
8
+ const roots = [projectRoot, globalRoot];
9
+
10
+ test("allows relative path within project root", () => {
11
+ // Relative paths are resolved relative to the first allowed root by our validator
12
+ const result = validateModulePath("./plugins/my-plugin.ts", roots);
13
+ expect(result.valid).toBe(true);
14
+ expect(result.absolutePath).toBe(resolve(projectRoot, "plugins/my-plugin.ts"));
15
+ });
16
+
17
+ test("allows absolute path within global root", () => {
18
+ const result = validateModulePath("/home/global/plugins/my-plugin.ts", roots);
19
+ expect(result.valid).toBe(true);
20
+ expect(result.absolutePath).toBe("/home/global/plugins/my-plugin.ts");
21
+ });
22
+
23
+ test("blocks traversal out of root (../)", () => {
24
+ // resolve handles the ../ then our startsWith check fails
25
+ const result = validateModulePath("../../etc/passwd", roots);
26
+ expect(result.valid).toBe(false);
27
+ expect(result.error).toContain("outside allowed roots");
28
+ });
29
+
30
+ test("blocks absolute path outside roots", () => {
31
+ const result = validateModulePath("/usr/bin/node", roots);
32
+ expect(result.valid).toBe(false);
33
+ expect(result.error).toContain("outside allowed roots");
34
+ });
35
+
36
+ test("handles root itself", () => {
37
+ const result = validateModulePath("/home/project", roots);
38
+ expect(result.valid).toBe(true);
39
+ expect(result.absolutePath).toBe("/home/project");
40
+ });
41
+
42
+ test("blocks empty path", () => {
43
+ const result = validateModulePath("", roots);
44
+ expect(result.valid).toBe(false);
45
+ expect(result.error).toContain("empty");
46
+ });
47
+ });
@@ -1,312 +0,0 @@
1
- /**
2
- * Run Lifecycle — Setup & Teardown Logic
3
- *
4
- * Encapsulates the bookend operations for a nax execution run:
5
- * - Setup: lock acquisition, PRD loading, plugin initialization, reporter setup
6
- * - Teardown: metrics computation, final status write, lock release, plugin cleanup
7
- */
8
-
9
- import * as os from "node:os";
10
- import path from "node:path";
11
- import { getAgent } from "../../agents";
12
- import type { NaxConfig } from "../../config";
13
- import {
14
- AgentNotFoundError,
15
- AgentNotInstalledError,
16
- LockAcquisitionError,
17
- StoryLimitExceededError,
18
- } from "../../errors";
19
- import { type LoadedHooksConfig, fireHook } from "../../hooks";
20
- import { getSafeLogger } from "../../logger";
21
- import { type StoryMetrics, saveRunMetrics } from "../../metrics";
22
- import { loadPlugins } from "../../plugins/loader";
23
- import type { PluginRegistry } from "../../plugins/registry";
24
- import type { PRD } from "../../prd";
25
- import { countStories, isComplete, isStalled, loadPRD } from "../../prd";
26
- import { clearCache as clearLlmCache, routeBatch as llmRouteBatch } from "../../routing/strategies/llm";
27
- import { type StoryBatch, precomputeBatchPlan } from "../batching";
28
- import { acquireLock, getAllReadyStories, hookCtx, releaseLock } from "../helpers";
29
- import type { StatusWriter } from "../status-writer";
30
-
31
- /** Setup result containing initialized state */
32
- export interface SetupResult {
33
- prd: PRD;
34
- pluginRegistry: PluginRegistry;
35
- batchPlan: StoryBatch[];
36
- }
37
-
38
- /** Teardown options */
39
- export interface TeardownOptions {
40
- runId: string;
41
- feature: string;
42
- startedAt: string;
43
- prd: PRD;
44
- allStoryMetrics: StoryMetrics[];
45
- totalCost: number;
46
- storiesCompleted: number;
47
- startTime: number;
48
- workdir: string;
49
- pluginRegistry: PluginRegistry;
50
- statusWriter: StatusWriter;
51
- iterations: number;
52
- }
53
-
54
- /**
55
- * Run lifecycle manager — handles setup and teardown for nax execution
56
- */
57
- export class RunLifecycle {
58
- constructor(
59
- private readonly prdPath: string,
60
- private readonly workdir: string,
61
- private readonly config: NaxConfig,
62
- private readonly hooks: LoadedHooksConfig,
63
- private readonly feature: string,
64
- private readonly dryRun: boolean,
65
- private readonly useBatch: boolean,
66
- private readonly statusWriter: StatusWriter,
67
- private readonly runId: string,
68
- private readonly startedAt: string,
69
- ) {}
70
-
71
- /**
72
- * Setup: Acquire lock, load PRD, initialize plugins, setup reporters
73
- */
74
- async setup(): Promise<SetupResult> {
75
- const logger = getSafeLogger();
76
-
77
- // Acquire lock to prevent concurrent execution
78
- const lockAcquired = await acquireLock(this.workdir);
79
- if (!lockAcquired) {
80
- logger?.error("execution", "Another nax process is already running in this directory");
81
- logger?.error("execution", "If you believe this is an error, remove nax.lock manually");
82
- throw new LockAcquisitionError(this.workdir);
83
- }
84
-
85
- // Load plugins
86
- const globalPluginsDir = path.join(os.homedir(), ".nax", "plugins");
87
- const projectPluginsDir = path.join(this.workdir, "nax", "plugins");
88
- const configPlugins = this.config.plugins || [];
89
- const pluginRegistry = await loadPlugins(globalPluginsDir, projectPluginsDir, configPlugins, this.workdir);
90
- const reporters = pluginRegistry.getReporters();
91
-
92
- logger?.info("plugins", `Loaded ${pluginRegistry.plugins.length} plugins`, {
93
- plugins: pluginRegistry.plugins.map((p) => ({ name: p.name, version: p.version, provides: p.provides })),
94
- });
95
-
96
- // Log run start
97
- const routingMode = this.config.routing.llm?.mode ?? "hybrid";
98
- logger?.info("run.start", `Starting feature: ${this.feature}`, {
99
- runId: this.runId,
100
- feature: this.feature,
101
- workdir: this.workdir,
102
- dryRun: this.dryRun,
103
- useBatch: this.useBatch,
104
- routingMode,
105
- });
106
-
107
- // Fire on-start hook
108
- await fireHook(this.hooks, "on-start", hookCtx(this.feature), this.workdir);
109
-
110
- // Check agent installation before starting
111
- const agent = getAgent(this.config.autoMode.defaultAgent);
112
- if (!agent) {
113
- logger?.error("execution", "Agent not found", {
114
- agent: this.config.autoMode.defaultAgent,
115
- });
116
- throw new AgentNotFoundError(this.config.autoMode.defaultAgent);
117
- }
118
-
119
- const installed = await agent.isInstalled();
120
- if (!installed) {
121
- logger?.error("execution", "Agent is not installed or not in PATH", {
122
- agent: this.config.autoMode.defaultAgent,
123
- binary: agent.binary,
124
- });
125
- logger?.error("execution", "Please install the agent and try again");
126
- throw new AgentNotInstalledError(this.config.autoMode.defaultAgent, agent.binary);
127
- }
128
-
129
- // Load PRD
130
- const prd = await loadPRD(this.prdPath);
131
- const counts = countStories(prd);
132
-
133
- // Status write point: run started
134
- this.statusWriter.setPrd(prd);
135
- this.statusWriter.setRunStatus("running");
136
- this.statusWriter.setCurrentStory(null);
137
- await this.statusWriter.update(0, 0);
138
-
139
- // Update reporters with correct totalStories count
140
- for (const reporter of reporters) {
141
- if (reporter.onRunStart) {
142
- try {
143
- await reporter.onRunStart({
144
- runId: this.runId,
145
- feature: this.feature,
146
- totalStories: counts.total,
147
- startTime: this.startedAt,
148
- });
149
- } catch (error) {
150
- logger?.warn("plugins", `Reporter '${reporter.name}' onRunStart failed`, { error });
151
- }
152
- }
153
- }
154
-
155
- // MEM-1: Validate story count doesn't exceed limit
156
- if (counts.total > this.config.execution.maxStoriesPerFeature) {
157
- logger?.error("execution", "Feature exceeds story limit", {
158
- totalStories: counts.total,
159
- limit: this.config.execution.maxStoriesPerFeature,
160
- });
161
- logger?.error("execution", "Split this feature into smaller features or increase maxStoriesPerFeature in config");
162
- throw new StoryLimitExceededError(counts.total, this.config.execution.maxStoriesPerFeature);
163
- }
164
-
165
- logger?.info("execution", `Starting ${this.feature}`, {
166
- totalStories: counts.total,
167
- doneStories: counts.passed,
168
- pendingStories: counts.pending,
169
- batchingEnabled: this.useBatch,
170
- });
171
-
172
- // Clear LLM routing cache at start of new run
173
- clearLlmCache();
174
-
175
- // PERF-1: Precompute batch plan once from ready stories
176
- let batchPlan: StoryBatch[] = [];
177
- if (this.useBatch) {
178
- const readyStories = getAllReadyStories(prd);
179
- batchPlan = precomputeBatchPlan(readyStories, 4);
180
-
181
- // Initial batch routing
182
- const mode = this.config.routing.llm?.mode ?? "hybrid";
183
- if (this.config.routing.strategy === "llm" && mode !== "per-story" && readyStories.length > 0) {
184
- try {
185
- logger?.debug("routing", "LLM batch routing: routing", { storyCount: readyStories.length, mode });
186
- await llmRouteBatch(readyStories, { config: this.config });
187
- logger?.debug("routing", "LLM batch routing complete", { label: "routing" });
188
- } catch (err) {
189
- logger?.warn("routing", "LLM batch routing failed, falling back to individual routing", {
190
- error: (err as Error).message,
191
- label: "routing",
192
- });
193
- }
194
- }
195
- }
196
-
197
- return {
198
- prd,
199
- pluginRegistry,
200
- batchPlan,
201
- };
202
- }
203
-
204
- /**
205
- * Teardown: Compute final metrics, write final status, release lock, cleanup plugins
206
- */
207
- async teardown(options: TeardownOptions): Promise<void> {
208
- const logger = getSafeLogger();
209
- const {
210
- runId,
211
- feature,
212
- startedAt,
213
- prd,
214
- allStoryMetrics,
215
- totalCost,
216
- storiesCompleted,
217
- startTime,
218
- workdir,
219
- pluginRegistry,
220
- statusWriter,
221
- iterations,
222
- } = options;
223
-
224
- const durationMs = Date.now() - startTime;
225
-
226
- // Save run metrics
227
- const runCompletedAt = new Date().toISOString();
228
- const runMetrics = {
229
- runId,
230
- feature,
231
- startedAt,
232
- completedAt: runCompletedAt,
233
- totalCost,
234
- totalStories: allStoryMetrics.length,
235
- storiesCompleted,
236
- storiesFailed: countStories(prd).failed,
237
- totalDurationMs: durationMs,
238
- stories: allStoryMetrics,
239
- };
240
-
241
- await saveRunMetrics(workdir, runMetrics);
242
-
243
- // Log run completion
244
- const finalCounts = countStories(prd);
245
-
246
- // Prepare per-story metrics summary
247
- const storyMetricsSummary = allStoryMetrics.map((sm) => ({
248
- storyId: sm.storyId,
249
- complexity: sm.complexity,
250
- modelTier: sm.modelTier,
251
- modelUsed: sm.modelUsed,
252
- attempts: sm.attempts,
253
- finalTier: sm.finalTier,
254
- success: sm.success,
255
- cost: sm.cost,
256
- durationMs: sm.durationMs,
257
- firstPassSuccess: sm.firstPassSuccess,
258
- }));
259
-
260
- logger?.info("run.complete", "Feature execution completed", {
261
- runId,
262
- feature,
263
- success: isComplete(prd),
264
- iterations,
265
- totalStories: finalCounts.total,
266
- storiesCompleted,
267
- storiesFailed: finalCounts.failed,
268
- storiesPending: finalCounts.pending,
269
- totalCost,
270
- durationMs,
271
- storyMetrics: storyMetricsSummary,
272
- });
273
-
274
- // Emit onRunEnd to reporters
275
- const reporters = pluginRegistry.getReporters();
276
- for (const reporter of reporters) {
277
- if (reporter.onRunEnd) {
278
- try {
279
- await reporter.onRunEnd({
280
- runId,
281
- totalDurationMs: durationMs,
282
- totalCost,
283
- storySummary: {
284
- completed: storiesCompleted,
285
- failed: finalCounts.failed,
286
- skipped: finalCounts.skipped,
287
- paused: finalCounts.paused,
288
- },
289
- });
290
- } catch (error) {
291
- logger?.warn("plugins", `Reporter '${reporter.name}' onRunEnd failed`, { error });
292
- }
293
- }
294
- }
295
-
296
- // Status write point: run end
297
- statusWriter.setPrd(prd);
298
- statusWriter.setCurrentStory(null);
299
- statusWriter.setRunStatus(isComplete(prd) ? "completed" : isStalled(prd) ? "stalled" : "running");
300
- await statusWriter.update(totalCost, iterations);
301
-
302
- // Teardown plugins
303
- try {
304
- await pluginRegistry.teardownAll();
305
- } catch (error) {
306
- logger?.warn("plugins", "Plugin teardown failed", { error });
307
- }
308
-
309
- // Always release lock, even if execution fails
310
- await releaseLock(workdir);
311
- }
312
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * Tests for src/execution/run-lifecycle.ts
3
- *
4
- * Covers: RunLifecycle teardown critical paths
5
- */
6
-
7
- import { describe, expect, it, mock } from "bun:test";
8
- import type { NaxConfig } from "../../src/config";
9
- import { RunLifecycle } from "../../src/execution/lifecycle";
10
- import type { LoadedHooksConfig } from "../../src/hooks";
11
-
12
- // ─────────────────────────────────────────────────────────────────────────────
13
- // Test fixtures
14
- // ─────────────────────────────────────────────────────────────────────────────
15
-
16
- const mockConfig: NaxConfig = {
17
- autoMode: { defaultAgent: "claude" },
18
- routing: { strategy: "complexity", llm: { mode: "hybrid" } },
19
- execution: {
20
- maxStoriesPerFeature: 100,
21
- sessionTimeoutSeconds: 600,
22
- maxIterations: 50,
23
- costLimitUSD: 10.0,
24
- },
25
- models: {
26
- fast: { provider: "anthropic", name: "claude-3-haiku-20240307" },
27
- balanced: { provider: "anthropic", name: "claude-3-5-sonnet-20241022" },
28
- powerful: { provider: "anthropic", name: "claude-opus-4-20250514" },
29
- },
30
- tierOrder: [
31
- { tier: "fast", attempts: 3 },
32
- { tier: "balanced", attempts: 2 },
33
- { tier: "powerful", attempts: 1 },
34
- ],
35
- plugins: [],
36
- tdd: { enabled: true },
37
- } as NaxConfig;
38
-
39
- const mockHooks: LoadedHooksConfig = {
40
- "on-start": [],
41
- "on-story-start": [],
42
- "on-story-end": [],
43
- "on-error": [],
44
- "on-escalate": [],
45
- "on-end": [],
46
- };
47
-
48
- const mockStatusWriter = {
49
- setPrd: mock(() => {}),
50
- setRunStatus: mock(() => {}),
51
- setCurrentStory: mock(() => {}),
52
- update: mock(async () => {}),
53
- };
54
-
55
- // ─────────────────────────────────────────────────────────────────────────────
56
- // RunLifecycle.teardown()
57
- // ─────────────────────────────────────────────────────────────────────────────
58
-
59
- describe("RunLifecycle.teardown", () => {
60
- it("calls plugin teardownAll during teardown", async () => {
61
- const teardownAllMock = mock(async () => {});
62
- const mockPluginRegistry = {
63
- teardownAll: teardownAllMock,
64
- plugins: [],
65
- getReporters: () => [],
66
- } as any;
67
-
68
- const lifecycle = new RunLifecycle(
69
- "/tmp/prd.json",
70
- "/tmp",
71
- mockConfig,
72
- mockHooks,
73
- "test-feature",
74
- false,
75
- false,
76
- // @ts-expect-error - partial mock
77
- mockStatusWriter,
78
- "run-001",
79
- new Date().toISOString(),
80
- );
81
-
82
- await lifecycle.teardown({
83
- runId: "run-001",
84
- feature: "test-feature",
85
- startedAt: new Date().toISOString(),
86
- prd: { feature: "test-feature", userStories: [] },
87
- allStoryMetrics: [],
88
- totalCost: 0,
89
- storiesCompleted: 0,
90
- startTime: Date.now(),
91
- workdir: "/tmp",
92
- pluginRegistry: mockPluginRegistry,
93
- statusWriter: mockStatusWriter as any,
94
- iterations: 0,
95
- });
96
-
97
- expect(teardownAllMock).toHaveBeenCalledTimes(1);
98
- });
99
-
100
- it("updates status writer during teardown", async () => {
101
- const mockPluginRegistry = {
102
- teardownAll: async () => {},
103
- plugins: [],
104
- getReporters: () => [],
105
- } as any;
106
-
107
- const lifecycle = new RunLifecycle(
108
- "/tmp/prd.json",
109
- "/tmp",
110
- mockConfig,
111
- mockHooks,
112
- "test-feature",
113
- false,
114
- false,
115
- // @ts-expect-error - partial mock
116
- mockStatusWriter,
117
- "run-001",
118
- new Date().toISOString(),
119
- );
120
-
121
- await lifecycle.teardown({
122
- runId: "run-001",
123
- feature: "test-feature",
124
- startedAt: new Date().toISOString(),
125
- prd: { feature: "test-feature", userStories: [] },
126
- allStoryMetrics: [],
127
- totalCost: 1.5,
128
- storiesCompleted: 5,
129
- startTime: Date.now(),
130
- workdir: "/tmp",
131
- pluginRegistry: mockPluginRegistry,
132
- statusWriter: mockStatusWriter as any,
133
- iterations: 10,
134
- });
135
-
136
- expect(mockStatusWriter.setPrd).toHaveBeenCalled();
137
- expect(mockStatusWriter.setRunStatus).toHaveBeenCalled();
138
- expect(mockStatusWriter.update).toHaveBeenCalled();
139
- });
140
- });