Skip to content

stack6:feat: add git sync, telemetry, and ui command updates#165

Open
VX1D wants to merge 8 commits intomichaelshimeles:mainfrom
VX1D:stack/pr6-git-telemetry-ui
Open

stack6:feat: add git sync, telemetry, and ui command updates#165
VX1D wants to merge 8 commits intomichaelshimeles:mainfrom
VX1D:stack/pr6-git-telemetry-ui

Conversation

@VX1D
Copy link
Copy Markdown
Contributor

@VX1D VX1D commented Mar 3, 2026

PR6: git telemetry ui

Summary

This PR closes the stack with end-to-end command, Git, telemetry, notification, and UI integration. It turns the lower-level execution layers into a complete operational workflow.

Why this PR exists

  • Final CLI behavior depends on command/Git/telemetry/UI being aligned.
  • Webhook and issue-sync paths are high risk and required extra hardening.
  • Test coverage for these user-facing paths needed to land here.

What it adds

  • Command layer updates:
    • cli/src/cli/commands/config.ts
    • cli/src/cli/commands/init.ts
    • cli/src/cli/commands/task.ts
  • Git integration updates:
    • cli/src/git/branch.ts
    • cli/src/git/issue-sync.ts
    • cli/src/git/merge.ts
    • cli/src/git/index.ts
  • Telemetry + notification updates:
    • cli/src/telemetry/index.ts
    • cli/src/telemetry/exporter.ts
    • cli/src/telemetry/writer.ts
    • cli/src/notifications/webhook.ts
  • UI surface updates:
    • cli/src/ui/index.ts
    • cli/src/ui/spinner.ts
    • cli/src/ui/progress.ts
    • cli/src/ui/progress-types.ts

PR preview

  • task command path now aligns with state/retry/notification flow used by the stack.
  • Git issue sync can update issue body from PRD progress, but now enforces path boundaries.
  • Webhook notifications support stricter URL validation (protocol/host/DNS checks).
  • Telemetry writer/exporter flow is cleaner and more fault-tolerant on malformed JSONL lines.
  • UI progress/spinner modules are wired for the richer execution events introduced earlier.

Concrete scenarios this fixes

  • --sync-issue with path escape attempt (../../secret): blocked.
  • Webhook URL resolves to private/internal host via DNS: blocked.
  • Corrupted telemetry line in JSONL: reader skips bad line instead of failing whole session export.

Security/reliability hardening

  • Issue sync now blocks PRD paths outside working directory.
  • Webhook validation strengthened with DNS-resolution checks and private-range blocking.
  • Additional blocked network ranges for SSRF defense.
  • Telemetry reader paths tolerate malformed JSONL lines safely.

Tests included

  • cli/__tests__/run-tests.test.ts
  • cli/__tests__/sandbox-security.test.ts
  • cli/__tests__/json-validation.test.ts

Validation

  • Final stacked tip validation passed.
  • cum-6 passes bun run check, bun tsc --noEmit, and bun test.

Note

Add file-locking for parallel execution, secure sandbox sync under tmpdir with path validation, and telemetry webhook redaction while updating UI progress and CLI command behavior

Introduce cross-process file locks with re-entrancy and stale cleanup; add sandbox creation that validates paths, syncs incrementally, and relocates to tmpdir; add telemetry secret redaction and resilient JSONL I/O; harden webhook notifications with SSRF checks and retries; switch CLI commands to throw instead of process.exit; add planning progress UI and spinner refactor; and update Git task/branch flows and PRD path checks.

📍Where to Start

Start with locking in execution.locking.acquireFileLock in cli/src/execution/locking.ts, then review sandbox build and validation in createSandbox and validatePath in cli/src/execution/sandbox.ts, and telemetry redaction in TelemetryCollector in cli/src/telemetry/collector.ts.

Macroscope summarized fa4cb06.

@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

Checked 30 published document(s) in 1 knowledge base(s). No updates required.

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 closes a large stacked feature with cross-cutting changes across CLI commands, Git integration, telemetry/secret redaction, webhook SSRF hardening, sandbox path validation, file locking, and UI rendering. The security improvements (DNS validation, private-IP blocking, path traversal guards) are meaningful and the overall architecture is coherent.

Key changes:

  • execution/locking.ts – new file-backed cross-process lock with 'wx' atomic creation, exponential backoff, and stale-lock cleanup.
  • execution/sandbox.ts – complete rewrite: syncDirectory with mtime-based diffing, symlink escape validation, inode-cycle detection in getModifiedFiles, and cleanupSandbox now restricted to the tmpdir sandbox base.
  • execution/skill-compress.ts – new file compressing skill markdown to reduce LLM token usage; word-boundary anchors are correctly applied but inline code spans are not protected, allowing substitutions like `directory` → `dir` to silently corrupt method and file names that AI agents rely on.
  • notifications/webhook.ts – HTTPS enforcement, private-IP and IPv6 SSRF blocking, per-provider allowlists, assertDnsStillSafe re-check before each send, retry with backoff, and AbortController timeouts.
  • sandbox.ts (copyBackPlannedFilesParallel) – the directory rollback on failure iterates the full directoriesToCreate set (which includes pre-existing directories) rather than only the directories created by this function, causing potential data loss when a directory creation error is encountered.
  • telemetry/collector.ts – secret redaction for API keys, GitHub tokens, AWS keys, and 64-char hex secrets applied to prompts, responses, parameters, results, and file paths.
  • telemetry/writer.ts – tolerant JSONL parsing: corrupt lines are skipped with logDebug instead of crashing the reader.
  • config/types.tstelemetry_webhook is now parsed by Zod, but sendNotifications() in webhook.ts still does not read or send to it; users who configure this key will receive no notification and no error.

Confidence Score: 2/5

  • Not safe to merge as-is — a data-loss bug in the copy-back rollback path and inline-code corruption in skill compression need to be addressed first.
  • Two new bugs were found beyond what prior review threads already caught: (1) copyBackPlannedFilesParallel can delete pre-existing directories when rolling back a failed directory-creation attempt, and (2) compressMarkdown mutates inline code spans (single-backtick) in skill files, silently corrupting identifier names that AI agents depend on. Combined with several open issues from earlier PR rounds (TOCTOU DNS rebinding, telemetry_webhook implemented but never sent, progress-rendering methods that build output but never write it), the overall risk is elevated.
  • cli/src/execution/sandbox.ts (copyBackPlannedFilesParallel rollback), cli/src/execution/skill-compress.ts (inline code protection), cli/src/notifications/webhook.ts (TOCTOU + telemetry_webhook)

Important Files Changed

