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) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-16 14:10:29 -06:00
parent 062b5c65eb
commit d87a4423b0
2 changed files with 17 additions and 16 deletions

View file

@ -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);

View file

@ -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";
}