Skip to content

stack1:Tfeat: harden config runtime loading and utility flow#160

Open
VX1D wants to merge 8 commits intomichaelshimeles:mainfrom
VX1D:stack/pr1-config-runtime
Open

stack1:Tfeat: harden config runtime loading and utility flow#160
VX1D wants to merge 8 commits intomichaelshimeles:mainfrom
VX1D:stack/pr1-config-runtime

Conversation

@VX1D
Copy link
Copy Markdown
Contributor

@VX1D VX1D commented Mar 3, 2026

PR1: config runtime

Summary

This PR hardens config loading/writing and runtime config behavior. It focuses on safety, predictable fallback behavior, and integration with shared contracts.

Why this PR exists

  • Config files are untrusted input and need stronger guardrails.
  • Runtime config flow had drift and inconsistent behavior across entry points.
  • Later orchestration features assume config state is deterministic.

What it adds

  • Config pipeline updates in:
    • cli/src/config/detector.ts
    • cli/src/config/index.ts
    • cli/src/config/loader.ts
    • cli/src/config/writer.ts
  • Validation support in cli/src/engines/validation.ts used by config paths.
  • Utility support for runtime config/transform hooks in:
    • cli/src/utils/hooks.ts
    • cli/src/utils/metrics.ts
    • cli/src/utils/opencode-parser.ts
    • cli/src/utils/resource-manager.ts
    • cli/src/utils/templates.ts
    • cli/src/utils/transform.ts

Security and reliability work

  • Prototype-pollution detection hardened with bounded traversal.
  • Defensive behavior on malformed/complex config data.
  • Better warning/debug logging when falling back to defaults.

Impact on later PRs

  • Stabilizes runtime options consumed by engine/execution layers.
  • Reduces config-related failure cascades in planning/parallel runs.

Validation

  • Included in cumulative stack verification.
  • Stack passes bun run check, bun tsc --noEmit, and bun test in order.

Note

Harden config runtime loading and CLI utilities by validating commands, blocking prototype-pollution keys, and adding centralized resource tracking with periodic cleanup and secret sanitization caps at 1MB

Add ResourceManager with secure temp creation, periodic cleanup, and a singleton accessor; introduce secret scrubbing with bounded regex and 1MB caps across sanitizers, telemetry, and transformers; enforce command and args validation in config loading and CLI command runners; add structured logging with sinks and secret-aware messages; and refactor cleanup and signal handling to reliably terminate child processes and run registered disposers.

📍Where to Start

Start with ResourceManager and its singleton accessor in resource-manager.ts, then review config hardening in loader.ts and command validation in validation.ts.

Macroscope summarized 1f2b42b.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 3, 2026

@VX1D is attempting to deploy a commit to the Goshen Labs Team on Vercel.

A member of the Team first needs to authorize it.

@dosubot
Copy link
Copy Markdown

dosubot bot commented Mar 3, 2026

Related Documentation

1 document(s) may need updating based on files changed in this PR:

Goshen Labs's Space

Agent Skills Injection
View Suggested Changes
@@ -1,18 +1,14 @@
-Ralphy automatically detects skill/playbook directories within a repository to provide AI agents with explicit guidance about available skills. Supported directories include `.agents/skills`, `.cursor/rules`, `.opencode/skills`, `.claude/skills`, `.github/skills`, and `.skills`. When any of these directories are present, Ralphy augments the generated prompt with an "Agent Skills" section, improving context and agent behavior.
+Ralphy automatically detects skill/playbook directories within a repository to provide AI agents with explicit guidance about available skills. Supported directories include `.opencode/skills` and `.claude/skills`. When any of these directories are present, Ralphy augments the generated prompt with an "Agent Skills" section, improving context and agent behavior.
 
 **Detection Mechanism**
 
-Ralphy checks for the existence of six well-known skill directory patterns in the repository's working directory. This is accomplished using a file system existence check. The detection logic is as follows:
+Ralphy checks for the existence of two well-known skill directory patterns in the repository's working directory. This is accomplished using a file system existence check. The detection logic is as follows:
 
 ```typescript
 function detectAgentSkills(workDir: string): string[] {
     const candidates = [
-        join(workDir, ".agents", "skills"),
-        join(workDir, ".cursor", "rules"),
         join(workDir, ".opencode", "skills"),
         join(workDir, ".claude", "skills"),
-        join(workDir, ".github", "skills"),
-        join(workDir, ".skills"),
     ];
     return candidates.filter((p) => existsSync(p));
 }
@@ -26,12 +22,8 @@
 ```
 ## Agent Skills
 This repo includes skill/playbook docs that describe preferred patterns, workflows, or tooling:
-- .agents/skills
-- .cursor/rules
 - .opencode/skills
 - .claude/skills
-- .github/skills
-- .skills
 
 Before you start coding:
 - Read and follow any relevant skill docs from the paths above.
@@ -47,4 +39,4 @@
 
 **Impact on Prompt Context and Agent Behavior**
 
-By surfacing the existence and location of skill/playbook documentation directly in the prompt—including `.github/skills`—Ralphy provides agents with immediate, actionable context about preferred patterns, workflows, and tooling for the repository. This explicit guidance helps agents align their behavior with project-specific conventions and best practices, leading to more relevant, maintainable, and consistent output. For agents like OpenCode, the lazy-loading mechanism further ensures that skills are loaded efficiently and only as needed, reducing overhead and improving responsiveness.
+By surfacing the existence and location of skill/playbook documentation directly in the prompt, Ralphy provides agents with immediate, actionable context about preferred patterns, workflows, and tooling for the repository. This explicit guidance helps agents align their behavior with project-specific conventions and best practices, leading to more relevant, maintainable, and consistent output. For agents like OpenCode, the lazy-loading mechanism further ensures that skills are loaded efficiently and only as needed, reducing overhead and improving responsiveness.

[Accept] [Decline]

Note: You must be authenticated to accept/decline updates.

How did I do? Any feedback?  Join Discord

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Greptile Summary

This PR hardens the config runtime pipeline and adds comprehensive utility infrastructure (structured logging, secret sanitization, resource management, hooks, metrics, templates, and a transform layer). The security intent is solid — prototype-pollution detection, command injection validation, telemetry secret scrubbing, and PRD path traversal prevention are all meaningful improvements.