Filename Overview
cli/src/execution/sandbox.ts Major rewrite adding syncDirectory, validatePath, copyBackPlannedFilesParallel, and stale-sandbox cleanup; symlink escape validation is improved but the directory rollback in copyBackPlannedFilesParallel can delete pre-existing directories on failure.
cli/src/execution/skill-compress.ts New file adding markdown compression and CSV skill serialization; word-boundary anchors are correctly applied for prose substitutions, but inline code (single-backtick spans) is not protected, causing identifier names inside inline code to be silently rewritten.
cli/src/execution/locking.ts New file-backed cross-process lock implementation with exponential backoff, stale-lock cleanup, and reentrant support; Bun/Node sleep handled correctly; overall solid but the fallback Atomics.wait can block the event loop under contention (addressed in prior threads).
cli/src/notifications/webhook.ts Adds HTTPS enforcement, private-IP blocking, per-provider hostname allowlists, DNS re-check before every send, retry with backoff, and request timeouts; fd00::/8 IPv6 range is now correctly blocked; TOCTOU DNS rebinding window remains open between validate and send (noted in prior threads).
cli/src/telemetry/collector.ts Adds sanitizeSecrets/sanitizeTelemetryValue for API keys, GitHub tokens, AWS keys, and hex secrets; applied consistently across prompts, responses, parameters, results, and file paths.
cli/src/utils/json-validation.ts New JSON boundary scanner for streaming JSONL; escape tracking is correctly guarded by inString; schema validation via Zod handles the common event types.
cli/src/execution/parallel.ts Adds PRD path validation via resolveSafeRelativePath, fixes closure-variable capture bug (agentId vs globalAgentNum), adds csv prdSource support, and ensures parent dirs are created before file copies.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[task / run command] --> B{parallel mode?}
    B -- yes --> C[runParallel]
    B -- no --> D[runSequential]

    C --> E[resolveSafeRelativePath\nPRD path validation]
    E --> F{worktree\navailable?}
    F -- yes --> G[runAgentInWorktree\ncreateAgentWorktree]
    F -- no/error --> H[runAgentInSandbox\ncreateSandbox]

    G --> I[acquireLocksForFiles\nlocking.ts]
    H --> I

    I --> J[AI Engine\nexecution]
    J --> K[getModifiedFiles\ninode cycle detection]
    K --> L[copyBackPlannedFiles\nParallel / syncSandboxToOriginal]
    L --> M[releaseLocksForFiles]

    M --> N[sendNotifications\nwebhook.ts]
    N --> O{webhook URL\nvalidation}
    O -- invalid --> P[logWarn, skip]
    O -- valid --> Q[assertDnsStillSafe\nbefore each retry]
    Q --> R[fetch with timeout\n& AbortController]

    D --> S[TelemetryCollector\nsanitizeSecrets]
    J --> S
    S --> T[TelemetryWriter\ntolerant JSONL write]

    style E fill:#f9a,stroke:#c00
    style O fill:#f9a,stroke:#c00
    style Q fill:#ffd,stroke:#aa0
    style I fill:#adf,stroke:#05f
    style L fill:#fca,stroke:#c60
Loading

Last reviewed commit: fa4cb06

Comment thread cli/src/ui/progress.ts
Comment thread cli/src/notifications/webhook.ts
Comment thread cli/README.md
discord_webhook: "https://discord.com/api/webhooks/..."
slack_webhook: "https://hooks.slack.com/services/..."
custom_webhook: "https://your-api.com/webhook"
telemetry_webhook: "https://your-api.com/telemetry" # optional
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

telemetry_webhook documented but not implemented

The README advertises telemetry_webhook as a new config key under notifications, but:

  1. NotificationsSchema in cli/src/config/types.ts does not include telemetry_webhook — any value users set will be silently stripped by Zod's default strict parsing.
  2. sendNotifications() in cli/src/notifications/webhook.ts has no branch that reads or sends to telemetry_webhook.

Users who add this key to their .ralphy/config.yaml will receive no notification and no error. Either implement the field or remove it from the documentation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/README.md
Line: 250

Comment:
**`telemetry_webhook` documented but not implemented**

The README advertises `telemetry_webhook` as a new config key under `notifications`, but:

1. `NotificationsSchema` in `cli/src/config/types.ts` does not include `telemetry_webhook` — any value users set will be silently stripped by Zod's default strict parsing.
2. `sendNotifications()` in `cli/src/notifications/webhook.ts` has no branch that reads or sends to `telemetry_webhook`.

Users who add this key to their `.ralphy/config.yaml` will receive no notification and no error. Either implement the field or remove it from the documentation.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/git/merge.ts
Comment thread cli/src/notifications/webhook.ts
Comment thread cli/src/ui/progress.ts
Comment on lines +138 to +203
private redrawPlanning(): void {
if (!this.planningTasks) return;

const tasks = this.planningTasks;
const doneCount = tasks.filter((t) => t.status === "done").length;
const failedCount = tasks.filter((t) => t.status === "failed").length;
const activeCount = tasks.filter((t) => t.status === "active").length;

const lines: string[] = [];
// Only show header in non-verbose mode or if state changed (simplified)
// In verbose mode we probably just want to log specific events,
// but for now let's just avoid clearing.
if (!verboseMode) {
lines.push(
pc.cyan(
`[PLANNING] ${doneCount}/${tasks.length} done${failedCount > 0 ? `, ${failedCount} failed` : ""} (${activeCount} active)`,
),
);
}

for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const taskTrunc = task.title.length > 55 ? `${task.title.substring(0, 52)}...` : task.title;

if (task.status === "active") {
const spinner = getSpinner(this.planningSpinnerIdx);
const elapsed = task.startTime ? ` (${formatDuration(Date.now() - task.startTime)})` : "";
const stepInfo = task.currentStep ? ` ${pc.dim(`[${task.currentStep}]`)}` : "";
const rewardInfo = task.reward ? ` ${pc.yellow(`Reward: ${task.reward}`)}` : "";
lines.push(`${spinner} Planning: ${taskTrunc}${elapsed}${stepInfo}${rewardInfo}`);

// Render recent steps history for active tasks
if (task.recentSteps && task.recentSteps.length > 0) {
// Show progress flow with arrows
// Current step (with completion status) → Previous steps
const completionIcon = pc.cyan("→");
lines.push(` ${completionIcon} ${pc.bold(task.currentStep || "Working")}`);

// Show up to 5 previous steps with arrows
for (let i = 1; i < task.recentSteps.length && i < 6; i++) {
const step = task.recentSteps[i];
const formattedStep = this.formatPlanningStep(step);
// Skip empty formatted steps
if (formattedStep) {
lines.push(` ${pc.dim(`↓ ${formattedStep}`)}`);
}
}
}
} else if (task.status === "done") {
const spinner = pc.green("✓");
const completionIcon = pc.green("✓");
const files = task.files || 0;
const time = task.time || "?";
const rewardInfo = task.reward ? ` ${pc.yellow(`Reward: ${task.reward}`)}` : "";
lines.push(
`${spinner} ${pc.dim(completionIcon)} Planning: ${taskTrunc} (${files} files, ${time}s)${rewardInfo}`,
);
} else if (task.status === "failed") {
const rewardInfo = task.reward ? ` ${pc.yellow(`Reward: ${task.reward}`)}` : "";
lines.push(`${pc.red("[FAIL]")} Planning: ${taskTrunc}${rewardInfo}`);
} else if (task.status === "pending") {
const rewardInfo = task.reward ? ` ${pc.yellow(`Reward: ${task.reward}`)}` : "";
lines.push(` Pending: ${taskTrunc}${rewardInfo}`);
}
}
this.lastLineCount = lines.length;
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 ui/progress.ts:138

