@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.
- package/.gitlab-ci.yml +3 -3
- package/CHANGELOG.md +7 -0
- package/docs/ROADMAP.md +2 -1
- package/nax/features/nax-compliance/prd.json +52 -0
- package/nax/features/nax-compliance/progress.txt +1 -0
- package/nax/features/v0.19.0-hardening/plan.md +7 -0
- package/nax/features/v0.19.0-hardening/prd.json +84 -0
- package/nax/features/v0.19.0-hardening/progress.txt +7 -0
- package/nax/features/v0.19.0-hardening/spec.md +18 -0
- package/nax/features/v0.19.0-hardening/tasks.md +8 -0
- package/nax/status.json +27 -0
- package/package.json +2 -2
- package/src/acceptance/fix-generator.ts +6 -2
- package/src/acceptance/generator.ts +3 -1
- package/src/acceptance/types.ts +3 -1
- package/src/agents/claude-plan.ts +6 -5
- package/src/agents/claude.ts +19 -9
- package/src/cli/analyze.ts +1 -0
- package/src/cli/init.ts +7 -6
- package/src/config/defaults.ts +1 -0
- package/src/config/types.ts +2 -0
- package/src/context/injector.ts +18 -18
- package/src/execution/crash-recovery.ts +7 -10
- package/src/execution/lifecycle/acceptance-loop.ts +1 -0
- package/src/execution/lifecycle/index.ts +0 -1
- package/src/execution/lifecycle/precheck-runner.ts +1 -1
- package/src/execution/lifecycle/run-setup.ts +14 -14
- package/src/execution/parallel.ts +1 -1
- package/src/execution/runner.ts +1 -19
- package/src/execution/sequential-executor.ts +1 -1
- package/src/hooks/runner.ts +2 -2
- package/src/interaction/plugins/auto.ts +2 -2
- package/src/logger/logger.ts +3 -5
- package/src/plugins/loader.ts +36 -9
- package/src/routing/batch-route.ts +32 -0
- package/src/routing/index.ts +1 -0
- package/src/routing/loader.ts +7 -0
- package/src/tui/hooks/usePty.ts +20 -9
- package/src/utils/path-security.ts +56 -0
- package/src/verification/executor.ts +6 -13
- package/test/integration/plugins/config-resolution.test.ts +3 -3
- package/test/integration/plugins/loader.test.ts +3 -1
- package/test/integration/precheck-integration.test.ts +18 -11
- package/test/integration/security-loader.test.ts +83 -0
- package/test/unit/formatters.test.ts +2 -3
- package/test/unit/hooks/shell-security.test.ts +40 -0
- package/test/unit/utils/path-security.test.ts +47 -0
- package/src/execution/lifecycle/run-lifecycle.ts +0 -312
- 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
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
346
|
-
|
|
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
|
|
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
|
|
package/src/execution/runner.ts
CHANGED
|
@@ -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";
|
package/src/hooks/runner.ts
CHANGED
|
@@ -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) {
|
package/src/logger/logger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/plugins/loader.ts
CHANGED
|
@@ -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,
|
|
121
|
-
const validated = await loadAndValidatePlugin(
|
|
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
|
-
|
|
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 ||
|
|
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: ${
|
|
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: ${
|
|
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
|
+
}
|
package/src/routing/index.ts
CHANGED
package/src/routing/loader.ts
CHANGED
|
@@ -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);
|
package/src/tui/hooks/usePty.ts
CHANGED
|
@@ -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 (!
|
|
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([
|
|
95
|
-
cwd:
|
|
96
|
-
env: { ...process.env, ...
|
|
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: "
|
|
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
|
|
132
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
88
|
+
|
|
89
89
|
let timedOut = false;
|
|
90
90
|
|
|
91
|
-
const timeoutPromise =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
275
|
-
expect(registry.plugins
|
|
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");
|