Harden tag mode tool permissions against prompt injection (#1002)

Two defenses for tag mode where an attacker with repo write access could
craft a prompt injection payload in an issue/PR to gain RCE on the
Actions runner:

1. git-push wrapper (H1 #3556799)
   The Bash(git\ push:*) rule permitted arbitrary flags and remotes,
   including combinations that execute shell commands locally. Replaced
   with scripts/git-push.sh which allowlists exactly 'origin <ref>' with
   no flags, validates the ref via check-ref-format. Same pattern as
   scripts/gh.sh.

2. acceptEdits instead of blanket Write/Edit (Asana 1213310082312048)
   Edit/MultiEdit/Write in allowedTools granted write access to the
   whole runner filesystem (~/.bashrc etc). Removed from allowedTools
   and set --permission-mode acceptEdits, which auto-accepts edits
   inside cwd ($GITHUB_WORKSPACE) and denies outside. Headless SDK has
   no prompt handler so 'ask' becomes deny.

Also:
- Noted that create-prompt/index.ts exports ALLOWED_TOOLS env var that
  nothing reads. The live path is modes/tag/index.ts. Mirrored the fix
  in both so the file the H1 report likely points to stays in sync.
- Updated prompt text (3 callsites) to reference the wrapper.
- Updated tests (4 prompt-content asserts, 7 tool-list asserts).
This commit is contained in:
kashyap murali
2026-03-12 13:35:17 -07:00
committed by GitHub
parent 5d0cc745cd
commit f956510b1a
5 changed files with 84 additions and 42 deletions
+13 -15
View File
@@ -23,19 +23,15 @@ import { GITHUB_SERVER_URL } from "../github/api/config";
import { extractUserRequest } from "../utils/extract-user-request";
export type { CommonFields, PreparedContext } from "./types";
const GIT_PUSH_WRAPPER = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
/** Filename for the user request file, read by the SDK runner */
const USER_REQUEST_FILENAME = "claude-user-request.txt";
// Tag mode defaults - these tools are needed for tag mode to function
const BASE_ALLOWED_TOOLS = [
"Edit",
"MultiEdit",
"Glob",
"Grep",
"LS",
"Read",
"Write",
];
// Tag mode defaults - these tools are needed for tag mode to function.
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside it.
const BASE_ALLOWED_TOOLS = ["Glob", "Grep", "LS", "Read"];
export function buildAllowedToolsString(
customAllowedTools?: string[],
@@ -59,7 +55,7 @@ export function buildAllowedToolsString(
baseTools.push(
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
`Bash(${GIT_PUSH_WRAPPER}:*)`,
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
@@ -434,7 +430,7 @@ function getCommitInstructions(
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
: ""
}
- Push to the remote: Bash(git push origin HEAD)`;
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin HEAD)`;
} else {
const branchName = eventData.claudeBranch || eventData.baseBranch;
return `
@@ -448,7 +444,7 @@ function getCommitInstructions(
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
: ""
}
- Push to the remote: Bash(git push origin ${branchName})`;
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin ${branchName})`;
}
}
}
@@ -823,7 +819,7 @@ ${
: `- Use git commands via the Bash tool for version control (remember that you have access to these git commands):
- Stage files: Bash(git add <files>)
- Commit changes: Bash(git commit -m "<message>")
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
- Push to remote: Bash(${GIT_PUSH_WRAPPER} origin <branch>)
- Delete files: Bash(git rm <files>) followed by commit and push
- Check status: Bash(git status)
- View diff: Bash(git diff)${eventData.isPR && eventData.baseBranch ? `\n - IMPORTANT: For PR diffs, use: Bash(git diff origin/${eventData.baseBranch}...HEAD)` : ""}`
@@ -977,7 +973,9 @@ export async function createPrompt(
console.log("========================");
}
// Set allowed tools
// NOTE: these env var exports are dead — nothing reads ALLOWED_TOOLS / DISALLOWED_TOOLS.
// The live path is modes/tag/index.ts which builds --allowedTools into claudeArgs directly.
// Kept only so the H1 report's pointed-to file stays in sync with the live fix.
const hasActionsReadPermission = false;
const allAllowedTools = buildAllowedToolsString(
+11 -8
View File
@@ -114,16 +114,17 @@ export async function prepareTagMode({
tool.startsWith("mcp__github_"),
);
// Build claude_args for tag mode with required tools
// Tag mode REQUIRES these tools to function properly
const gitPushWrapper = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
// Build claude_args for tag mode with required tools.
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode (set below)
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside (e.g. ~/.bashrc).
// Listing them here would grant blanket write access to the whole runner (Asana 1213310082312048).
const tagModeTools = [
"Edit",
"MultiEdit",
"Glob",
"Grep",
"LS",
"Read",
"Write",
"mcp__github_comment__update_claude_comment",
"mcp__github_ci__get_ci_status",
"mcp__github_ci__get_workflow_run_details",
@@ -137,7 +138,7 @@ export async function prepareTagMode({
tagModeTools.push(
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
`Bash(${gitPushWrapper}:*)`,
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
@@ -171,8 +172,10 @@ export async function prepareTagMode({
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
// Add required tools for tag mode
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
// Add required tools for tag mode.
// acceptEdits: file edits auto-allowed inside cwd ($GITHUB_WORKSPACE), denied outside.
// Headless SDK has no prompt handler, so anything that falls through to "ask" is denied.
claudeArgs += ` --permission-mode acceptEdits --allowedTools "${tagModeTools.join(",")}"`;
// Append user's claude_args (which may have more --mcp-config flags)
if (userClaudeArgs) {