Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/github/operations/branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined {
*
* Valid branch names:
* - Start with alphanumeric character (not dash, to prevent option injection)
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, or hash (#)
* - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), or at-sign (@)
* - Do not start or end with a period
* - Do not end with a slash
* - Do not contain '..' (path traversal)
Expand Down Expand Up @@ -58,16 +58,19 @@ export function validateBranchName(branchName: string): void {
);
}

// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period/hash/plus.
// Strict whitelist pattern: alphanumeric start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/at-sign.
// # is valid per git-check-ref-format and commonly used in branch names like "fix/#123-description".
// + is valid per git-check-ref-format and generated by Claude Code's EnterWorktree tool when
// converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo").
// All git calls use execFileSync (not shell interpolation), so neither # nor + carries injection risk.
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.#+-]*$/;
// @ is valid per git-check-ref-format and generated by multi-agent orchestration tools like
// Gastown (https://github.com/gastownhall/gastown) which produce names like "polecat/name@sessionid".
// Note: "@{" is still rejected below (git reflog syntax); bare "@" carries no injection risk since
// all git calls use execFileSync (not shell interpolation).
const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9/_.#+@-]*$/;

if (!validPattern.test(branchName)) {
throw new Error(
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), or plus signs (+).`,
`Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), or at-signs (@).`,
);
}

Expand Down
10 changes: 10 additions & 0 deletions test/validate-branch-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ describe("validateBranchName", () => {
expect(() => validateBranchName("fix+issue-123")).not.toThrow();
expect(() => validateBranchName("feature+new-thing")).not.toThrow();
});

it("should accept branch names containing @ (generated by multi-agent tools like Gastown)", () => {
// Gastown (https://github.com/gastownhall/gastown) generates branch names like
// "polecat/name/project@sessionid" where "@" separates the task name from a session ID.
expect(() =>
validateBranchName("polecat/nux/bo-gpl@moxcd5jj"),
).not.toThrow();
expect(() => validateBranchName("agent/task@abc123")).not.toThrow();
expect(() => validateBranchName("feature@session-id")).not.toThrow();
});
});

describe("command injection attempts", () => {
Expand Down