-
Notifications
You must be signed in to change notification settings - Fork 2k
Harden tag mode tool permissions against prompt injection #1002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| # Wrapper around `git push` that only allows `origin <ref>` with no flags. | ||
| # Defends against --receive-pack / --exec RCE and arbitrary-remote exfiltration | ||
| # (H1 #3556799). `git push:*` in allowedTools permits `git push --receive-pack='sh -c ...' ext::sh` | ||
| # which runs arbitrary shell on the Actions runner. This wrapper closes that. | ||
| # | ||
| # Usage: | ||
| # git-push.sh origin HEAD | ||
| # git-push.sh origin claude/issue-123-20260304 | ||
|
|
||
| if [[ $# -ne 2 ]]; then | ||
| echo "Error: exactly two arguments required: origin <ref>" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| for arg in "$@"; do | ||
| if [[ "$arg" == -* ]]; then | ||
| echo "Error: flags are not allowed (got: $arg)" >&2 | ||
| exit 1 | ||
| fi | ||
| done | ||
|
|
||
| if [[ "$1" != "origin" ]]; then | ||
| echo "Error: remote must be 'origin' (got: $1)" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| REF="$2" | ||
| if [[ "$REF" != "HEAD" ]] && ! git check-ref-format --branch "$REF" >/dev/null 2>&1; then | ||
| echo "Error: invalid ref: $REF" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| exec git push origin "$REF" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
975
to
+977
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider removing this dead code: This block still calls A comment referencing the H1 report near the live code in |
||
| // Kept only so the H1 report's pointed-to file stays in sync with the live fix. | ||
| const hasActionsReadPermission = false; | ||
|
|
||
| const allAllowedTools = buildAllowedToolsString( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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:*)", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential remaining RCE vector via git hooks: The Worth verifying whether
|
||
| "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(",")}"`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Consider either appending the hardened flags after user args (so they take precedence), or stripping |
||
|
|
||
| // Append user's claude_args (which may have more --mcp-config flags) | ||
| if (userClaudeArgs) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Module-level env var capture: This top-level
constreadsprocess.env.GITHUB_ACTION_PATHat import time, not at call time. If the module is imported before the env var is set, this bakes in"undefined/scripts/git-push.sh".In production (GitHub Actions runtime), this is fine since the runner sets
GITHUB_ACTION_PATHbefore execution. But in tests,beforeAllsets the env var after the module is already imported, soGIT_PUSH_WRAPPERcapturesundefined. Tests currently pass because they check for"scripts/git-push.sh"as a substring rather than the full path, which masks the issue.Contrast with
src/modes/tag/index.tswhich correctly reads it inside the function body. Consider moving this insidebuildAllowedToolsString(or making it a getter) to match that pattern.