From d87a4423b0748dc8bafcf7cf907f128857e62ed8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Mon, 16 Mar 2026 14:10:29 -0600 Subject: [PATCH] fix: eliminate command injection surface in diff-context, harden file-watcher path resolution Use execFileSync with argument arrays instead of execSync with string interpolation to prevent shell injection via sinceDays parameter. Validate sinceDays as a positive integer. Replace string-based path resolution in file-watcher with path.relative() to prevent traversal via symlinks or .. segments. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/diff-context.ts | 23 ++++++++++---------- src/resources/extensions/gsd/file-watcher.ts | 10 ++++----- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/resources/extensions/gsd/diff-context.ts b/src/resources/extensions/gsd/diff-context.ts index 3260d1d06..e838ec1b1 100644 --- a/src/resources/extensions/gsd/diff-context.ts +++ b/src/resources/extensions/gsd/diff-context.ts @@ -6,7 +6,7 @@ * Standalone module: only imports node:child_process and node:path. */ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -32,8 +32,8 @@ const EXEC_OPTS = { stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], }; -function git(cmd: string, cwd: string): string { - return execSync(`git ${cmd}`, { ...EXEC_OPTS, cwd }).trim(); +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { ...EXEC_OPTS, cwd }).trim(); } function splitLines(output: string): string[] { @@ -62,13 +62,14 @@ export async function getRecentlyChangedFiles( // 1. Committed changes in the last N commits (or since sinceDays) let committedFiles: string[] = []; try { - const since = `--since="${sinceDays} days ago"`; - const raw = git(`log --diff-filter=ACMR --name-only --pretty=format: ${since}`, dir); + const days = Math.max(1, Math.floor(Number(sinceDays))); + if (!Number.isFinite(days)) throw new Error("invalid sinceDays"); + const raw = git(["log", "--diff-filter=ACMR", "--name-only", "--pretty=format:", `--since=${days} days ago`], dir); committedFiles = splitLines(raw); } catch { // Fallback: use HEAD~10 try { - const raw = git("diff --name-only HEAD~10", dir); + const raw = git(["diff", "--name-only", "HEAD~10"], dir); committedFiles = splitLines(raw); } catch { // Shallow clone or <10 commits — ignore @@ -78,7 +79,7 @@ export async function getRecentlyChangedFiles( // 2. Staged changes let stagedFiles: string[] = []; try { - const raw = git("diff --cached --name-only", dir); + const raw = git(["diff", "--cached", "--name-only"], dir); stagedFiles = splitLines(raw); } catch { // ignore @@ -87,7 +88,7 @@ export async function getRecentlyChangedFiles( // 3. Unstaged / untracked via porcelain status let statusFiles: string[] = []; try { - const raw = git("status --porcelain", dir); + const raw = git(["status", "--porcelain"], dir); statusFiles = splitLines(raw).map((line) => line.slice(3)); // strip XY + space } catch { // ignore @@ -131,7 +132,7 @@ export async function getChangedFilesWithContext( // 1. Staged files with numstat try { - const numstat = git("diff --cached --numstat", dir); + const numstat = git(["diff", "--cached", "--numstat"], dir); for (const line of splitLines(numstat)) { const [added, deleted, filePath] = line.split("\t"); if (!filePath) continue; @@ -147,7 +148,7 @@ export async function getChangedFilesWithContext( // 2. Unstaged modifications with numstat try { - const numstat = git("diff --numstat", dir); + const numstat = git(["diff", "--numstat"], dir); for (const line of splitLines(numstat)) { const [added, deleted, filePath] = line.split("\t"); if (!filePath) continue; @@ -163,7 +164,7 @@ export async function getChangedFilesWithContext( // 3. Untracked / deleted from porcelain status try { - const raw = git("status --porcelain", dir); + const raw = git(["status", "--porcelain"], dir); for (const line of splitLines(raw)) { const code = line.slice(0, 2); const filePath = line.slice(3); diff --git a/src/resources/extensions/gsd/file-watcher.ts b/src/resources/extensions/gsd/file-watcher.ts index 3d5e91586..98928ed62 100644 --- a/src/resources/extensions/gsd/file-watcher.ts +++ b/src/resources/extensions/gsd/file-watcher.ts @@ -1,5 +1,6 @@ import type { FSWatcher } from "chokidar"; import type { EventBus } from "@gsd/pi-coding-agent"; +import { relative } from "node:path"; let watcher: FSWatcher | null = null; @@ -50,17 +51,16 @@ export async function startFileWatcher( } function resolveEvent(filePath: string): string | null { - const relative = filePath - .replace(agentDir, "") - .replace(/^[/\\]+/, ""); + const rel = relative(agentDir, filePath); + if (rel.startsWith("..")) return null; // Check direct file matches for (const [file, event] of Object.entries(EVENT_MAP)) { - if (relative === file) return event; + if (rel === file) return event; } // Check extensions directory - if (relative.startsWith(EXTENSIONS_DIR + "/") || relative === EXTENSIONS_DIR) { + if (rel.startsWith(EXTENSIONS_DIR + "/") || rel === EXTENSIONS_DIR) { return "extensions-changed"; }