97937f46f7
* console UI improvements and cleanup - add verify workflow button and API endpoint for manual installation check - move env var check into PromptBox as blocking overlay (hoisted to RepoConsole) - extract FlagsCheatSheet modal, replace verbose flag hints everywhere - add info popovers for repo setup / post-checkout script descriptions - remove unused prAutoFixCiFailures schema fields and migration - default mentionAllowNonCollaborator to disabled for safety on public repos - update docs for triggers and getting started Co-authored-by: Cursor <cursoragent@cursor.com> * add diagnostic logging for push_branch bug investigation temporary [push-debug] logs to trace why getPushDestination falls back to origin/<localBranch> instead of using the correct remote branch name for same-repo PRs. Co-authored-by: Cursor <cursoragent@cursor.com> * add git config diagnostic to verify original bug cause Co-authored-by: Cursor <cursoragent@cursor.com> * temporarily disable StoredPushDest to test git config path Co-authored-by: Cursor <cursoragent@cursor.com> * remove diagnostic logging for push_branch investigation verified that StoredPushDest fix works correctly on preview repo. both the stored dest path and the git config fallback resolve to the correct remote branch in the GitHub Actions environment. Co-authored-by: Cursor <cursoragent@cursor.com> * fix formatting in AgentSettings and TriggersSettings Co-authored-by: Cursor <cursoragent@cursor.com> * pass derived env var state to PromptBox instead of raw secrets data eliminates duplicated derivation logic between RepoConsole and PromptBox by passing envVarMissing, envVarChecking, and agentKeyNames as props. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: prevent duplicate comment after PR review deletes progress comment progressCommentId now uses three states: undefined (no comment yet), number (active), null (deliberately deleted). After create_pull_request_review deletes the progress comment, subsequent report_progress calls skip instead of creating a new comment. Co-authored-by: Cursor <cursoragent@cursor.com> * effort descriptions, test ordering, husky, docs images, typo fix - rewrote delegation effort level descriptions to per-level breakdown - action-agents now waits for action-agnostic; action-agnostic waits for root - added husky + lint-staged (biome check --write on staged files) - updated triggers docs images and triggers.mdx content - fixed "figured" → "figures" typo on landing page - updated pnpm-lock.yaml Co-authored-by: Cursor <cursoragent@cursor.com> * Commit --------- Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { regex } from "arkregex";
|
|
import { type } from "arktype";
|
|
import { log } from "../utils/cli.ts";
|
|
import { $git } from "../utils/gitAuth.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import type { StoredPushDest, ToolContext } from "./server.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
type PushDestination = {
|
|
remoteName: string;
|
|
remoteBranch: string;
|
|
url: string;
|
|
};
|
|
|
|
/**
|
|
* get where git would actually push this branch.
|
|
* prefers the stored destination from toolState (set by checkout_pr) when it
|
|
* matches the current branch, because git config reads can silently fail in
|
|
* certain environments causing pushes to the wrong remote branch.
|
|
*
|
|
* falls back to reading branch.X.pushRemote and branch.X.merge from git config,
|
|
* and finally to origin/<branch> for branches created without checkout_pr.
|
|
*/
|
|
function getPushDestination(
|
|
branch: string,
|
|
storedDest: StoredPushDest | undefined
|
|
): PushDestination {
|
|
// prefer stored destination from checkout_pr when it matches the current branch
|
|
if (storedDest && storedDest.localBranch === branch) {
|
|
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
|
|
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
|
|
log: false,
|
|
}).trim();
|
|
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
|
|
}
|
|
|
|
// fall back to git config (for branches not created by checkout_pr)
|
|
try {
|
|
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
|
const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
|
const remoteBranch = merge.replace(/^refs\/heads\//, "");
|
|
const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
|
|
return { remoteName: pushRemote, remoteBranch, url };
|
|
} catch {
|
|
// no push config - branch was created locally without checkout_pr
|
|
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
|
|
const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
|
|
return { remoteName: "origin", remoteBranch: branch, url };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* normalize URL for comparison (handle .git suffix, case)
|
|
*/
|
|
function normalizeUrl(url: string): string {
|
|
return url.replace(/\.git$/, "").toLowerCase();
|
|
}
|
|
|
|
type ValidatePushParams = {
|
|
branch: string;
|
|
pushUrl: string;
|
|
storedDest: StoredPushDest | undefined;
|
|
};
|
|
|
|
/**
|
|
* validate that the push destination matches expected URL.
|
|
* pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
|
|
*/
|
|
function validatePushDestination(params: ValidatePushParams): PushDestination {
|
|
const dest = getPushDestination(params.branch, params.storedDest);
|
|
|
|
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) {
|
|
throw new Error(
|
|
`Push blocked: destination does not match expected repository.\n` +
|
|
`Expected: ${params.pushUrl}\n` +
|
|
`Actual: ${dest.url}\n` +
|
|
`Git configuration may have been tampered with.`
|
|
);
|
|
}
|
|
|
|
return dest;
|
|
}
|
|
|
|
export const PushBranch = type({
|
|
branchName: type.string
|
|
.describe("The branch name to push (defaults to current branch)")
|
|
.optional(),
|
|
force: type.boolean.describe("Force push (use with caution)").default(false),
|
|
});
|
|
|
|
export function PushBranchTool(ctx: ToolContext) {
|
|
const defaultBranch = ctx.repo.data.default_branch || "main";
|
|
const pushPermission = ctx.payload.push;
|
|
|
|
return tool({
|
|
name: "push_branch",
|
|
description:
|
|
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
|
|
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
|
|
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
|
|
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
|
parameters: PushBranch,
|
|
execute: execute(async ({ branchName, force }) => {
|
|
// permission check
|
|
if (pushPermission === "disabled") {
|
|
throw new Error("Push is disabled. This repository is configured for read-only access.");
|
|
}
|
|
|
|
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
|
|
// validate push destination matches expected URL
|
|
const pushUrl = ctx.toolState.pushUrl;
|
|
if (!pushUrl) {
|
|
throw new Error("pushUrl not set - setupGit must run before push_branch");
|
|
}
|
|
const pushDest = validatePushDestination({
|
|
branch,
|
|
pushUrl,
|
|
storedDest: ctx.toolState.pushDest,
|
|
});
|
|
|
|
// block pushes to default branch in restricted mode
|
|
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
|
throw new Error(
|
|
`Push blocked: cannot push directly to default branch '${pushDest.remoteBranch}'. ` +
|
|
`Create a feature branch and open a PR instead.`
|
|
);
|
|
}
|
|
|
|
// use refspec when local and remote branch names differ
|
|
const refspec =
|
|
branch === pushDest.remoteBranch ? branch : `${branch}:${pushDest.remoteBranch}`;
|
|
const pushArgs = force
|
|
? ["--force", "-u", pushDest.remoteName, refspec]
|
|
: ["-u", pushDest.remoteName, refspec];
|
|
|
|
log.debug(`pushing ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`);
|
|
if (force) {
|
|
log.warning(`force pushing - this will overwrite remote history`);
|
|
}
|
|
$git("push", pushArgs, {
|
|
token: ctx.gitToken,
|
|
restricted: ctx.payload.bash !== "enabled",
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
branch,
|
|
remoteBranch: pushDest.remoteBranch,
|
|
remote: pushDest.remoteName,
|
|
force,
|
|
message: `successfully pushed ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch}`,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
// commands that require authentication - redirect to dedicated tools
|
|
const AUTH_REQUIRED_REDIRECT: Record<string, string> = {
|
|
push: "Use push_branch tool instead.",
|
|
fetch: "Use git_fetch tool instead.",
|
|
pull: "Use git_fetch + git merge instead.",
|
|
clone: "Repository already cloned. Use checkout_pr for PR branches.",
|
|
};
|
|
|
|
// SECURITY: subcommands blocked when bash is disabled.
|
|
// in disabled mode the agent has NO shell access, so these subcommands are the
|
|
// primary escape vectors for arbitrary code execution. in restricted mode the
|
|
// agent already has bash in a stripped sandbox, so blocking these is redundant.
|
|
const NOBASH_BLOCKED_SUBCOMMANDS: Record<string, string> = {
|
|
config: "Blocked: git config can set up filter drivers or hooks that execute arbitrary code.",
|
|
submodule:
|
|
"Blocked: git submodule can reference malicious repositories and execute code on update.",
|
|
"update-index":
|
|
"Blocked: git update-index can modify index entries in ways that bypass file protections.",
|
|
"filter-branch": "Blocked: git filter-branch executes arbitrary code on repository history.",
|
|
replace: "Blocked: git replace can redirect object lookups.",
|
|
// subcommands that accept --exec or similar flags for arbitrary code execution
|
|
rebase: "Blocked: git rebase --exec can execute arbitrary shell commands.",
|
|
bisect: "Blocked: git bisect run can execute arbitrary shell commands.",
|
|
};
|
|
|
|
// SECURITY: subcommand-specific arg flags that execute code.
|
|
// only blocked when bash is disabled — in restricted mode the agent already
|
|
// has shell access in a stripped sandbox, so these provide no additional security.
|
|
//
|
|
// NOTE: global git flags like -c and --config-env are NOT included here
|
|
// because they only work before the subcommand. in the MCP tool, the
|
|
// subcommand is always first, so -c in args is parsed as a subcommand flag
|
|
// (e.g., git log -c = combined diff format), not config injection.
|
|
// the subcommand check (rejecting "-" prefix) already blocks that attack.
|
|
//
|
|
// matched as: arg === flag OR arg starts with flag + "="
|
|
// (avoids false positives like --exclude matching --exec)
|
|
const NOBASH_BLOCKED_ARGS = ["--exec", "--extcmd", "--upload-pack", "--receive-pack"];
|
|
|
|
// SECURITY: subcommand must match [a-z][a-z0-9-]* to reject flags passed as the subcommand.
|
|
// this blocks injection of global git options like -c, -C, --exec-path, --config-env, etc.
|
|
//
|
|
// critical attack: git -c "alias.x=!evil-command" x
|
|
// -> sets alias "x" to a shell command via -c config injection, then runs it
|
|
// -> achieves arbitrary code execution even with bash=disabled
|
|
const subcommandPattern = regex("^[a-z][a-z0-9-]*$");
|
|
|
|
const Git = type({
|
|
subcommand: type(subcommandPattern).describe("Git subcommand (e.g., 'status', 'log', 'diff')"),
|
|
args: type.string.array().describe("Additional arguments for the git command").optional(),
|
|
});
|
|
|
|
export function GitTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "git",
|
|
description:
|
|
"Run git commands. For push/fetch/pull, use the dedicated MCP tools instead (push_branch, git_fetch).",
|
|
parameters: Git,
|
|
execute: execute(async (params) => {
|
|
const subcommand = params.subcommand;
|
|
const args = params.args ?? [];
|
|
|
|
const redirect = AUTH_REQUIRED_REDIRECT[subcommand];
|
|
if (redirect) {
|
|
throw new Error(`git ${subcommand} requires authentication. ${redirect}`);
|
|
}
|
|
|
|
// SECURITY: block dangerous subcommands when bash is disabled.
|
|
// in restricted mode the agent has bash in a stripped sandbox, so blocking
|
|
// these through the MCP tool is redundant (agent can do it via bash).
|
|
if (ctx.payload.bash === "disabled") {
|
|
const blocked = NOBASH_BLOCKED_SUBCOMMANDS[subcommand];
|
|
if (blocked) {
|
|
throw new Error(blocked);
|
|
}
|
|
|
|
// block subcommand-specific flags that execute arbitrary code
|
|
for (const arg of args) {
|
|
const isBlocked = NOBASH_BLOCKED_ARGS.some(
|
|
(flag) => arg === flag || arg.startsWith(flag + "=")
|
|
);
|
|
if (isBlocked) {
|
|
throw new Error(
|
|
`Blocked: '${arg}' flag can execute arbitrary code and is not allowed.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const output = $("git", [subcommand, ...args]);
|
|
return { success: true, output };
|
|
}),
|
|
});
|
|
}
|
|
|
|
const GitFetch = type({
|
|
ref: type.string.describe("Ref to fetch: branch name, tag, or 'pull/N/head' for PRs"),
|
|
depth: type.number.describe("Fetch depth (for shallow clones)").optional(),
|
|
});
|
|
|
|
export function GitFetchTool(ctx: ToolContext) {
|
|
return tool({
|
|
name: "git_fetch",
|
|
description: "Fetch refs from remote repository. Use this instead of git fetch directly.",
|
|
parameters: GitFetch,
|
|
execute: execute(async (params) => {
|
|
const fetchArgs = ["--no-tags", "origin", params.ref];
|
|
if (params.depth !== undefined) {
|
|
fetchArgs.push(`--depth=${params.depth}`);
|
|
}
|
|
$git("fetch", fetchArgs, {
|
|
token: ctx.gitToken,
|
|
restricted: ctx.payload.bash !== "enabled",
|
|
});
|
|
return { success: true, ref: params.ref };
|
|
}),
|
|
});
|
|
}
|
|
|
|
const DeleteBranch = type({
|
|
branchName: type.string.describe("Remote branch to delete"),
|
|
});
|
|
|
|
export function DeleteBranchTool(ctx: ToolContext) {
|
|
const pushPermission = ctx.payload.push;
|
|
|
|
return tool({
|
|
name: "delete_branch",
|
|
description: "Delete a remote branch. Requires push: enabled permission.",
|
|
parameters: DeleteBranch,
|
|
execute: execute(async (params) => {
|
|
if (pushPermission !== "enabled") {
|
|
throw new Error(
|
|
"Branch deletion requires push: enabled permission. " +
|
|
"Current mode only allows pushing to non-protected branches."
|
|
);
|
|
}
|
|
|
|
$git("push", ["origin", "--delete", params.branchName], {
|
|
token: ctx.gitToken,
|
|
restricted: ctx.payload.bash !== "enabled",
|
|
});
|
|
return { success: true, deleted: params.branchName };
|
|
}),
|
|
});
|
|
}
|
|
|
|
const PushTags = type({
|
|
tag: type.string.describe("Tag name to push"),
|
|
force: type.boolean.describe("Force push the tag").default(false),
|
|
});
|
|
|
|
export function PushTagsTool(ctx: ToolContext) {
|
|
const pushPermission = ctx.payload.push;
|
|
|
|
return tool({
|
|
name: "push_tags",
|
|
description: "Push a tag to remote. Requires push: enabled permission.",
|
|
parameters: PushTags,
|
|
execute: execute(async (params) => {
|
|
if (pushPermission !== "enabled") {
|
|
throw new Error(
|
|
"Tag pushing requires push: enabled permission. " +
|
|
"Current mode only allows pushing branches."
|
|
);
|
|
}
|
|
|
|
const pushArgs = [...(params.force ? ["-f"] : []), "origin", `refs/tags/${params.tag}`];
|
|
$git("push", pushArgs, {
|
|
token: ctx.gitToken,
|
|
restricted: ctx.payload.bash !== "enabled",
|
|
});
|
|
return { success: true, tag: params.tag };
|
|
}),
|
|
});
|
|
}
|