Key changes:

  • loader.ts: Recursive, bounded prototype-pollution check replaces the previous string-based approach; command validation gates test/lint/build config commands
  • writer.ts: Fire-and-forget appendFile for progress logging, but may lose in-flight writes on signal-triggered exits since cleanup.ts calls process.exit() without draining pending I/O
  • validation.ts: New validateCommand/validateArgs guards with DoS limits; the allowed-character regex permits ../ traversal in path-based tokens — consider explicit rejection
  • sandbox-git.ts: Type-safe optional chaining for mutex release is correct and safe
  • parallel.ts: PRD path now validated through resolveSafeRelativePath; agent-ID closure bug fixed by capturing before async callback
  • ui/logger.ts: Full structured-logging rewrite with console/JSON sinks, level filtering, and per-message secret sanitization
  • telemetry/: All collector paths now run through sanitizeSecrets; webhook timeout correctly cleared in finally
  • New utilities: cleanup.ts, errors.ts, hooks.ts, metrics.ts, opencode-parser.ts, resource-manager.ts, sanitization.ts, templates.ts, transform.ts

Two actionable issues remain:

  1. cli/src/config/writer.ts — signal-exit data loss risk in logTaskProgress
  2. cli/src/engines/validation.ts — allow explicit rejection of ../ path-traversal in command tokens

Confidence Score: 3/5

  • Safe to merge after addressing the progress-write data-loss risk on signal-triggered exits.
  • The PR contains genuinely useful security hardening (prototype-pollution detection, command injection validation, telemetry scrubbing, PRD path protection) and well-written new utilities. However, the removal of flushAllProgressWrites without a replacement drain mechanism means signal-handled exits (SIGINT/SIGTERM) can silently discard in-flight appendFile calls. This is a real reliability concern worth addressing. The path-traversal suggestion in validateCommand is valid defense-in-depth but lower priority since config is already user-editable. Recommend fixing the progress-write data-loss issue before merge.
  • cli/src/config/writer.ts (progress-write data loss on signal exit)

Last reviewed commit: 1f2b42b

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 3, 2026

Additional Comments (5)

cli/src/engines/validation.ts, line 364
validateCommand rejects all commands containing spaces

The validCommandPattern regex does not include spaces ( ), but every real-world test/lint/build command configured by users contains spaces — e.g. "bun test", "npm run lint", "./node_modules/.bin/jest --coverage". Because this pattern is applied to the full command string (not just the binary name), any such command will fail validation and fall back to an empty string "" in loadTestCommand/loadLintCommand/loadBuildCommand.

This is a significant regression: users with valid commands configured in their config.yaml will silently lose them.

	// Allow valid command characters: alphanumeric, hyphen, underscore, dot, forward slash, backslash (Windows), space
	const validCommandPattern = isWindows ? /^[a-zA-Z0-9._\-\\ ]+$/ : /^[a-zA-Z0-9._\-/ ]+$/;

Alternatively, the function should only validate the binary/executable name (before the first space) against the strict character pattern, and then validate arguments separately via validateArgs.


cli/src/utils/resource-manager.ts, line 2228
Non-unique resource ID in trackFile can cause silent collisions

trackFile uses file-${Date.now()} as the resource ID, which is not unique if two files are tracked within the same millisecond — the second call will silently overwrite the first entry in this.resources. Every other resource-creation method in this class (createTempDir, createTempFile, trackProcess, trackMemory) appends a generateSecureId() suffix for uniqueness. This one does not.

		const resourceId = `file-${Date.now()}-${generateSecureId()}`;

cli/src/utils/resource-manager.ts, line 2283
JSON.stringify(data) in trackMemory throws on non-serializable values

JSON.stringify throws a TypeError for any value that is not JSON-serializable — circular references, BigInt values, functions, Symbol keys, etc. Since data: unknown can be anything, callers can easily trigger an unhandled exception here.

		// Estimate memory usage (rough approximation)
		let size = 0;
		try {
			size = JSON.stringify(data).length * 2; // UTF-16 bytes
		} catch {
			// Non-serializable data — skip size estimation
		}

cli/src/utils/metrics.ts, line 1063
Unbounded memory growth in InMemoryMetricsCollector.histogram

Each call to histogram pushes a new value to buckets and then sorts all accumulated values. Over a long-running process, histogramBuckets grows without any bound: both memory consumption and per-call CPU cost are O(n). Consider capping the stored raw values (e.g. keep the last 10 000 observations) or switching to a streaming algorithm (e.g. HDR Histogram, t-Digest) that does not accumulate raw values:

		// Store raw values for histogram calculation (capped to prevent unbounded growth)
		const MAX_HISTOGRAM_SAMPLES = 10000;
		const buckets = this.histogramBuckets.get(bucketKey) || [];
		buckets.push(value);
		if (buckets.length > MAX_HISTOGRAM_SAMPLES) {
			buckets.shift(); // Drop oldest sample
		}
		this.histogramBuckets.set(bucketKey, buckets);

cli/src/config/writer.ts, line 298
flushAllProgressWrites is now a no-op stub but still part of the public API

The async write-batching queue has been removed in favour of synchronous appendFileSync, which is fine. However, flushAllProgressWrites is still exported and still has an async signature. Any downstream caller that relied on this to guarantee persistence before process exit will silently call a do-nothing function with no indication that the semantics have changed. Consider either removing the export entirely (if no callers remain) or adding a JSDoc comment clearly documenting the no-op behaviour:


/** @deprecated Writes are now synchronous; this function is a no-op kept for API compatibility. */
export async function flushAllProgressWrites(): Promise<void> {
	return Promise.resolve();
}

