Skip to content

Commit f956510

Browse files
authored
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).
1 parent 5d0cc74 commit f956510

File tree

5 files changed

+84
-42
lines changed

5 files changed

+84
-42
lines changed

scripts/git-push.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Wrapper around `git push` that only allows `origin <ref>` with no flags.
5+
# Defends against --receive-pack / --exec RCE and arbitrary-remote exfiltration
6+
# (H1 #3556799). `git push:*` in allowedTools permits `git push --receive-pack='sh -c ...' ext::sh`
7+
# which runs arbitrary shell on the Actions runner. This wrapper closes that.
8+
#
9+
# Usage:
10+
# git-push.sh origin HEAD
11+
# git-push.sh origin claude/issue-123-20260304
12+
13+
if [[ $# -ne 2 ]]; then
14+
echo "Error: exactly two arguments required: origin <ref>" >&2
15+
exit 1
16+
fi
17+
18+
for arg in "$@"; do
19+
if [[ "$arg" == -* ]]; then
20+
echo "Error: flags are not allowed (got: $arg)" >&2
21+
exit 1
22+
fi
23+
done
24+
25+
if [[ "$1" != "origin" ]]; then
26+
echo "Error: remote must be 'origin' (got: $1)" >&2
27+
exit 1
28+
fi
29+
30+
REF="$2"
31+
if [[ "$REF" != "HEAD" ]] && ! git check-ref-format --branch "$REF" >/dev/null 2>&1; then
32+
echo "Error: invalid ref: $REF" >&2
33+
exit 1
34+
fi
35+
36+
exec git push origin "$REF"

src/create-prompt/index.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,15 @@ import { GITHUB_SERVER_URL } from "../github/api/config";
2323
import { extractUserRequest } from "../utils/extract-user-request";
2424
export type { CommonFields, PreparedContext } from "./types";
2525

26+
const GIT_PUSH_WRAPPER = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
27+
2628
/** Filename for the user request file, read by the SDK runner */
2729
const USER_REQUEST_FILENAME = "claude-user-request.txt";
2830

29-
// Tag mode defaults - these tools are needed for tag mode to function
30-
const BASE_ALLOWED_TOOLS = [
31-
"Edit",
32-
"MultiEdit",
33-
"Glob",
34-
"Grep",
35-
"LS",
36-
"Read",
37-
"Write",
38-
];
31+
// Tag mode defaults - these tools are needed for tag mode to function.
32+
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode
33+
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside it.
34+
const BASE_ALLOWED_TOOLS = ["Glob", "Grep", "LS", "Read"];
3935

4036
export function buildAllowedToolsString(
4137
customAllowedTools?: string[],
@@ -59,7 +55,7 @@ export function buildAllowedToolsString(
5955
baseTools.push(
6056
"Bash(git add:*)",
6157
"Bash(git commit:*)",
62-
"Bash(git push:*)",
58+
`Bash(${GIT_PUSH_WRAPPER}:*)`,
6359
"Bash(git status:*)",
6460
"Bash(git diff:*)",
6561
"Bash(git log:*)",
@@ -434,7 +430,7 @@ function getCommitInstructions(
434430
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
435431
: ""
436432
}
437-
- Push to the remote: Bash(git push origin HEAD)`;
433+
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin HEAD)`;
438434
} else {
439435
const branchName = eventData.claudeBranch || eventData.baseBranch;
440436
return `
@@ -448,7 +444,7 @@ function getCommitInstructions(
448444
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
449445
: ""
450446
}
451-
- Push to the remote: Bash(git push origin ${branchName})`;
447+
- Push to the remote: Bash(${GIT_PUSH_WRAPPER} origin ${branchName})`;
452448
}
453449
}
454450
}
@@ -823,7 +819,7 @@ ${
823819
: `- Use git commands via the Bash tool for version control (remember that you have access to these git commands):
824820
- Stage files: Bash(git add <files>)
825821
- Commit changes: Bash(git commit -m "<message>")
826-
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
822+
- Push to remote: Bash(${GIT_PUSH_WRAPPER} origin <branch>)
827823
- Delete files: Bash(git rm <files>) followed by commit and push
828824
- Check status: Bash(git status)
829825
- 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(
977973
console.log("========================");
978974
}
979975

980-
// Set allowed tools
976+
// NOTE: these env var exports are dead — nothing reads ALLOWED_TOOLS / DISALLOWED_TOOLS.
977+
// The live path is modes/tag/index.ts which builds --allowedTools into claudeArgs directly.
978+
// Kept only so the H1 report's pointed-to file stays in sync with the live fix.
981979
const hasActionsReadPermission = false;
982980

983981
const allAllowedTools = buildAllowedToolsString(

src/modes/tag/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,17 @@ export async function prepareTagMode({
114114
tool.startsWith("mcp__github_"),
115115
);
116116

117-
// Build claude_args for tag mode with required tools
118-
// Tag mode REQUIRES these tools to function properly
117+
const gitPushWrapper = `${process.env.GITHUB_ACTION_PATH}/scripts/git-push.sh`;
118+
119+
// Build claude_args for tag mode with required tools.
120+
// Edit/MultiEdit/Write are intentionally omitted: acceptEdits permission mode (set below)
121+
// auto-allows file edits inside $GITHUB_WORKSPACE and denies writes outside (e.g. ~/.bashrc).
122+
// Listing them here would grant blanket write access to the whole runner (Asana 1213310082312048).
119123
const tagModeTools = [
120-
"Edit",
121-
"MultiEdit",
122124
"Glob",
123125
"Grep",
124126
"LS",
125127
"Read",
126-
"Write",
127128
"mcp__github_comment__update_claude_comment",
128129
"mcp__github_ci__get_ci_status",
129130
"mcp__github_ci__get_workflow_run_details",
@@ -137,7 +138,7 @@ export async function prepareTagMode({
137138
tagModeTools.push(
138139
"Bash(git add:*)",
139140
"Bash(git commit:*)",
140-
"Bash(git push:*)",
141+
`Bash(${gitPushWrapper}:*)`,
141142
"Bash(git status:*)",
142143
"Bash(git diff:*)",
143144
"Bash(git log:*)",
@@ -171,8 +172,10 @@ export async function prepareTagMode({
171172
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
172173
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
173174

174-
// Add required tools for tag mode
175-
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
175+
// Add required tools for tag mode.
176+
// acceptEdits: file edits auto-allowed inside cwd ($GITHUB_WORKSPACE), denied outside.
177+
// Headless SDK has no prompt handler, so anything that falls through to "ask" is denied.
178+
claudeArgs += ` --permission-mode acceptEdits --allowedTools "${tagModeTools.join(",")}"`;
176179

177180
// Append user's claude_args (which may have more --mcp-config flags)
178181
if (userClaudeArgs) {

test/create-prompt.test.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22

3-
import { describe, test, expect } from "bun:test";
3+
import { describe, test, expect, beforeAll } from "bun:test";
44
import {
55
generatePrompt,
66
getEventTypeAndContext,
@@ -9,6 +9,10 @@ import {
99
} from "../src/create-prompt";
1010
import type { PreparedContext } from "../src/create-prompt";
1111

12+
beforeAll(() => {
13+
process.env.GITHUB_ACTION_PATH = "/test/action/path";
14+
});
15+
1216
describe("generatePrompt", () => {
1317
const mockGitHubData = {
1418
contextData: {
@@ -505,7 +509,7 @@ describe("generatePrompt", () => {
505509
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
506510

507511
// Should contain PR-specific instructions (git commands when not using signing)
508-
expect(prompt).toContain("git push");
512+
expect(prompt).toContain("scripts/git-push.sh origin");
509513
expect(prompt).toContain(
510514
"Always push to the existing branch when triggered on a PR",
511515
);
@@ -643,7 +647,7 @@ describe("generatePrompt", () => {
643647
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
644648

645649
// Should contain open PR instructions (git commands when not using signing)
646-
expect(prompt).toContain("git push");
650+
expect(prompt).toContain("scripts/git-push.sh origin");
647651
expect(prompt).toContain(
648652
"Always push to the existing branch when triggered on a PR",
649653
);
@@ -757,7 +761,7 @@ describe("generatePrompt", () => {
757761
expect(prompt).toContain("Use git commands via the Bash tool");
758762
expect(prompt).toContain("git add");
759763
expect(prompt).toContain("git commit");
760-
expect(prompt).toContain("git push");
764+
expect(prompt).toContain("scripts/git-push.sh origin");
761765

762766
// Should use the minimal comment tool
763767
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
@@ -886,17 +890,18 @@ describe("buildAllowedToolsString", () => {
886890
const result = buildAllowedToolsString();
887891

888892
// The base tools should be in the result
889-
expect(result).toContain("Edit");
893+
// Edit/MultiEdit/Write are NOT in allowedTools — acceptEdits permission mode handles them
894+
expect(result).not.toContain("Edit");
895+
expect(result).not.toContain("Write");
890896
expect(result).toContain("Glob");
891897
expect(result).toContain("Grep");
892898
expect(result).toContain("LS");
893899
expect(result).toContain("Read");
894-
expect(result).toContain("Write");
895900

896901
// Default is no commit signing, so should have specific Bash git commands
897902
expect(result).toContain("Bash(git add:*)");
898903
expect(result).toContain("Bash(git commit:*)");
899-
expect(result).toContain("Bash(git push:*)");
904+
expect(result).toContain("scripts/git-push.sh:*)");
900905
expect(result).toContain("mcp__github_comment__update_claude_comment");
901906

902907
// Should not have commit signing tools
@@ -908,12 +913,12 @@ describe("buildAllowedToolsString", () => {
908913
const result = buildAllowedToolsString([], false, false);
909914

910915
// The base tools should be in the result
911-
expect(result).toContain("Edit");
916+
expect(result).not.toContain("Edit");
912917
expect(result).toContain("Glob");
913918
expect(result).toContain("Grep");
914919
expect(result).toContain("LS");
915920
expect(result).toContain("Read");
916-
expect(result).toContain("Write");
921+
expect(result).not.toContain("Write");
917922

918923
// Should have specific Bash git commands for non-signing mode
919924
expect(result).toContain("Bash(git add:*)");
@@ -930,7 +935,7 @@ describe("buildAllowedToolsString", () => {
930935
const result = buildAllowedToolsString(customTools);
931936

932937
// Base tools should be present
933-
expect(result).toContain("Edit");
938+
expect(result).toContain("Read");
934939
expect(result).toContain("Glob");
935940

936941
// Custom tools should be appended
@@ -950,7 +955,7 @@ describe("buildAllowedToolsString", () => {
950955
const result = buildAllowedToolsString([], true);
951956

952957
// Base tools should be present
953-
expect(result).toContain("Edit");
958+
expect(result).toContain("Read");
954959
expect(result).toContain("Glob");
955960

956961
// GitHub Actions tools should be included
@@ -964,7 +969,7 @@ describe("buildAllowedToolsString", () => {
964969
const result = buildAllowedToolsString(customTools, true);
965970

966971
// Base tools should be present
967-
expect(result).toContain("Edit");
972+
expect(result).toContain("Read");
968973

969974
// Custom tools should be included
970975
expect(result).toContain("Tool1");
@@ -980,12 +985,12 @@ describe("buildAllowedToolsString", () => {
980985
const result = buildAllowedToolsString([], false, true);
981986

982987
// Base tools should be present
983-
expect(result).toContain("Edit");
988+
expect(result).not.toContain("Edit");
984989
expect(result).toContain("Glob");
985990
expect(result).toContain("Grep");
986991
expect(result).toContain("LS");
987992
expect(result).toContain("Read");
988-
expect(result).toContain("Write");
993+
expect(result).not.toContain("Write");
989994

990995
// Commit signing tools should be included
991996
expect(result).toContain("mcp__github_file_ops__commit_files");
@@ -1001,17 +1006,17 @@ describe("buildAllowedToolsString", () => {
10011006
const result = buildAllowedToolsString([], false, false);
10021007

10031008
// Base tools should be present
1004-
expect(result).toContain("Edit");
1009+
expect(result).not.toContain("Edit");
10051010
expect(result).toContain("Glob");
10061011
expect(result).toContain("Grep");
10071012
expect(result).toContain("LS");
10081013
expect(result).toContain("Read");
1009-
expect(result).toContain("Write");
1014+
expect(result).not.toContain("Write");
10101015

10111016
// Specific Bash git commands should be included
10121017
expect(result).toContain("Bash(git add:*)");
10131018
expect(result).toContain("Bash(git commit:*)");
1014-
expect(result).toContain("Bash(git push:*)");
1019+
expect(result).toContain("scripts/git-push.sh:*)");
10151020
expect(result).toContain("Bash(git status:*)");
10161021
expect(result).toContain("Bash(git diff:*)");
10171022
expect(result).toContain("Bash(git log:*)");
@@ -1030,7 +1035,7 @@ describe("buildAllowedToolsString", () => {
10301035
const result = buildAllowedToolsString(customTools, true, false);
10311036

10321037
// Base tools should be present
1033-
expect(result).toContain("Edit");
1038+
expect(result).toContain("Read");
10341039
expect(result).toContain("Bash(git add:*)");
10351040

10361041
// Custom tools should be included

test/pull-request-target.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ describe("pull_request_target event support", () => {
135135
const prompt = generatePrompt(envVars, mockGitHubData, false, "tag");
136136

137137
// Should include git commands for non-commit-signing mode
138-
expect(prompt).toContain("git push");
138+
expect(prompt).toContain("scripts/git-push.sh origin");
139139
expect(prompt).toContain(
140140
"Always push to the existing branch when triggered on a PR",
141141
);

0 commit comments

Comments
 (0)