@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.
- 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/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/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
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);
|
|
@@ -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");
|
|
@@ -114,21 +114,23 @@ describe("Precheck Integration with nax run", () => {
|
|
|
114
114
|
* Helper to read JSONL log and parse precheck entry
|
|
115
115
|
*/
|
|
116
116
|
async function readPrecheckLog(logFilePath: string): Promise<any | null> {
|
|
117
|
-
const
|
|
118
|
-
if (!(
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
117
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
118
|
+
if (!existsSync(logFilePath)) return null;
|
|
121
119
|
|
|
122
|
-
const content =
|
|
123
|
-
|
|
120
|
+
const content = readFileSync(logFilePath, "utf8");
|
|
121
|
+
if (!content.trim()) return null;
|
|
124
122
|
|
|
123
|
+
const lines = content.trim().split("\n");
|
|
125
124
|
for (const line of lines) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return entry;
|
|
125
|
+
try {
|
|
126
|
+
const entry = JSON.parse(line);
|
|
127
|
+
if (entry.type === "precheck") return entry;
|
|
128
|
+
} catch {
|
|
129
|
+
// ignore
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
console.log(`[DEBUG] precheckLog not found. Content=<<<${content}>>>`);
|
|
132
134
|
return null;
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -183,7 +185,8 @@ describe("Precheck Integration with nax run", () => {
|
|
|
183
185
|
expect(result.success).toBe(true);
|
|
184
186
|
|
|
185
187
|
// Verify precheck was NOT logged to JSONL
|
|
186
|
-
|
|
188
|
+
console.log(`[DEBUG] TEST READING FROM: ${logFilePath}`);
|
|
189
|
+
const precheckLog = await readPrecheckLog(logFilePath);
|
|
187
190
|
expect(precheckLog).toBeNull();
|
|
188
191
|
} finally {
|
|
189
192
|
rmSync(nonGitDir, { recursive: true, force: true });
|
|
@@ -225,6 +228,7 @@ describe("Precheck Integration with nax run", () => {
|
|
|
225
228
|
});
|
|
226
229
|
|
|
227
230
|
// Verify precheck was logged to JSONL (AC5)
|
|
231
|
+
console.log(`[DEBUG] TEST READING FROM: ${logFilePath}`);
|
|
228
232
|
const precheckLog = await readPrecheckLog(logFilePath);
|
|
229
233
|
expect(precheckLog).not.toBeNull();
|
|
230
234
|
expect(precheckLog.type).toBe("precheck");
|
|
@@ -302,7 +306,8 @@ describe("Precheck Integration with nax run", () => {
|
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
// Verify precheck failure was logged (AC5)
|
|
305
|
-
|
|
309
|
+
console.log(`[DEBUG] TEST READING FROM: ${logFilePath}`);
|
|
310
|
+
const precheckLog = await readPrecheckLog(logFilePath);
|
|
306
311
|
expect(precheckLog).not.toBeNull();
|
|
307
312
|
expect(precheckLog.passed).toBe(false);
|
|
308
313
|
expect(precheckLog.blockers.length).toBeGreaterThan(0);
|
|
@@ -355,6 +360,7 @@ describe("Precheck Integration with nax run", () => {
|
|
|
355
360
|
expect(result.success).toBe(true);
|
|
356
361
|
|
|
357
362
|
// Verify precheck passed (may have warnings)
|
|
363
|
+
console.log(`[DEBUG] TEST READING FROM: ${logFilePath}`);
|
|
358
364
|
const precheckLog = await readPrecheckLog(logFilePath);
|
|
359
365
|
expect(precheckLog).not.toBeNull();
|
|
360
366
|
expect(precheckLog.passed).toBe(true);
|
|
@@ -396,6 +402,7 @@ describe("Precheck Integration with nax run", () => {
|
|
|
396
402
|
});
|
|
397
403
|
|
|
398
404
|
// Verify precheck entry structure
|
|
405
|
+
console.log(`[DEBUG] TEST READING FROM: ${logFilePath}`);
|
|
399
406
|
const precheckLog = await readPrecheckLog(logFilePath);
|
|
400
407
|
expect(precheckLog).not.toBeNull();
|
|
401
408
|
expect(precheckLog.type).toBe("precheck");
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { loadPlugins, _setPluginErrorSink, _resetPluginErrorSink } from "../../src/plugins/loader";
|
|
3
|
+
import { loadCustomStrategy } from "../../src/routing/loader";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import * as fs from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
describe("Loader Security (SEC-1, SEC-2)", () => {
|
|
8
|
+
const projectRoot = "/tmp/nax-sec-test-" + Date.now();
|
|
9
|
+
const projectPluginsDir = resolve(projectRoot, "nax/plugins");
|
|
10
|
+
const globalPluginsDir = resolve(projectRoot, ".nax/plugins");
|
|
11
|
+
|
|
12
|
+
let capturedErrors: string[] = [];
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
await fs.mkdir(projectPluginsDir, { recursive: true });
|
|
16
|
+
await fs.mkdir(globalPluginsDir, { recursive: true });
|
|
17
|
+
capturedErrors = [];
|
|
18
|
+
_setPluginErrorSink((msg: string) => capturedErrors.push(msg));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(projectRoot, { recursive: true, force: true });
|
|
23
|
+
_resetPluginErrorSink();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("SEC-1: Blocks plugin load from outside allowed roots", async () => {
|
|
27
|
+
// Attempt to load from /etc/passwd (outside project/global roots)
|
|
28
|
+
const configPlugins = [{ module: "/etc/passwd", config: {} }];
|
|
29
|
+
|
|
30
|
+
const registry = await loadPlugins(
|
|
31
|
+
globalPluginsDir,
|
|
32
|
+
projectPluginsDir,
|
|
33
|
+
configPlugins,
|
|
34
|
+
projectRoot
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(registry.plugins).toHaveLength(0);
|
|
38
|
+
expect(capturedErrors.some(err => err.includes("Security: Path \"/etc/passwd\" is outside allowed roots"))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("SEC-1: Allows plugin load from project directory", async () => {
|
|
42
|
+
// Create a dummy plugin in project directory
|
|
43
|
+
const pluginPath = resolve(projectPluginsDir, "test-plugin.ts");
|
|
44
|
+
await fs.writeFile(pluginPath, `
|
|
45
|
+
export default {
|
|
46
|
+
name: "test-plugin",
|
|
47
|
+
version: "1.0.0",
|
|
48
|
+
provides: ["reporter"],
|
|
49
|
+
setup: async () => {},
|
|
50
|
+
extensions: {
|
|
51
|
+
reporter: {
|
|
52
|
+
name: "test-reporter",
|
|
53
|
+
description: "A test reporter",
|
|
54
|
+
onRunStart: async () => {},
|
|
55
|
+
onStoryComplete: async () => {},
|
|
56
|
+
onRunEnd: async () => {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} as any;
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
const registry = await loadPlugins(
|
|
63
|
+
globalPluginsDir,
|
|
64
|
+
projectPluginsDir,
|
|
65
|
+
[],
|
|
66
|
+
projectRoot
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (registry.plugins.length === 0) { console.log('Captured Errors:', capturedErrors); }
|
|
70
|
+
expect(registry.plugins).toHaveLength(1);
|
|
71
|
+
expect(registry.plugins[0].name).toBe("test-plugin");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("SEC-2: Blocks custom routing strategy from outside project root", async () => {
|
|
75
|
+
// Attempt to load from /etc/passwd (outside project root)
|
|
76
|
+
try {
|
|
77
|
+
await loadCustomStrategy("/etc/passwd", projectRoot);
|
|
78
|
+
throw new Error("Should have failed");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
expect(error.message).toContain("Security: Path \"/etc/passwd\" is outside allowed roots");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -44,9 +44,8 @@ describe("formatConsole", () => {
|
|
|
44
44
|
|
|
45
45
|
const output = formatConsole(entry);
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
expect(bracketCount).toBe(2); // Only timestamp and stage
|
|
47
|
+
// Visibility test instead of raw bracket count (avoid ANSI issues)
|
|
48
|
+
expect(output).not.toContain("[user-auth-001]");
|
|
50
49
|
});
|
|
51
50
|
|
|
52
51
|
test("formats data as pretty JSON on new line", () => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
// Access internal functions for testing
|
|
3
|
+
// @ts-ignore
|
|
4
|
+
import { hasShellOperators, validateHookCommand } from "../../../src/hooks/runner";
|
|
5
|
+
|
|
6
|
+
describe("Hook Shell Security (SEC-3)", () => {
|
|
7
|
+
test("hasShellOperators detects backticks", () => {
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
expect(hasShellOperators("echo `whoami`")).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("hasShellOperators detects pipes and redirects", () => {
|
|
13
|
+
// @ts-ignore
|
|
14
|
+
expect(hasShellOperators("echo hi | grep h")).toBe(true);
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
expect(hasShellOperators("echo hi > file.txt")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("validateHookCommand blocks backtick substitution", () => {
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
expect(() => validateHookCommand("echo `whoami`")).toThrow(/dangerous pattern/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("validateHookCommand blocks $(...) substitution", () => {
|
|
25
|
+
// @ts-ignore
|
|
26
|
+
expect(() => validateHookCommand("echo $(whoami)")).toThrow(/dangerous pattern/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("validateHookCommand blocks eval", () => {
|
|
30
|
+
// @ts-ignore
|
|
31
|
+
expect(() => validateHookCommand("eval 'echo hi'")).toThrow(/dangerous pattern/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("allows safe commands", () => {
|
|
35
|
+
// @ts-ignore
|
|
36
|
+
expect(() => validateHookCommand("echo 'Hello World'")).not.toThrow();
|
|
37
|
+
// @ts-ignore
|
|
38
|
+
expect(() => validateHookCommand("bun test")).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|