Print before updating lastLineCount. redrawPlanning and showHeartbeat build lines but never write to stdout, making the UI invisible and causing subsequent clears to erase unrelated output. Write the lines first, then set lastLineCount.

 		this.lastLineCount = lines.length;
+		clearConsole(this.lastLineCount);
+		for (const line of lines) {
+			process.stdout.write(`${line}\n`);
+		}
  	}
Also found in 1 other location(s)

cli/src/ui/spinner.ts:35

The fallback spinner object, used when nanospinner fails to initialize (e.g., in CI or non-TTY environments), defines its update method as a no-op () =&gt; {}. While this correctly prevents the periodic tick() method from spamming the logs, it causes updateStep() to silently fail to display any output. updateStep calls this.spinner.update() to inform the user of state changes (e.g., "Thinking" -> "Writing"), but since the fallback implementation does nothing and does not throw, the intended backup logging in the updateStep catch block (lines 114-117) is never triggered. As a result, users in fallback environments will see the spinner start and finish, but all intermediate steps will be invisible.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/progress.ts around lines 138-203:

Print before updating `lastLineCount`. `redrawPlanning` and `showHeartbeat` build lines but never write to `stdout`, making the UI invisible and causing subsequent clears to erase unrelated output. Write the lines first, then set `lastLineCount`.

Evidence trail:
File: cli/src/ui/progress.ts (REVIEWED_COMMIT)
- Lines 138-208: `redrawPlanning()` builds `lines[]` array, sets `this.lastLineCount = lines.length` at line 208, but has no `process.stdout.write()` call
- Lines 271-282: `showHeartbeat()` builds `parts[]` array, sets `this.lastLineCount = 1` at line 280, but has no `process.stdout.write()` call
- Lines 238-267: `renderAgentCards()` shows correct pattern: calls `process.stdout.write()` before setting `lastLineCount`

Also found in 1 other location(s):
- cli/src/ui/spinner.ts:35 -- The fallback spinner object, used when `nanospinner` fails to initialize (e.g., in CI or non-TTY environments), defines its `update` method as a no-op `() => {}`. While this correctly prevents the periodic `tick()` method from spamming the logs, it causes `updateStep()` to silently fail to display any output. `updateStep` calls `this.spinner.update()` to inform the user of state changes (e.g., "Thinking" -> "Writing"), but since the fallback implementation does nothing and does not throw, the intended backup logging in the `updateStep` `catch` block (lines 114-117) is never triggered. As a result, users in fallback environments will see the spinner start and finish, but all intermediate steps will be invisible.

Comment thread cli/src/ui/progress.ts
Comment on lines +113 to +121
showPlanningProgress(tasks: PlanningTaskStatus[]): void {
if (!ansiSupported) {
for (const task of tasks) {
if (task.status === "active") {
} else if (task.status === "done") {
}
}
return;
}
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 ui/progress.ts:113

Implement a non‑ANSI fallback in showPlanningProgress. The !ansiSupported path is empty, so users without ANSI (Windows/CI) see no progress; log simple per‑task updates without clearing/animation.

-		for (const task of tasks) {
-			if (task.status === "active") {
-			} else if (task.status === "done") {
-			}
-		}
-		return;
+		for (const task of tasks) {
+			const statusIcon = task.status === "done" ? "✓" : task.status === "failed" ? "✗" : "•";
+			process.stdout.write(`${statusIcon} ${task.title}\n`);
+		}
+		return;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/progress.ts around lines 113-121:

Implement a non‑ANSI fallback in `showPlanningProgress`. The `!ansiSupported` path is empty, so users without ANSI (Windows/CI) see no progress; log simple per‑task updates without clearing/animation.

Evidence trail:
cli/src/ui/progress.ts lines 113-121 at REVIEWED_COMMIT: The `showPlanningProgress` function has an `if (!ansiSupported)` branch containing a for loop with empty `if (task.status === "active") { }` and `else if (task.status === "done") { }` blocks, followed by an early return. The code path does nothing.

Comment thread cli/src/ui/progress.ts
Comment on lines +48 to +51
function setLogHandler(_handler: ((message: string) => void) | null): void {
// No-op: handler is not currently used but kept for API compatibility
}

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 ui/progress.ts:48

setLogHandler accepts a handler but does nothing with it, so safeLog is never registered even though showPhaseHeader passes it. When logs occur during progress display, they write directly to stdout without clearing the spinner first. This shifts the cursor and corrupts the clearConsole line count, causing visual artifacts (ghost frames) and overwritten log messages.

+let activeLogHandler: ((message: string) => void) | null = null;
+
+function setLogHandler(handler: ((message: string) => void) | null): void {
+	activeLogHandler = handler;
+}
+
+export function logDuringProgress(message: string): void {
+	if (activeLogHandler) {
+		activeLogHandler(message);
+	} else {
+		console.log(message);
+	}
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/progress.ts around lines 48-51:

`setLogHandler` accepts a handler but does nothing with it, so `safeLog` is never registered even though `showPhaseHeader` passes it. When logs occur during progress display, they write directly to stdout without clearing the spinner first. This shifts the cursor and corrupts the `clearConsole` line count, causing visual artifacts (ghost frames) and overwritten log messages.

Evidence trail:
cli/src/ui/progress.ts lines 48-50: `setLogHandler` function is explicitly a no-op with comment 'No-op: handler is not currently used but kept for API compatibility'
cli/src/ui/progress.ts line 93: `showPhaseHeader` calls `setLogHandler(this.safeLog)` attempting to register the handler
cli/src/ui/progress.ts lines 75-86: `safeLog` method implementation that calls `this.clear()` before writing - this is designed to prevent visual artifacts but is never actually used
cli/src/ui/progress.ts line 277: `stopAll()` also calls `setLogHandler(null)` suggesting the API was intended to work

Comment thread cli/src/notifications/webhook.ts Outdated
}
}

return { valid: true };
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 notifications/webhook.ts:111

validateWebhookUrl checks that the resolved IP is not private, but callers then use the original hostname in fetch, which performs a second DNS lookup. An attacker can serve a safe IP during validation and a private IP during the actual request, bypassing SSRF protection. Consider returning the resolved IP and using it directly in the fetch request (e.g., via a custom HTTP agent) to prevent DNS rebinding.

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

`validateWebhookUrl` checks that the resolved IP is not private, but callers then use the original hostname in `fetch`, which performs a second DNS lookup. An attacker can serve a safe IP during validation and a private IP during the actual request, bypassing SSRF protection. Consider returning the resolved IP and using it directly in the fetch request (e.g., via a custom HTTP agent) to prevent DNS rebinding.

Evidence trail:
cli/src/notifications/webhook.ts lines 95-104 (DNS lookup in validateWebhookUrl), lines 268-275 (Discord webhook validation then call with original URL), lines 281-290 (Slack webhook), lines 294-303 (Custom webhook), lines 191-194 (fetch call in sendDiscordNotification using webhookUrl directly). The validation at lines 95-104 resolves DNS once, but the fetch at line 191 will perform a separate DNS lookup using the hostname.

Comment thread cli/src/ui/progress.ts
let ansiSupportChecked = false;
let ansiSupported = true;

function checkAnsiSupport(): void {
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 ui/progress.ts:13

ANSI detection should be TTY‑aware and lazy. Gate ansiSupported on process.stdout.isTTY, and defer any chcp/capability checks to first use. This avoids escape codes in redirected output and the synchronous import‑time chcp (including its persistent code‑page change).

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

ANSI detection should be TTY‑aware and lazy. Gate `ansiSupported` on `process.stdout.isTTY`, and defer any `chcp`/capability checks to first use. This avoids escape codes in redirected output and the synchronous import‑time `chcp` (including its persistent code‑page change).

Evidence trail:
cli/src/ui/progress.ts lines 10-29 at REVIEWED_COMMIT: ansiSupported defaults to true (line 11), checkAnsiSupport() does not check process.stdout.isTTY, checkAnsiSupport() is called at import time (line 29), execSync runs 'chcp 65001' on Windows (line 22). clearConsole() at lines 52-60 writes ANSI escape sequences unconditionally when ansiSupported is true.

Comment thread cli/src/ui/progress.ts
const action = fileActionMatch[1].trim();
let file = fileActionMatch[2].trim();
// Remove task title from file path if present
file = file.replace(/^Task\s+ST-\d+:\s*[^"]+"\s*/, "").trim();
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 ui/progress.ts:319

Make Task ST-... stripping robust. In formatAgentStep/formatPlanningStep, the current regexes either hide valid messages or remove filenames. Use a single helper that precisely detects Task ST-<id>: (optional quotes, anchored) and removes only the prefix, preserving the content after the colon.

-			file = file.replace(/^Task\s+ST-\d+:\s*[^"]+"\s*/, "").trim();
+			file = file.replace(/^Task\s+ST-\d+:\s*(?:"[^"]*"\s*)?/, "").trim();
Also found in 1 other location(s)

cli/src/notifications/webhook.ts:24

The IPv4 blocking regexes in BLOCKED_IP_RANGES lack start-of-string anchors (^), meaning they will incorrectly match valid public IPs that happen to contain blocked sequences (e.g. 1.127.0.1 or 210.0.0.1). Although the comment implies anchors are present (^127\., etc.), the actual regex literals in the code object do not have them in the provided diff view for some entries if I look closely? Wait, looking at lines 15-21, they do have ^.

However, there is a different issue: the regex for 0.0.0.0/8 is /^0\./. This blocks any IP starting with 0., which technically is valid (0.0.0.0/8 is 'Current network' and reserved, so blocking it is correct).

Let's re-examine BLOCKED_IPV6_RANGES (lines 24-35).
Line 27: /^fe80:/i matches fe80:.
Line 28: /^fc00:/i matches fc00:.

However, fc00::/7 is the Unique Local Address range. This means it covers fc00: through fdff:. The regex /^fc00:/i ONLY catches addresses starting with fc00. It completely misses fd00::/8 (the other half of the ULA range, often used for locally assigned ULAs). Users with private IPv6 networks starting with fd (which is standard for random generation) will not be blocked, bypassing the SSRF protection.

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

Make `Task ST-...` stripping robust. In `formatAgentStep`/`formatPlanningStep`, the current regexes either hide valid messages or remove filenames. Use a single helper that precisely detects `Task ST-<id>:` (optional quotes, anchored) and removes only the prefix, preserving the content after the colon.

Evidence trail:
cli/src/ui/progress.ts lines 440-442: regex `/^Task\s+ST-\d+:\s*/` lacks end anchor, causing it to match and return empty for valid content like `Task ST-123: Read config.json`
cli/src/ui/progress.ts line 319: regex `/^Task\s+ST-\d+:\s*[^"]+"\s*/` requires quote character, leaves trailing quote in output
cli/src/ui/progress.ts lines 449, 458, 469: different pattern `/^"Task\s+ST-\d+:\s*[^"]+"\s*/` with leading quote
cli/src/ui/progress.ts line 490: yet another pattern `Task ST-\d+:\s*(.+)` without start anchor

Also found in 1 other location(s):
- cli/src/notifications/webhook.ts:24 -- The IPv4 blocking regexes in `BLOCKED_IP_RANGES` lack start-of-string anchors (`^`), meaning they will incorrectly match valid public IPs that happen to contain blocked sequences (e.g. `1.127.0.1` or `210.0.0.1`). Although the comment implies anchors are present (`^127\.`, etc.), the actual regex literals in the code object do not have them in the provided diff view for some entries if I look closely? Wait, looking at lines 15-21, they *do* have `^`. 

However, there is a different issue: the regex for `0.0.0.0/8` is `/^0\./`. This blocks any IP starting with `0.`, which technically is valid (0.0.0.0/8 is 'Current network' and reserved, so blocking it is correct). 

Let's re-examine `BLOCKED_IPV6_RANGES` (lines 24-35). 
Line 27: `/^fe80:/i` matches `fe80:`. 
Line 28: `/^fc00:/i` matches `fc00:`. 

However, `fc00::/7` is the Unique Local Address range. This means it covers `fc00:` through `fdff:`. The regex `/^fc00:/i` ONLY catches addresses starting with `fc00`. It completely misses `fd00::/8` (the other half of the ULA range, often used for locally assigned ULAs). Users with private IPv6 networks starting with `fd` (which is standard for random generation) will not be blocked, bypassing the SSRF protection.

Comment thread cli/src/git/merge.ts
// Using three-dot notation to show changes since the branches diverged
const diffOutput = await git.diff([`${targetBranch}...${branch}`, "--name-only"]);
// Using two-dot notation to show unique changes to target (excluding ancestor commits)
const diffOutput = await git.diff([`${targetBranch}..${branch}`, "--name-only"]);
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 git/merge.ts:271

The two-dot notation in git.diff([${targetBranch}..${branch}, "--name-only"]) returns files changed in targetBranch since the branch diverged, not just files the branch itself modified. This pollutes filesChanged with unrelated files, causing calculateConflictScore to report false conflicts between branches that never touched the same files. The conflict-sorting optimization becomes ineffective because every branch appears to overlap with every other branch on the files targetBranch changed.

Suggested change
const diffOutput = await git.diff([`${targetBranch}..${branch}`, "--name-only"]);
const diffOutput = await git.diff([`${targetBranch}...${branch}`, "--name-only"]);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/git/merge.ts around line 271:

The two-dot notation in `git.diff([`${targetBranch}..${branch}`, "--name-only"])` returns files changed in `targetBranch` since the branch diverged, not just files the branch itself modified. This pollutes `filesChanged` with unrelated files, causing `calculateConflictScore` to report false conflicts between branches that never touched the same files. The conflict-sorting optimization becomes ineffective because every branch appears to overlap with every other branch on the files `targetBranch` changed.

Evidence trail:
- cli/src/git/merge.ts lines 260-288 (REVIEWED_COMMIT): `analyzePreMerge` function with two-dot notation at line 271
- cli/src/git/merge.ts lines 296-314 (REVIEWED_COMMIT): `calculateConflictScore` function that counts file overlaps
- Git documentation at https://git-scm.com/docs/git-diff/2.29.0: confirms `git diff A..B` shows all differences between A and B trees, while `git diff A...B` shows changes since common ancestor ('git diff A...B' is equivalent to 'git diff $(git merge-base A B) B')

Comment thread cli/src/ui/spinner.ts
/**
* Mark as error
*/
error(message?: string): void {
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 ui/spinner.ts:168

The error method drops elapsed time from the output when a custom message is provided. The previous implementation appended pc.red([elapsed]) explicitly, but the new implementation returns message directly without timing information, losing observability for failed long-running tasks. Consider always including the elapsed time in the error output, or document if this is an intentional change.

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

The `error` method drops elapsed time from the output when a custom `message` is provided. The previous implementation appended `pc.red([elapsed])` explicitly, but the new implementation returns `message` directly without timing information, losing observability for failed long-running tasks. Consider always including the elapsed time in the error output, or document if this is an intentional change.

Evidence trail:
cli/src/ui/spinner.ts lines 168-173 at REVIEWED_COMMIT - current error() method implementation. git_diff base=48c77a0d65607d1d881833cd0bfe553c3add18e2^ head=48c77a0d65607d1d881833cd0bfe553c3add18e2 path=cli/src/ui/spinner.ts shows the previous implementation with `this.spinner.error({ text: \`${message || this.formatText()} ${pc.red(\`[${elapsed}]\`)}\` });` which always appended elapsed time, versus the new implementation that only includes time via formatText() when no message is provided.

Comment thread cli/src/ui/spinner.ts
Comment on lines 123 to +130
tick(): void {
this.spinner.update({ text: this.formatText() });
if (!this.tickInterval) {
// Don't update if spinner is stopped
return;
}

try {
// Always update the timer, bypassing throttle
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 ui/spinner.ts:123

The heartbeat mechanism is rendered ineffective by the guard clause in tick(). The constructor initializes heartbeatInterval to keep the spinner updating even if the main timer fails, but tick() returns early when this.tickInterval is null. If tickInterval fails to initialize, the heartbeat calls tick() which immediately exits, defeating the fallback. Conversely, if tickInterval is active, the heartbeat triggers redundant updates every 5 seconds that bypass the throttle in updateStep() but still call spinner.update(). Consider using a separate flag like isRunning to track spinner state, so the heartbeat can function independently of the main timer.

 tick(): void {
- 		if (!this.tickInterval) {
+ 		if (!this.tickInterval && !this.heartbeatInterval) {
 			// Don't update if spinner is stopped
 			return;
 		}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/spinner.ts around lines 123-130:

The heartbeat mechanism is rendered ineffective by the guard clause in `tick()`. The constructor initializes `heartbeatInterval` to keep the spinner updating even if the main timer fails, but `tick()` returns early when `this.tickInterval` is null. If `tickInterval` fails to initialize, the heartbeat calls `tick()` which immediately exits, defeating the fallback. Conversely, if `tickInterval` is active, the heartbeat triggers redundant updates every 5 seconds that bypass the throttle in `updateStep()` but still call `spinner.update()`. Consider using a separate flag like `isRunning` to track spinner state, so the heartbeat can function independently of the main timer.

Evidence trail:
cli/src/ui/spinner.ts lines 40-49 (tickInterval initialization with catch setting to null), lines 54-69 (heartbeatInterval setup calling tick()), lines 111-114 (tick() guard clause returning early when tickInterval is null), line 113 comment 'Don't update if spinner is stopped'

Comment thread cli/src/ui/progress.ts

lines.push(""); // Add spacing between agents
}

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 ui/progress.ts:265

renderAgentCards uses clearConsole to refresh the UI, but clearConsole returns early without clearing when ansiSupported is false (line 52). In non-ANSI environments, the agent cards are reprinted to stdout on every tick without clearing previous output, causing massive log flooding with duplicated agent state. Consider detecting non-ANSI support and disabling the animated refresh, or falling back to a single-line update strategy.

     renderAgentCards(agents: AgentProgress[]): void {
         const now = Date.now();
         const activeAgents = agents.filter((a) => a.status === "working");
 
+        // Skip animated rendering in non-ANSI environments to avoid output flooding
+        if (!ansiSupported) {
+            return;
+        }
+
         // Show each agent in a static row format with their recent action
         const lines: string[] = [];
         for (const agent of activeAgents) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file cli/src/ui/progress.ts around line 265:

`renderAgentCards` uses `clearConsole` to refresh the UI, but `clearConsole` returns early without clearing when `ansiSupported` is false (line 52). In non-ANSI environments, the agent cards are reprinted to stdout on every tick without clearing previous output, causing massive log flooding with duplicated agent state. Consider detecting non-ANSI support and disabling the animated refresh, or falling back to a single-line update strategy.

Evidence trail:
cli/src/ui/progress.ts lines 52-56 (clearConsole returns early when !ansiSupported), lines 265-269 (renderAgentCards calls clearConsole then unconditionally writes lines), lines 10-28 (ansiSupported detection logic). Commit: REVIEWED_COMMIT

Comment thread cli/src/ui/progress.ts

import { spawn } from "bun";

const testFiles = ["__tests__/sandbox-security.test.ts", "__tests__/locking-security.test.ts"];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reference to non-existent test file locking-security.test.ts

__tests__/locking-security.test.ts is listed in testFiles but it is not included in this PR and does not appear to exist in the repository. When this runner executes, bun test __tests__/locking-security.test.ts will fail with a "file not found" or similar error, causing allPassed to be false and the overall runner to exit with code 1.

Either add the missing test file or remove it from the list until it exists:

Suggested change
const testFiles = ["__tests__/sandbox-security.test.ts", "__tests__/locking-security.test.ts"];
const testFiles = ["__tests__/sandbox-security.test.ts"];
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/__tests__/run-tests.test.ts
Line: 9

Comment:
**Reference to non-existent test file `locking-security.test.ts`**

`__tests__/locking-security.test.ts` is listed in `testFiles` but it is not included in this PR and does not appear to exist in the repository. When this runner executes, `bun test __tests__/locking-security.test.ts` will fail with a "file not found" or similar error, causing `allPassed` to be `false` and the overall runner to exit with code `1`.

Either add the missing test file or remove it from the list until it exists:

```suggestion
const testFiles = ["__tests__/sandbox-security.test.ts"];
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/git/merge.ts Outdated
Comment thread cli/src/git/branch.ts
Comment thread cli/src/ui/progress.ts
Comment thread cli/src/git/merge.ts Outdated
Comment on lines 272 to 274

try {
// Get list of files that differ between the branch and target
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Misleading comment on two-dot diff notation

The comment says "two-dot notation to show unique changes to target (excluding ancestor commits)", but that is the behaviour of three-dot notation (A...B). Two-dot (A..B) is equivalent to git diff A B — it shows all differences between the two branch tips, including commits that landed in targetBranch after the branches diverged. This can inflate the conflict-candidate file list with files that were only changed on the target, leading to false positives in analyzePreMerge.

If the intent is to detect files the feature branch uniquely changes, the original three-dot notation was more accurate. At minimum the comment should be corrected to describe what the notation actually does.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/git/merge.ts
Line: 272-274

Comment:
**Misleading comment on two-dot diff notation**

The comment says `"two-dot notation to show unique changes to target (excluding ancestor commits)"`, but that is the behaviour of three-dot notation (`A...B`). Two-dot (`A..B`) is equivalent to `git diff A B` — it shows **all** differences between the two branch tips, including commits that landed in `targetBranch` after the branches diverged. This can inflate the conflict-candidate file list with files that were only changed on the target, leading to false positives in `analyzePreMerge`.

If the intent is to detect files the feature branch uniquely changes, the original three-dot notation was more accurate. At minimum the comment should be corrected to describe what the notation actually does.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/ui/progress.ts Outdated
Comment thread cli/src/ui/spinner.ts Outdated
Comment thread cli/src/cli/commands/task.ts Outdated
Comment thread cli/src/ui/progress.ts
Comment thread cli/src/telemetry/writer.ts
Comment thread cli/src/notifications/webhook.ts Outdated

import { spawn } from "bun";

const testFiles = ["__tests__/sandbox-security.test.ts", "__tests__/locking-security.test.ts"];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test runner references __tests__/locking-security.test.ts (line 9), which does not exist in the repository. When this runner executes, bun test __tests__/locking-security.test.ts will fail with "file not found", causing allPassed to be false and the overall runner to exit with code 1.

Either add the missing test file or remove it from the list:

Suggested change
const testFiles = ["__tests__/sandbox-security.test.ts", "__tests__/locking-security.test.ts"];
const testFiles = ["__tests__/sandbox-security.test.ts"];
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/__tests__/run-tests.test.ts
Line: 9

Comment:
The test runner references `__tests__/locking-security.test.ts` (line 9), which does not exist in the repository. When this runner executes, `bun test __tests__/locking-security.test.ts` will fail with "file not found", causing `allPassed` to be `false` and the overall runner to exit with code 1.

Either add the missing test file or remove it from the list:

```suggestion
const testFiles = ["__tests__/sandbox-security.test.ts"];
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/git/merge.ts Outdated
Comment thread cli/src/git/branch.ts
Comment thread cli/src/ui/progress.ts
Comment thread cli/src/notifications/webhook.ts
Comment thread cli/src/utils/json-validation.ts Outdated
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 4, 2026

Additional Comments (2)

cli/src/execution/locking.ts
Uncaught exception from unlinkSync in catch block of cleanupStaleLockFiles

The catch block at line 1486-1488 calls unlinkSync(filePath) unconditionally. If unlinkSync itself throws (e.g., ENOENT because another process already deleted the file between the failed read and this delete attempt), the exception is not caught and propagates up through cleanupStaleLockFiles into acquireFileLock. Since cleanupStaleLockFiles is called outside the inner retry try/catch, this unhandled exception would crash the entire acquireFileLock call for all callers.

Stale-lock cleanup should be best-effort and never crash the acquirer:

} catch {
    try {
        unlinkSync(filePath);
    } catch {
        // best-effort: file may have been deleted by another process
    }
}

The same applies to the unlinkSync inside the if (now - lockInfo.timestamp >= lockInfo.timeout) branch — it should also be wrapped.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/locking.ts
Line: 1470-1489

Comment:
**Uncaught exception from `unlinkSync` in catch block of `cleanupStaleLockFiles`**

The `catch` block at line 1486-1488 calls `unlinkSync(filePath)` unconditionally. If `unlinkSync` itself throws (e.g., `ENOENT` because another process already deleted the file between the failed read and this delete attempt), the exception is not caught and propagates up through `cleanupStaleLockFiles` into `acquireFileLock`. Since `cleanupStaleLockFiles` is called **outside** the inner retry `try/catch`, this unhandled exception would crash the entire `acquireFileLock` call for all callers.

Stale-lock cleanup should be best-effort and never crash the acquirer:

```typescript
} catch {
    try {
        unlinkSync(filePath);
    } catch {
        // best-effort: file may have been deleted by another process
    }
}
```

The same applies to the `unlinkSync` inside the `if (now - lockInfo.timestamp >= lockInfo.timeout)` branch — it should also be wrapped.

How can I resolve this? If you propose a fix, please make it concise.

cli/src/execution/locking.ts
Atomics.wait fallback is disallowed on the main thread in some worker environments

The fallback path Atomics.wait(sleepArray, 0, 0, ms) is reached when Bun.sleepSync is not available (e.g., Node.js workers, or future non-Bun environments). While Atomics.wait is permitted on the main Node.js thread, it is not permitted in a browser's main thread and can be unexpectedly restricted in certain worker configurations. Since acquireFileLock is synchronous and intended for non-async use, the fallback warrants a comment or a narrower guard:

// Atomics.wait is allowed in Node.js main/worker threads but blocked in browser main threads.
// This code path is only expected in non-Bun server runtimes.
Atomics.wait(sleepArray, 0, 0, ms);

Adding a clear runtime comment documents the intent and makes the restriction visible to future maintainers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/locking.ts
Line: 1412-1419

Comment:
**`Atomics.wait` fallback is disallowed on the main thread in some worker environments**

The fallback path `Atomics.wait(sleepArray, 0, 0, ms)` is reached when `Bun.sleepSync` is not available (e.g., Node.js workers, or future non-Bun environments). While `Atomics.wait` is permitted on the main Node.js thread, it is **not permitted** in a browser's main thread and can be unexpectedly restricted in certain worker configurations. Since `acquireFileLock` is synchronous and intended for non-async use, the fallback warrants a comment or a narrower guard:

```typescript
// Atomics.wait is allowed in Node.js main/worker threads but blocked in browser main threads.
// This code path is only expected in non-Bun server runtimes.
Atomics.wait(sleepArray, 0, 0, ms);
```

Adding a clear runtime comment documents the intent and makes the restriction visible to future maintainers.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/execution/sandbox.ts Outdated
Comment thread cli/src/execution/skill-compress.ts Outdated
Comment thread cli/src/execution/skill-compress.ts Outdated
Comment on lines +181 to +188
try {
absoluteBase = realpathSync(resolve(baseDir));
absoluteTarget = realpathSync(resolve(baseDir, targetPath));
} catch {
// If realpath fails (e.g., path doesn't exist), fall back to resolve
absoluteBase = resolve(baseDir);
absoluteTarget = resolve(baseDir, targetPath);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Combined try/catch resets canonicalized absoluteBase on target-path failure

The two realpathSync calls share a single try/catch block:

try {
    absoluteBase = realpathSync(resolve(baseDir)); // may succeed
    absoluteTarget = realpathSync(resolve(baseDir, targetPath)); // may throw (path not yet created)
} catch {
    absoluteBase = resolve(baseDir); // resets base even if it succeeded above
    absoluteTarget = resolve(baseDir, targetPath);
}

When targetPath does not exist yet (common for new-file creation), realpathSync(targetPath) throws and the catch block resets absoluteBase from its canonical resolved form back to the possibly-symlink-form. The subsequent startsWith check still works in most cases, but splitting into two independent try/catch blocks ensures each falls back independently:

Suggested change
try {
absoluteBase = realpathSync(resolve(baseDir));
absoluteTarget = realpathSync(resolve(baseDir, targetPath));
} catch {
// If realpath fails (e.g., path doesn't exist), fall back to resolve
absoluteBase = resolve(baseDir);
absoluteTarget = resolve(baseDir, targetPath);
}
try {
absoluteBase = realpathSync(resolve(baseDir));
} catch {
absoluteBase = resolve(baseDir);
}
try {
absoluteTarget = realpathSync(resolve(baseDir, targetPath));
} catch {
absoluteTarget = resolve(baseDir, targetPath);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/sandbox.ts
Line: 181-188

Comment:
**Combined `try/catch` resets canonicalized `absoluteBase` on target-path failure**

The two `realpathSync` calls share a single `try/catch` block:

```typescript
try {
    absoluteBase = realpathSync(resolve(baseDir)); // may succeed
    absoluteTarget = realpathSync(resolve(baseDir, targetPath)); // may throw (path not yet created)
} catch {
    absoluteBase = resolve(baseDir); // resets base even if it succeeded above
    absoluteTarget = resolve(baseDir, targetPath);
}
```

When `targetPath` does not exist yet (common for new-file creation), `realpathSync(targetPath)` throws and the catch block resets `absoluteBase` from its canonical resolved form back to the possibly-symlink-form. The subsequent `startsWith` check still works in most cases, but splitting into two independent try/catch blocks ensures each falls back independently:

```suggestion
try {
    absoluteBase = realpathSync(resolve(baseDir));
} catch {
    absoluteBase = resolve(baseDir);
}
try {
    absoluteTarget = realpathSync(resolve(baseDir, targetPath));
} catch {
    absoluteTarget = resolve(baseDir, targetPath);
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/execution/locking.ts
Comment thread cli/README.md
discord_webhook: "https://discord.com/api/webhooks/..."
slack_webhook: "https://hooks.slack.com/services/..."
custom_webhook: "https://your-api.com/webhook"
telemetry_webhook: "https://your-api.com/telemetry" # optional
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

telemetry_webhook documented but not implemented

The README advertises telemetry_webhook as a new config key under notifications, but:

  1. NotificationsSchema in cli/src/config/types.ts includes telemetry_webhook (line 20), so it is parsed.
  2. However, sendNotifications() in cli/src/notifications/webhook.ts has no branch that reads or sends to telemetry_webhook — only discord_webhook, slack_webhook, and custom_webhook are used.

Users who add this key to their .ralphy/config.yaml will receive no telemetry webhook notification and no error. Either implement sending to telemetry_webhook or remove it from the documentation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/README.md
Line: 250

Comment:
**`telemetry_webhook` documented but not implemented**

The README advertises `telemetry_webhook` as a new config key under `notifications`, but:

1. `NotificationsSchema` in `cli/src/config/types.ts` includes `telemetry_webhook` (line 20), so it is parsed.
2. However, `sendNotifications()` in `cli/src/notifications/webhook.ts` has no branch that reads or sends to `telemetry_webhook` — only `discord_webhook`, `slack_webhook`, and `custom_webhook` are used.

Users who add this key to their `.ralphy/config.yaml` will receive no telemetry webhook notification and no error. Either implement sending to `telemetry_webhook` or remove it from the documentation.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread cli/src/utils/json-validation.ts Outdated
Comment thread cli/src/telemetry/writer.ts
Comment thread cli/src/execution/sandbox.ts
Comment thread cli/src/utils/cleanup.ts
Comment thread cli/src/execution/skill-compress.ts Outdated
Comment thread cli/src/execution/skill-compress.ts
Comment thread cli/src/execution/sandbox.ts Outdated
Comment on lines +807 to +826
logDebug(`Failed to rollback directory ${createdDir}: ${rollbackErr}`);
}
}
}
throw new Error(`Failed to create directory structure: ${err}`);
}
}
}

// Phase 3: Copy files with TOCTOU protection
// SECURITY: Re-validate paths immediately before copy to prevent symlink attacks
let synced = 0;
for (const change of pendingChanges) {
try {
// Re-validate paths right before use to prevent TOCTOU attacks
const sandboxPath = validatePath(sandboxDir, change.relPath);
const originalPath = validatePath(originalDir, change.relPath);

if (!sandboxPath || !originalPath) {
logDebug(`Security: Path re-validation failed for ${change.relPath}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rollback deletes pre-existing directories on failure

directoriesToCreate is built from the dirname of every pending change — it includes directories that already existed on disk before Phase 2 started. The loop only calls mkdirSync when !existsSync(dir), but the failure rollback iterates the full directoriesToCreate set and deletes every member that currently exists, including those that were present before this function ran.

Concrete example:

  • Dir A already exists, dir B does not.
  • mkdirSync(B) succeeds.
  • mkdirSync(C) fails → rollback triggers.
  • existsSync(A) is truermSync(A, { recursive: true, force: true }) deletes the pre-existing directory.

Fix: track only the directories that this function creates and limit the rollback to that set:

const newlyCreatedDirs: string[] = [];
for (const dir of directoriesToCreate) {
    if (!existsSync(dir)) {
        try {
            mkdirSync(dir, { recursive: true });
            newlyCreatedDirs.push(dir);
        } catch (err) {
            // Rollback only what we created
            for (const createdDir of newlyCreatedDirs.reverse()) {
                try { rmSync(createdDir, { recursive: true, force: true }); } catch {}
            }
            throw new Error(`Failed to create directory structure: ${err}`);
        }
    }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/sandbox.ts
Line: 807-826

Comment:
**Rollback deletes pre-existing directories on failure**

`directoriesToCreate` is built from the `dirname` of every pending change — it includes directories that already existed on disk before Phase 2 started. The loop only calls `mkdirSync` when `!existsSync(dir)`, but the failure rollback iterates the **full** `directoriesToCreate` set and deletes every member that currently exists, including those that were present before this function ran.

Concrete example:
- Dir `A` already exists, dir `B` does not.
- `mkdirSync(B)` succeeds.
- `mkdirSync(C)` fails → rollback triggers.
- `existsSync(A)` is `true``rmSync(A, { recursive: true, force: true })` **deletes the pre-existing directory**.

Fix: track only the directories that this function creates and limit the rollback to that set:

```typescript
const newlyCreatedDirs: string[] = [];
for (const dir of directoriesToCreate) {
    if (!existsSync(dir)) {
        try {
            mkdirSync(dir, { recursive: true });
            newlyCreatedDirs.push(dir);
        } catch (err) {
            // Rollback only what we created
            for (const createdDir of newlyCreatedDirs.reverse()) {
                try { rmSync(createdDir, { recursive: true, force: true }); } catch {}
            }
            throw new Error(`Failed to create directory structure: ${err}`);
        }
    }
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +18 to +55
return segment;
}

return segment
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]+$/gm, "")
.replace(/^\s+$/gm, "")
.replace(/Please note that /gi, "Note: ")
.replace(/In order to /gi, "To ")
.replace(/Make sure to /gi, "")
.replace(/You should /gi, "")
.replace(/You must /gi, "Must ")
.replace(/It is important to /gi, "")
.replace(/Keep in mind that /gi, "")
.replace(/\*\*Note\*\*:/g, "Note:")
.replace(/\*\*Important\*\*:/g, "Important:")
.replace(/\bimplementation\b/gi, "impl")
.replace(/\binformation\b/gi, "info")
.replace(/\bdirectory\b/gi, "dir")
.replace(/\bdirectories\b/gi, "dirs")
.replace(/\binitialization\b/gi, "init")
.replace(/\bconfiguration\b/gi, "config")
.replace(/\bparameters\b/gi, "params")
.replace(/\benvironment\b/gi, "env")
.replace(/\bdocumentation\b/gi, "docs");
})
.join("");

return compressed.trim();
}

function csvEscape(value: string): string {
const escaped = value.replace(/"/g, '""');
if (/[",\n\r]/.test(escaped)) {
return `"${escaped}"`;
}
return escaped;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Word substitutions corrupt inline code spans

compressMarkdown splits on triple-backtick fenced blocks (```…```) to skip those sections, but inline code using single backticks (e.g. `directory`, `configuration.yaml`, `getEnvironment()`) is not protected and gets rewritten by the word substitutions.

Examples of corruption:

  • Set the `directory` optionSet the `dir` option (broken option name)
  • Load `configuration.yaml`Load `config.yaml` (broken file name)
  • call `getEnvironment()`call `getEnv()` (broken method name)

An AI agent reading a compressed skill may generate calls to non-existent methods or reference wrong file names, producing hard-to-trace runtime bugs.

Extend the split pattern to also capture inline code spans before applying substitutions:

// Split on both fenced blocks AND inline code spans
const segments = content.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
const compressed = segments
    .map((segment) => {
        // Leave code spans/fences untouched
        if (segment.startsWith("```") || segment.startsWith("`")) {
            return segment;
        }
        return segment
            // ... existing replacements ...
    })
    .join("");
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/execution/skill-compress.ts
Line: 18-55

Comment:
**Word substitutions corrupt inline code spans**

`compressMarkdown` splits on triple-backtick fenced blocks `(```…```)` to skip those sections, but **inline code** using single backticks (e.g. `` `directory` ``, `` `configuration.yaml` ``, `` `getEnvironment()` ``) is not protected and gets rewritten by the word substitutions.

Examples of corruption:
- `` Set the `directory` option ```` Set the `dir` option `` (broken option name)
- `` Load `configuration.yaml` ```` Load `config.yaml` `` (broken file name)
- `` call `getEnvironment()` ```` call `getEnv()` `` (broken method name)

An AI agent reading a compressed skill may generate calls to non-existent methods or reference wrong file names, producing hard-to-trace runtime bugs.

Extend the split pattern to also capture inline code spans before applying substitutions:

```typescript
// Split on both fenced blocks AND inline code spans
const segments = content.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
const compressed = segments
    .map((segment) => {
        // Leave code spans/fences untouched
        if (segment.startsWith("```") || segment.startsWith("`")) {
            return segment;
        }
        return segment
            // ... existing replacements ...
    })
    .join("");
```

How can I resolve this? If you propose a fix, please make it concise.

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