Comment on lines +15 to +19
function debugLog(...args: unknown[]): void {
if (DEBUG || (globalThis as { verboseMode?: boolean }).verboseMode === true) {
logDebug(args.map((a) => String(a)).join(" "));
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium engines/validation.ts:15

debugLog checks DEBUG || verboseMode to decide whether to log, but then calls logDebug, which silently returns when verboseMode is false. Setting RALPHY_DEBUG=true therefore produces no output unless verboseMode is also enabled, bypassing the intended environment-variable override. Consider using console.log directly (or a separate unchecked logger) so DEBUG alone enables logging.

-function debugLog(...args: unknown[]): void {
-	if (DEBUG || (globalThis as { verboseMode?: boolean }).verboseMode === true) {
-		logDebug(args.map((a) => String(a)).join(" "));
-	}
-}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/engines/validation.ts around lines 15-19:

`debugLog` checks `DEBUG || verboseMode` to decide whether to log, but then calls `logDebug`, which silently returns when `verboseMode` is false. Setting `RALPHY_DEBUG=true` therefore produces no output unless `verboseMode` is also enabled, bypassing the intended environment-variable override. Consider using `console.log` directly (or a separate unchecked logger) so `DEBUG` alone enables logging.

Evidence trail:
cli/src/engines/validation.ts lines 15-19: `debugLog` function checks `DEBUG || globalThis.verboseMode` then calls `logDebug()`.
cli/src/ui/logger.ts lines 3, 8-10, 42-46: `logDebug` has its own `verboseMode` check (module-level variable set by `setVerbose()`).
cli/src/cli/commands/run.ts line 32: `setVerbose(options.verbose)` - only sets verboseMode when --verbose flag is passed.
cli/src/cli/commands/task.ts line 23: same pattern.
The env var `RALPHY_DEBUG=true` makes `DEBUG` true, passes `debugLog`'s check, but `logDebug` silently returns because logger.ts's internal `verboseMode` remains false.

Comment on lines +269 to +278
this.metrics.clear();
this.histogramBuckets.clear();
}

private getMetricKey(name: string, labels?: MetricLabels): string {
if (!labels || Object.keys(labels).length === 0) {
return name;
}
const labelStr = Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/metrics.ts:269

getMetricKey builds keys without escaping , or =, so different label sets can collide. Suggest using an unambiguous encoding (e.g., stable-key-order JSON or URL-encoding) when serializing labels.

-	private getMetricKey(name: string, labels?: MetricLabels): string {
-		if (!labels || Object.keys(labels).length === 0) {
-			return name;
-		}
-		const labelStr = Object.entries(labels)
-			.sort(([a], [b]) => a.localeCompare(b))
-			.map(([k, v]) => `${k}=${v}`)
-			.join(",");
-		return `${name}{${labelStr}}`;
-	}
+	private getMetricKey(name: string, labels?: MetricLabels): string {
+		if (!labels || Object.keys(labels).length === 0) {
+			return name;
+		}
+		const labelStr = Object.entries(labels)
+			.sort(([a], [b]) => a.localeCompare(b))
+			.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
+			.join(",");
+		return `${name}{${labelStr}}`;
+	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/metrics.ts around lines 269-278:

`getMetricKey` builds keys without escaping `,` or `=`, so different label sets can collide. Suggest using an unambiguous encoding (e.g., stable-key-order JSON or URL-encoding) when serializing labels.

Evidence trail:
cli/src/utils/metrics.ts lines 269-278 (getMetricKey function), lines 26-28 (MetricLabels interface). The function uses `.map(([k, v]) => `${k}=${v}`).join(",")` without escaping, allowing collisions when label values contain `,` or `=` characters.

Comment on lines 53 to 60
function escapeYaml(value: string | undefined | null): string {
return (value || "").replace(/"/g, '\\"');
if (!value) return "";
// Use YAML library for proper escaping instead of simple quote replacement
// This prevents YAML injection attacks
const serialized = YAML.stringify(value).trim();
// Remove surrounding quotes added by YAML.stringify for simple strings
return serialized.replace(/^"|"$/g, "");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High config/writer.ts:53

escapeYaml now uses YAML.stringify, which can return single-quoted strings (e.g., 'foo"bar') or block scalars. createConfigContent wraps this output in double quotes, so a command like echo "hello" becomes name: "'echo \"hello\"'" — a string literal containing single quotes that corrupts the value when parsed. For multi-line strings, wrapping a block scalar in quotes produces invalid YAML.

 function escapeYaml(value: string | undefined | null): string {
 	if (!value) return "";
-	// Use YAML library for proper escaping instead of simple quote replacement
-	// This prevents YAML injection attacks
-	const serialized = YAML.stringify(value).trim();
-	// Remove surrounding quotes added by YAML.stringify for simple strings
-	return serialized.replace(/^"|"$/g, "");
+	// Escape backslashes and double quotes, then wrap in double quotes
+	const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
+	return `"${escaped}"`;
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/config/writer.ts around lines 53-60:

`escapeYaml` now uses `YAML.stringify`, which can return single-quoted strings (e.g., `'foo"bar'`) or block scalars. `createConfigContent` wraps this output in double quotes, so a command like `echo "hello"` becomes `name: "'echo \"hello\"'"` — a string literal containing single quotes that corrupts the value when parsed. For multi-line strings, wrapping a block scalar in quotes produces invalid YAML.

Evidence trail:
cli/src/config/writer.ts lines 50-57: `escapeYaml` function uses `YAML.stringify(value).trim()` and strips only double quotes with `replace(/^"|"/g, "")`. Single quotes and block scalars are not handled.

cli/src/config/writer.ts lines 15-21: `createConfigContent` wraps `escapeYaml` output in double quotes, e.g., `name: "${escapeYaml(detected.name)}"` and `test: "${escapeYaml(detected.testCmd)}"`.

When `YAML.stringify` returns single-quoted strings (for inputs containing double quotes) or block scalars (for multi-line strings), the wrapping in double quotes produces invalid YAML.

Comment on lines 149 to +158
export function loadLintCommand(workDir = process.cwd()): string {
const config = loadConfig(workDir);
return config?.commands.lint ?? "";
const command = config?.commands.lint ?? "";

if (command && !validateCommand(command)) {
logWarn(`Invalid lint command in config: "${command}". Falling back to default.`);
return "";
}

return command;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium config/loader.ts:149

Suggestion: validateCommand forbids spaces, so multi-word commands in loadTestCommand, loadLintCommand, and loadBuildCommand are treated as invalid and reset to empty strings. Consider splitting the config into command and args and validate only the executable, or relax the validation to allow spaces/common arg syntax to avoid rejecting valid commands.

-	if (command && !validateCommand(command)) {
+	if (command && !validateCommand(command.split(' ')[0])) {
		logWarn(`Invalid lint command in config: "${command}". Falling back to default.`);
		return "";
	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/config/loader.ts around lines 149-158:

Suggestion: `validateCommand` forbids spaces, so multi-word commands in `loadTestCommand`, `loadLintCommand`, and `loadBuildCommand` are treated as invalid and reset to empty strings. Consider splitting the config into `command` and `args` and validate only the executable, or relax the validation to allow spaces/common arg syntax to avoid rejecting valid commands.

Evidence trail:
cli/src/engines/validation.ts lines 50-56: validateCommand uses regex `/^[a-zA-Z0-9._\-/]+$/` which does not include space character. cli/src/config/loader.ts lines 127-139 (loadTestCommand), 149-161 (loadLintCommand), 166-178 (loadBuildCommand): all call validateCommand and return empty string "" when validation fails.

Comment on lines +185 to +200

counter(name: string, value = 1, labels?: MetricLabels): void {
const key = this.getMetricKey(name, labels);
const existing = this.metrics.get(key);

if (existing && existing.type === "counter") {
existing.value = (existing.value as number) + value;
} else {
this.metrics.set(key, {
name,
type: "counter",
description: `Counter metric: ${name}`,
labels,
value,
timestamp: Date.now(),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/metrics.ts:185

When incrementing an existing counter, the code mutates existing.value in-place (line 190) but leaves timestamp untouched. This causes the counter to report its creation time as the timestamp rather than the time of the last increment, unlike gauge and histogram which refresh the timestamp on every call.

-		if (existing && existing.type === "counter") {
-			existing.value = (existing.value as number) + value;
-		} else {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/metrics.ts around lines 185-200:

When incrementing an existing counter, the code mutates `existing.value` in-place (line 190) but leaves `timestamp` untouched. This causes the counter to report its creation time as the timestamp rather than the time of the last increment, unlike `gauge` and `histogram` which refresh the timestamp on every call.

Evidence trail:
cli/src/utils/metrics.ts lines 185-258 at REVIEWED_COMMIT:
- Counter: lines 189-190 show only `existing.value` is mutated, no timestamp update
- Counter creation: line 197 sets `timestamp: Date.now()` only for new counters
- Gauge: line 213 sets `timestamp: Date.now()` on every call via `this.metrics.set()`
- Histogram: line 253 sets `timestamp: Date.now()` on every call via `this.metrics.set()`

Comment on lines +336 to +339
setActiveSpan(span: Span | null): void {
this.activeSpan = span;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/metrics.ts:336

SimpleTracer appends every span to this.spans at line 307, but the array is never cleared or bounded. Since SimpleTracer is the default global tracer, long-running CLI processes will accumulate unbounded memory and eventually crash with OOM. Consider either clearing this.spans when endSpan is called, exposing a flush/clear method, or implementing a fixed-size ring buffer.

 	getSpans(): Span[] {
 		return this.spans;
 	}
+
+	clear(): void {
+		this.spans = [];
+	}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/metrics.ts around lines 336-339:

`SimpleTracer` appends every span to `this.spans` at line 307, but the array is never cleared or bounded. Since `SimpleTracer` is the default global tracer, long-running CLI processes will accumulate unbounded memory and eventually crash with OOM. Consider either clearing `this.spans` when `endSpan` is called, exposing a flush/clear method, or implementing a fixed-size ring buffer.

Evidence trail:
cli/src/utils/metrics.ts lines 285-337 (SimpleTracer class showing spans array initialization at 285, push at 307, endSpan at 311-314 which doesn't remove from array, getSpans at 336-337), cli/src/utils/metrics.ts line 353 (`let globalTracer: Tracer = new SimpleTracer();`), cli/src/utils/metrics.ts lines 131-146 (Tracer interface with no clear() method), cli/src/index.ts line 46 (`await runLoop(options)` for long-running PRD loop mode), cli/src/cli/commands/run.ts (runLoop implementation showing task processing loop)

Comment on lines +113 to +119
export const OpenCodeEventSchema = z.union([
ToolUseEventSchema,
StepStartEventSchema,
StepFinishEventSchema,
TextEventSchema,
ErrorEventSchema,
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/opencode-parser.ts:113

When a JSON object has type: "plan" or type: "thinking", the event is returned with isValid: false and dropped. detectEventType lacks cases for these types and returns "unknown", and OpenCodeEventSchema doesn't include schemas for them, so validation fails. Consider adding schemas for plan and thinking events to the union and updating detectEventType to handle them.

+export const PlanEventSchema = z.object({
+	type: z.literal("plan"),
+});
+
+export const ThinkingEventSchema = z.object({
+	type: z.literal("thinking"),
+});
+
 export const OpenCodeEventSchema = z.union([
 	ToolUseEventSchema,
 	StepStartEventSchema,
 	StepFinishEventSchema,
 	TextEventSchema,
 	ErrorEventSchema,
+	PlanEventSchema,
+	ThinkingEventSchema,
 ]);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/opencode-parser.ts around lines 113-119:

When a JSON object has `type: "plan"` or `type: "thinking"`, the event is returned with `isValid: false` and dropped. `detectEventType` lacks cases for these types and returns `"unknown"`, and `OpenCodeEventSchema` doesn't include schemas for them, so validation fails. Consider adding schemas for `plan` and `thinking` events to the union and updating `detectEventType` to handle them.

Evidence trail:
cli/src/utils/opencode-parser.ts line 113: `EventType` includes `"plan" | "thinking"` in the union type
cli/src/utils/opencode-parser.ts lines 202-223: `detectEventType()` switch statement has no cases for `plan` or `thinking`, returns `"unknown"` for them
cli/src/utils/opencode-parser.ts lines 95-101: `OpenCodeEventSchema` union has no schema for plan or thinking events
cli/src/utils/opencode-parser.ts lines 307-321: `parseOpenCodeLine()` returns `isValid: false` when eventType is "unknown" and Zod validation fails
cli/src/utils/opencode-parser.ts lines 351-353: `filterEvents()` drops events where `!parsed.isValid`

Comment on lines +289 to +292
private spans: Span[] = [];
private activeSpan: Span | null = null;
private idCounter = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/metrics.ts:289

Span context propagation is fragile: startSpan doesn’t inherit the current span and activeSpan is a global mutable field, so concurrent withSpan calls corrupt parent/child links. Suggest using AsyncLocalStorage for per-async context and defaulting startSpan to the current span when parentContext is absent.

	startSpan(name: string, parentContext?: SpanContext, attributes?: Record<string, unknown>): Span {
		const spanId = this.generateId();
-		const traceId = parentContext?.traceId || this.generateId();
+		const traceId = parentContext?.traceId || this.activeSpan?.context.traceId || this.generateId();
+		const parentSpanId = parentContext?.spanId || (traceId === this.activeSpan?.context.traceId ? this.activeSpan?.context.spanId : undefined);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/metrics.ts around lines 289-292:

Span context propagation is fragile: `startSpan` doesn’t inherit the current span and `activeSpan` is a global mutable field, so concurrent `withSpan` calls corrupt parent/child links. Suggest using `AsyncLocalStorage` for per-async context and defaulting `startSpan` to the current span when `parentContext` is absent.

Evidence trail:
cli/src/utils/metrics.ts lines 286-304 (SimpleTracer class with activeSpan field and startSpan method that doesn't inherit from activeSpan), lines 353 (global tracer instance), lines 407-409 (startSpan convenience function), lines 421-441 (withSpan function with save/restore pattern for activeSpan that breaks under concurrent async execution).

register<T extends HookContext>(
hookName: HookName,
handler: HookHandler<T>,
options?: { priority?: number; name?: string },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/hooks.ts:119

Suggestion: avoid mutating the hooks array while executing. Replace the array on register (instead of push/sort) and iterate over a snapshot in execute so adds/removals during execution can’t reorder or skip handlers.

Also found in 1 other location(s)

cli/src/utils/transform.ts:111

The execute method iterates directly over the mutable this.transformers array using an async for...of loop. If the transformers array is modified (e.g., a transformer is unregistered) while the execution is paused at the await entry.transformer(...) line, the loop iterator will desynchronize from the array contents. This causes the loop to skip the next transformer in the list when execution resumes, potentially bypassing critical transformations. The loop should iterate over a shallow copy (e.g., [...this.transformers]) to ensure stability.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/hooks.ts around line 119:

Suggestion: avoid mutating the `hooks` array while executing. Replace the array on `register` (instead of `push`/`sort`) and iterate over a snapshot in `execute` so adds/removals during execution can’t reorder or skip handlers.

Evidence trail:
cli/src/utils/hooks.ts lines 106-128 (register method uses push/sort), lines 133-155 (execute method iterates directly over array reference without snapshot), lines 119-125 (unregister function uses splice on same array)

Also found in 1 other location(s):
- cli/src/utils/transform.ts:111 -- The `execute` method iterates directly over the mutable `this.transformers` array using an async `for...of` loop. If the `transformers` array is modified (e.g., a transformer is unregistered) while the execution is paused at the `await entry.transformer(...)` line, the loop iterator will desynchronize from the array contents. This causes the loop to skip the next transformer in the list when execution resumes, potentially bypassing critical transformations. The loop should iterate over a shallow copy (e.g., `[...this.transformers]`) to ensure stability.

Comment on lines +138 to +146
return () => {
const hooks = this.hooks.get(hookName) || [];
const index = hooks.findIndex((h) => h.name === entry.name);
if (index !== -1) {
hooks.splice(index, 1);
logDebugContext("Hooks", `Unregistered hook: ${hookName} (${entry.name})`);
}
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low utils/hooks.ts:138

getRegisteredHooks returns hook names that have no active listeners after all hooks are unregistered. When the last hook is removed via the unregister function (lines 138-145), the entry is deleted from the array but the empty array remains in the Map, so the key is still returned. This creates an inconsistency where getRegisteredHooks reports a hook as present but hasHooks returns false. Consider deleting the Map key when the array becomes empty.

 		const hooks = this.hooks.get(hookName) || [];
 		const index = hooks.findIndex((h) => h.name === entry.name);
 		if (index !== -1) {
 			hooks.splice(index, 1);
+ 			if (hooks.length === 0) {
+ 				this.hooks.delete(hookName);
+ 			}
 			logDebugContext("Hooks", `Unregistered hook: ${hookName} (${entry.name})`);
 		}
 	};
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/utils/hooks.ts around lines 138-146:

`getRegisteredHooks` returns hook names that have no active listeners after all hooks are unregistered. When the last hook is removed via the unregister function (lines 138-145), the entry is deleted from the array but the empty array remains in the Map, so the key is still returned. This creates an inconsistency where `getRegisteredHooks` reports a hook as present but `hasHooks` returns `false`. Consider deleting the Map key when the array becomes empty.

Evidence trail:
cli/src/utils/hooks.ts lines 138-145 (unregister function uses splice but doesn't delete Map key), lines 178-181 (hasHooks checks array length > 0), lines 205-207 (getRegisteredHooks returns all Map keys). Commit: REVIEWED_COMMIT

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (5)

cli/src/config/writer.ts, line 60
Stripping YAML quotes defeats injection protection

YAML.stringify deliberately wraps certain strings in quotes to preserve their meaning and prevent injection. Removing those quotes with .replace(/^"|"$/g, "") re-introduces the vulnerability you are trying to prevent.

Examples:

  • Input "yes"YAML.stringify"yes" → after strip → yes → parsed as boolean, not string
  • Input "test: value"YAML.stringify"test: value" → after strip → test: value → causes YAML parse error

A safe fix is to return the trimmed, quoted result directly:

function escapeYaml(value: string | undefined | null): string {
	if (!value) return '""';
	return YAML.stringify(value).trim();
}

cli/src/engines/validation.ts, line 123
validateArgs omits redirection operator check present in validateCommand

validateCommand checks for [<>] (I/O redirection) at line 61, but validateArgs does not. After tokenization, a quoted argument like "> /etc/passwd" becomes the string > /etc/passwd, which would pass validation here.

If the validated command and args are later assembled into a shell string (e.g., via exec or execSync), an undetected > or < in an argument can redirect output to arbitrary files, creating a command-injection vector.

Add the missing redirection check to match validateCommand:

	const dangerousPatterns = [
		/[;&|`<>]/, // Shell metacharacters (add <> to match validateCommand)
		/\$\{/, // Variable expansion
		/\$\(/, // Command substitution
		/`/, // Backtick substitution
		/\|\|/, // OR operator
		/&&/, // AND operator
	];

cli/src/utils/resource-manager.ts, line 339
Unawaited async cleanup call can cause race conditions

this.cleanup() returns a Promise<void> but is called without await in the synchronous checkMemoryUsage method. Although cleanup uses Promise.allSettled internally (so it doesn't throw), fire-and-forget patterns make it impossible to know when—or whether—cleanup has completed. This can cause race conditions if resources are reallocated before the prior cleanup finishes.

Make the call explicit by either:

  1. Making checkMemoryUsage async and awaiting the call
  2. Explicitly voiding the result to signal intentional fire-and-forget:
	private checkMemoryUsage(): void {
		const stats = this.getStats();
		if (stats.totalDiskUsage > this.maxMemoryUsage) {
			// Fire-and-forget: intentional; cleanup errors are settled internally
			void this.cleanup({ maxAge: 10 * 60 * 1000 });
		}
	}

cli/src/engines/validation.ts, line 62
Redundant patterns in dangerousPatterns array

The character class /[;&|$]/ (line 54) already matches pipes (|) and backticks (`` ``). The subsequent patterns on lines 58–59 that explicitly match //` and `/||/` will never be the first match on those characters—they're fully subsumed by the first pattern. Similarly, `/&&/` (line 60) is redundant because a single `&` is already caught.

This is functionally safe (over-restriction is fine for security), but the redundancy obscures the intent. If you ever need to allow single | while blocking ||, these redundancies would break that intent. Consider consolidating:

	const dangerousPatterns = [
		/[;&|`$<>]/, // Shell metacharacters (consolidate all single chars here)
		/\$\{/, // Variable expansion
		/\$\(/, // Command substitution
		/\|\|/, // OR operator (explicit for clarity)
		/&&/, // AND operator (explicit for clarity)
	];

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


cli/src/utils/resource-manager.ts, line 130
String size check uses character count, not byte count

Both branches of the ternary are identical (content.length). For strings, .length counts UTF-16 code units, not bytes—a string with multi-byte Unicode characters can be significantly larger on disk than content.length suggests. For a Buffer, .length is byte-accurate, but for a string you should use Buffer.byteLength(content, 'utf8') to enforce a real byte limit.

			const contentSize = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.length;

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (10)

cli/src/utils/transform.ts, line 94
Built-in transformers (including sanitizeSecretsTransformer) are registered to the global pipeline instance, not to the calling instance. When execute() is called on a non-global TransformPipeline with an empty transformers list, the built-ins are registered to globalTransformPipeline instead. This leaves the local instance empty and bypasses secret redaction entirely — a critical security issue for any code using non-global pipelines.

async execute(input: string, context: TransformContext): Promise<TransformResult> {
	// Lazy-register built-in transformers only on the global pipeline to avoid circular dependency
	if (this === globalTransformPipeline && !transformersRegistered) {
		registerBuiltInTransformers();
	}

Alternatively, remove lazy-registration from execute and require callers to explicitly initialize pipelines via direct registration.


cli/src/utils/transform.ts, line 85
The unregister callback uses findIndex((t) => t.name === name) to locate the transformer to remove. If multiple transformers are registered with the same name, findIndex always returns the first occurrence — so attempting to unregister a later-added transformer silently removes the first one instead. The entry object is already in closure scope, so indexOf(entry) should be used to guarantee the correct instance is removed.

		// Return unregister function
		return () => {
			const index = this.transformers.indexOf(entry);
			if (index !== -1) {
				this.transformers.splice(index, 1);
				logDebugContext("TransformPipeline", `Unregistered transformer: ${name}`);
			}
		};

cli/src/utils/transform.ts, line 238
When input.length > MAX_SANITIZE_INPUT_LENGTH, the function returns a truncated slice without applying any secret-redaction patterns. An attacker who can produce large prompts (e.g., by including large files in context) can ensure their API keys or other secrets appear in the first 1 MB and still be transmitted in plain text. The sanitization patterns should be applied to the working input before or after truncation.

export const sanitizeSecretsTransformer: Transformer = (input) => {
	// Limit input length to prevent ReDoS attacks, but still apply sanitization
	const workingInput =
		input.length > MAX_SANITIZE_INPUT_LENGTH
			? `${input.slice(0, MAX_SANITIZE_INPUT_LENGTH)}\n\n[WARNING: Content truncated due to size limits]`
			: input;

	// All patterns use bounded quantifiers to prevent ReDoS
	const patterns = [
		{ regex: /sk-[a-zA-Z0-9]{48}/g, replacement: "[API_KEY_REDACTED]" },
		// ... rest of patterns
	];

	let result = workingInput;
	for (const { regex, replacement } of patterns) {
		result = result.replace(regex, replacement);
	}

	return result;
};

cli/src/utils/transform.ts, line 43
Transformer is declared as returning string (synchronous), while execute() uses await entry.transformer(...) (line 113). This type mismatch means TypeScript will not catch async transformers returning Promise<string>, and linters may flag await on a non-Promise value. The type should be widened to support both sync and async usage.

export type Transformer = (input: string, context: TransformContext) => string | Promise<string>;

cli/src/utils/transform.ts, line 303
The first branch checks code.includes("import "), which also matches Python (import os) and Java (import java.util.*). Since this branch is evaluated first, Python and Java files with import statements will be wrapped in ```typescript blocks instead of their correct language. The Python-specific else if on line 296 is unreachable for imports.

Reorder the checks from most-specific to least-specific:

		let language = "";
		if (code.includes("import java.") || code.includes("package ")) language = "java";
		else if (code.includes("def ") && (code.includes(":") || code.includes("self "))) language = "python";
		else if (code.includes("import ") || code.includes("export ") || code.includes("const ") || code.includes("let ")) language = "typescript";

cli/src/utils/templates.ts, line 265
The condition if (variable.pattern && context[variable.name]) skips pattern validation for falsy values (0, false, ""). This means a variable with a required pattern (e.g., ^\\d+$) will silently allow an empty string to pass validation. Test for undefined explicitly rather than relying on truthiness.

		if (variable.pattern && context[variable.name] !== undefined) {
			const value = String(context[variable.name]);
			const regex = new RegExp(variable.pattern);
			if (!regex.test(value)) {
				throw new Error(`Variable '${variable.name}' value '${value}' does not match pattern '${variable.pattern}'`);
			}
		}

cli/src/utils/templates.ts, line 321
The in operator (line 312) returns true for inherited prototype properties such as toString, valueOf, constructor, etc. If a template contains {{toString}} or {{constructor}}, the rendered output will include the native function's source code, leaking internal implementation details. Use Object.prototype.hasOwnProperty.call to check only own properties.

	private renderVariables(content: string, context: TemplateContext): string {
		return content.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
			if (Object.prototype.hasOwnProperty.call(context, varName)) {
				const value = context[varName];
				if (Array.isArray(value)) {
					return value.join("\n");
				}
				return String(value);
			}
			return match; // Keep original if not found
		});
	}

cli/src/utils/opencode-parser.ts, line 111
TextEventSchema and ErrorEventSchema are missing the sessionID field that is present in all other event schemas (ToolUseEventSchema line 45, StepStartEventSchema line 60, StepFinishEventSchema line 90). Since Zod strips unknown keys by default, any sessionID field in the raw JSON for text or error events will be silently dropped after parsing. Downstream functions like extractSessionIds (line 760) and filterEvents (line 444) will fail to include text and error events in session-specific filtering, resulting in incomplete session reconstructions.

export const TextEventSchema = z.object({
	type: z.literal("text"),
	timestamp: z.number().optional(),
	sessionID: z.string().optional(),
	part: TextPartSchema,
});

export const ErrorEventSchema = z.object({
	type: z.literal("error"),
	timestamp: z.number().optional(),
	sessionID: z.string().optional(),
	error: z
		.object({
			message: z.string(),
		})
		.optional(),
	message: z.string().optional(),
});

cli/src/utils/transform.ts, line 270
The task title (line 263) is interpolated directly into an HTML comment without sanitization. If the title contains the sequence -->, it closes the comment early and the remainder of the title would appear as raw prompt text to the LLM — a prompt injection vulnerability. An adversary-controlled task title could inject arbitrary instructions.

export const addMetadataHeaderTransformer: Transformer = (input, context) => {
	const timestamp = new Date().toISOString();
	const safeTitle = context.task.title.replace(/-->/g, "--\u200B>");  // Zero-width space prevents -->
	const header = `<!--
Task: ${safeTitle}
Engine: ${context.engine}
Timestamp: ${timestamp}
-->

`;
	return header + input;
};

cli/src/engines/validation.ts, line 62
The first pattern /[;&|$]/already matches any single|and&, making the later patterns /||/and/&&/unreachable as independent guards. While blocking still works today (the first pattern prevents both), the redundancy creates a false impression that||and&&` require special handling and can mask future logic errors if the first pattern is ever narrowed. Simplify by removing the redundant entries or documenting their purpose if they are intentional defensive layers.

	const dangerousPatterns = [
		/[;&|`$]/, // Shell metacharacters (including | and &)
		/\$\{/, // Variable expansion
		/\$\(/, // Command substitution
		/`/, // Backtick substitution (redundant with [`] above, but kept for clarity)
		/[<>]/, // Redirection
	];

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (5)

cli/src/engines/validation.ts, line 475
Bare $var injection bypasses validateArgs check

validateCommand blocks any command string containing a bare $ character (via the first pattern /[;&| `$]/), but validateArgsintentionally omits$from its simple metacharacter set — only${and$(are blocked. This means a config argument like$IFS, $PATH, or $HOMEpassesvalidateArgs` unimpeded while the exact same byte in the command binary name is rejected.

This is an asymmetry between the two validation functions that creates a potential injection path. For example: if the validated command is bun and an argument is $IFS;whoami, the $IFS part passes because it is neither ${ nor $(, and ; is already blocked — but a carefully crafted argument such as $(<file) would also be blocked by \$\(. However bare $VAR substitution is not universally blocked and represents an inconsistency worth addressing.

	const dangerousPatterns = [
		/[;&|`$]/, // Shell metacharacters (including bare $ for variable expansion)
		/\$\{/, // Variable expansion
		/\$\(/, // Command substitution
		/`/, // Backtick substitution
		/[<>]/, // Redirection and XML-like payloads
	];

cli/src/config/loader.ts, line 131
hasPrototypePollution errors are silently swallowed as ordinary parse failures

hasPrototypePollution throws two distinct errors — "Config file too complex" and "Config file nesting exceeds safety limits" — when an attacker crafts a deeply nested or very large config object. Both of these exceptions bubble out of the if (hasPrototypePollution(parsed)) check and fall directly into the surrounding catch block, where they are treated identically to a malformed YAML parse error: a logWarn is emitted and RalphyConfigSchema.parse({}) returns the default config.

Consequences:

  1. An adversary who can write the config file can force a predictable fallback-to-defaults by deliberately exceeding the node/depth limits, which may be preferable for them over a specific crafted value.
  2. Operators see "Invalid config file … Falling back to defaults" rather than a clear "security threshold exceeded" message.

Handle hasPrototypePollution errors distinctly before the outer catch, or at minimum detect them inside the catch and log a higher-severity warning:

try {
    const content = readFileSync(configPath, "utf-8");
    const parsed = YAML.parse(content);

    if (hasPrototypePollution(parsed)) {
        logWarn(`Config file at ${configPath} contains prototype-pollution keys — ignoring.`);
        return RalphyConfigSchema.parse({});
    }

    return RalphyConfigSchema.parse(parsed);
} catch (error) {
    // Only ordinary parse/validation errors reach here
    logWarn(`Invalid config file at ${configPath}: ${error}. Falling back to defaults.`);
    logDebug(`Config parse details: ${error}`);
    return RalphyConfigSchema.parse({});
}

cli/src/config/loader.ts, line 129
Double-logging the same error object

Both logWarn and logDebug stringify and log the same error reference. logWarn already embeds ${error} inline, so logDebug produces an identical (or near-identical) line in debug mode. The logDebug call adds no extra information.

Consider logging supplementary context in logDebug (e.g. the stack trace or structured fields) or remove the redundant call:

		logWarn(`Invalid config file at ${configPath}: ${error}. Falling back to defaults.`);
		logDebug(`Config parse stack: ${error instanceof Error ? error.stack : error}`);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


cli/src/utils/resource-manager.ts, line 3088
Identical branches in the size check — Buffer byte length is never used

Both arms of the ternary evaluate to content.length, so the string/Buffer distinction is effectively dead code:

const contentSize = typeof content === "string" ? content.length : content.length;

For a string, content.length counts UTF-16 code units, not bytes. A string of 5 million multi-byte characters would appear to be 5 MB but actually occupy ~10 MB when UTF-8-encoded on disk. For a Buffer, content.length is already the byte count, which is correct.

The size limit guard is therefore inaccurate for string inputs and will never use the correct Buffer.byteLength path:

			const contentSize = typeof content === "string" ? Buffer.byteLength(content, "utf-8") : content.length;

cli/src/config/writer.ts, line 325
Synchronous I/O regression in parallel-mode hot path

The previous implementation used an async, debounced write queue (appendFile + 100 ms batching) specifically to avoid blocking the event loop during parallel task runs. This PR replaces it with a synchronous appendFileSync, which blocks the Node.js event loop on every progress write.

In parallel mode, multiple tasks may write progress simultaneously, so this change reintroduces file-system contention and latency that the original design explicitly addressed. The PR description says the old code had "drift," but it doesn't explain why the synchronous path is preferable. Consider retaining appendFile (or a lightweight fs.promises.appendFile) to avoid the regression.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (3)

cli/src/utils/sanitization.ts, line 54
Security gap: Missing Anthropic API key redaction pattern

The sanitizeSecrets function is used directly by logger.ts to redact secrets from every log message. However, it's missing the sk-ant- pattern for Anthropic API keys that is correctly present in transform.ts at line 239.

Anthropic API keys start with sk-ant-, and the current sk-[a-zA-Z0-9]{48} pattern will never match them (it requires exactly 48 alphanumerics after sk-, whereas Anthropic keys have 16-256 chars with underscores/hyphens). Any Anthropic API key that leaks into a warning, debug, or error log will be emitted in plaintext.

	// All patterns use bounded quantifiers to prevent ReDoS
	// Patterns are designed to match specific token formats with fixed lengths
	const patterns = [
		{ regex: /sk-[a-zA-Z0-9]{48}/g, replacement: "[API_KEY_REDACTED]" },
		{ regex: /sk-ant-[a-zA-Z0-9_-]{16,256}/g, replacement: "[ANTHROPIC_KEY_REDACTED]" },
		{ regex: /ghp_[a-zA-Z0-9]{36}/g, replacement: "[GITHUB_TOKEN_REDACTED]" },
		{ regex: /gho_[a-zA-Z0-9]{52}/g, replacement: "[GITHUB_OAUTH_REDACTED]" },
		{ regex: /AKIA[0-9A-Z]{16}/g, replacement: "[AWS_KEY_REDACTED]" },
		// For hex secrets, use a bounded length and require word boundaries to prevent
		// matching large hex strings that could cause performance issues
		{ regex: /\b[0-9a-f]{64}\b/g, replacement: "[HEX_SECRET_REDACTED]" },
	];

cli/src/utils/resource-manager.ts, line 340
Unhandled promise rejection in checkMemoryUsage

The cleanup() method returns Promise<void>, but it is called here without .catch() or void annotation. This creates an unhandled promise rejection that can crash the process on Node.js v15+.

	private checkMemoryUsage(): void {
		const stats = this.getStats();
		if (stats.totalDiskUsage > this.maxMemoryUsage) {
			// Clean up oldest resources first
			void this.cleanup({ maxAge: 10 * 60 * 1000 }).catch((err) => {
				console.error("Memory-triggered cleanup failed:", err);
			});
		}
	}

cli/src/ui/logger.ts, line 282
JsonFileLogSink does not create the log directory

The validateLogPath enforces that log files stay inside process.cwd()/logs, but neither the constructor nor validateLogPath creates that directory. When appendFileSync is first called in flush(), if the logs/ directory does not exist, it will throw ENOENT. The error is caught and logged (line 309), but this causes repeated error spam on every flush attempt and grows the buffer indefinitely until MAX_BUFFER_SIZE is hit.

Consider creating the directory in the constructor:

import { mkdirSync } from "node:fs";
// in constructor, after validateLogPath:
mkdirSync(path.dirname(this.filePath), { recursive: true });

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

cli/src/engines/validation.ts, line 67
validateCommand applies the dangerousPatterns check to the entire trimmedCommand string (including arguments) before tokenization. A legitimate build command like npm run build -- --define:NODE_ENV=$NODE_ENV would be rejected because $ appears in an argument, even though the command is passed to spawn/execFile (not a shell) where $ in arguments is safe.

The $ check should not apply to pre-tokenization validation, or should be moved to per-argument validation only (where it already exists in validateArgs). Consider removing $ from the character class on line 55 since it's also caught by the per-argument check on line 115.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 4, 2026

Additional Comments (2)

cli/src/config/writer.ts, line 138
Progress writes may be lost on signal-triggered exits. The old batched write queue had an explicit flushAllProgressWrites() drain called in the finally block of index.ts, guaranteeing all pending writes completed before exit. That call was removed in this PR.

The new approach fires appendFile and voids the promise (line 138). Under normal exit (error thrown, event loop drains), Node.js will wait for pending I/O before exiting. However, the setupSignalHandlers() in cleanup.ts calls process.exit(0) after runCleanup() completes (line 142) — and since runCleanup() only awaits registered CleanupFn entries (not in-flight appendFile promises), those writes can be silently lost when a signal handler triggers.

Consider either:

  1. Registering the in-flight write promises with the cleanup system
  2. Making logTaskProgress return the promise so callers can await it if needed

Example:

export function logTaskProgress(
    task: string,
    status: "completed" | "failed",
    workDir = process.cwd(),
): Promise<void> {
    const progressPath = getProgressPath(workDir);
    if (!existsSync(progressPath)) {
        return Promise.resolve();
    }
    const timestamp = new Date().toISOString().slice(0, 16).replace("T", " ");
    const icon = status === "completed" ? "✓" : "✗";
    const line = `- [${icon}] ${timestamp} - ${task}\n`;
    
    return appendFile(progressPath, line, "utf-8").catch((error) => {
        logWarn(`Failed to append task progress: ${error}`);
    });
}

cli/src/engines/validation.ts, line 79
validateCommand permits .. path-traversal components in command tokens. The allowed-character regex for non-Windows is /^[a-zA-Z0-9._\-/]+$/ (line 79), which allows . and / together. This means tokens like ../../bin/sh or ../../../usr/bin/python3 will pass validation.

This is intentionally broad to support ./node_modules/.bin/cli-style paths, but it also silently permits directory-escape patterns. Even though the immediate context (config file is user-editable) is already trusted, rejecting ../ components would improve security posture as a defense-in-depth measure.

Consider rejecting command tokens that contain ../ or start with ..:

if (commandToken.includes("../") || commandToken.startsWith("..")) {
    debugLog(`Command validation failed: path traversal in command token "${commandToken}"`);
    return null;
}

Add this check before the validCommandPattern.test() on line 81 to ensure path-traversal attempts are explicitly rejected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant