Compare commits

..

5 Commits

Author SHA1 Message Date
Claude
d544d7303b chore: fix markdown formatting in documentation
Remove unnecessary blank lines after list items in markdown files.
2026-01-07 18:49:13 +00:00
Claude
5c0f1e2273 feat: add clawd-stop label check to CI auto-fix workflow
Skip auto-fix when the PR has a 'clawd-stop' label. This provides a
stop-gap mechanism to prevent the auto-fix from running on specific PRs.
2026-01-07 18:48:24 +00:00
David Dworken
5da7ba548c feat: add path validation for commit_files MCP tool (#796)
Add validatePathWithinRepo helper to ensure file paths resolve within the repository root directory. This hardens the commit_files tool by validating paths before file operations.

Changes:
- Add src/mcp/path-validation.ts with async path validation using realpath
- Update commit_files to validate all paths before reading files
- Prevent symlink-based path escapes by resolving real paths
- Add comprehensive test coverage including symlink attack scenarios

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-07 10:16:31 -08:00
Ashwin Bhat
964b8355fb fix: use original title from webhook payload instead of fetched title (#793)
* fix: use original title from webhook payload instead of fetched title

- Add extractOriginalTitle() helper to extract title from webhook payload
- Add originalTitle parameter to fetchGitHubData()
- Update tag mode to pass original title from webhook context
- Add tests for extractOriginalTitle and originalTitle parameter

This ensures the title used in prompts is the one that existed when the
trigger event occurred, rather than a potentially modified title fetched
later via GraphQL.

* fix: add title sanitization and explicit TOCTOU test

- Apply sanitizeContent() to titles in formatContext() for defense-in-depth
- Add explicit test documenting TOCTOU prevention for title handling
2026-01-07 23:45:12 +05:30
orbisai0security
c83d67a9b9 fix: resolve high vulnerability CVE-2025-66414 (#792)
Automatically generated security fix

Co-authored-by: orbisai0security <orbisai0security@users.noreply.github.com>
2026-01-07 12:53:45 +05:30
16 changed files with 539 additions and 59 deletions

View File

@@ -17,7 +17,6 @@ TASK OVERVIEW:
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else. 1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
2. Next, use gh commands to get context about the issue: 2. Next, use gh commands to get context about the issue:
- Use `gh issue view ${{ github.event.issue.number }}` to retrieve the current issue's details - Use `gh issue view ${{ github.event.issue.number }}` to retrieve the current issue's details
- Use `gh search issues` to find similar issues that might provide context for proper categorization - Use `gh search issues` to find similar issues that might provide context for proper categorization
- You have access to these Bash commands: - You have access to these Bash commands:
@@ -27,7 +26,6 @@ TASK OVERVIEW:
- Bash(gh search:\*) - to search for similar issues - Bash(gh search:\*) - to search for similar issues
3. Analyze the issue content, considering: 3. Analyze the issue content, considering:
- The issue title and description - The issue title and description
- The type of issue (bug report, feature request, question, etc.) - The type of issue (bug report, feature request, question, etc.)
- Technical areas mentioned - Technical areas mentioned
@@ -36,7 +34,6 @@ TASK OVERVIEW:
- Components affected - Components affected
4. Select appropriate labels from the available labels list provided above: 4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature - Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive - Be specific but comprehensive
- IMPORTANT: Add a priority label (P1, P2, or P3) based on the label descriptions from gh label list - IMPORTANT: Add a priority label (P1, P2, or P3) based on the label descriptions from gh label list

View File

@@ -57,7 +57,6 @@ Thank you for your interest in contributing to Claude Code Base Action! This doc
``` ```
This script: This script:
- Installs `act` if not present (requires Homebrew on macOS) - Installs `act` if not present (requires Homebrew on macOS)
- Runs the GitHub Action workflow locally using Docker - Runs the GitHub Action workflow locally using Docker
- Requires your `ANTHROPIC_API_KEY` to be set - Requires your `ANTHROPIC_API_KEY` to be set

View File

@@ -85,26 +85,26 @@ Add the following to your workflow file:
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
| `prompt` | The prompt to send to Claude Code | No\* | '' | | `prompt` | The prompt to send to Claude Code | No\* | '' |
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' | | `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' | | `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' | | `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' | | `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' | | `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' | | `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
| `system_prompt` | Override system prompt | No | '' | | `system_prompt` | Override system prompt | No | '' |
| `append_system_prompt` | Append to system prompt | No | '' | | `append_system_prompt` | Append to system prompt | No | '' |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' | | `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' | | `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' | | `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' | | `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' | | `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' | | `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' |
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' | | `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' |
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' | | `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#-full-output-security-warning)) | No | 'false'\*\* | | `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#-full-output-security-warning)) | No | 'false'\*\* |
\*Either `prompt` or `prompt_file` must be provided, but not both. \*Either `prompt` or `prompt_file` must be provided, but not both.
@@ -490,7 +490,6 @@ This example shows how to use OIDC authentication with GCP Vertex AI:
To securely use your Anthropic API key: To securely use your Anthropic API key:
1. Add your API key as a repository secret: 1. Add your API key as a repository secret:
- Go to your repository's Settings - Go to your repository's Settings
- Navigate to "Secrets and variables" → "Actions" - Navigate to "Secrets and variables" → "Actions"
- Click "New repository secret" - Click "New repository secret"

View File

@@ -2,6 +2,6 @@
"name": "mcp-test", "name": "mcp-test",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0" "@modelcontextprotocol/sdk": "^1.24.0"
} }
} }

View File

@@ -116,7 +116,6 @@ The `additional_permissions` input allows Claude to access GitHub Actions workfl
To allow Claude to view workflow run results, job logs, and CI status: To allow Claude to view workflow run results, job logs, and CI status:
1. **Grant the necessary permission to your GitHub token**: 1. **Grant the necessary permission to your GitHub token**:
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow: - When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
```yaml ```yaml

View File

@@ -228,12 +228,10 @@ jobs:
The action now automatically detects the appropriate mode: The action now automatically detects the appropriate mode:
1. **If `prompt` is provided** → Runs in **automation mode** 1. **If `prompt` is provided** → Runs in **automation mode**
- Executes immediately without waiting for @claude mentions - Executes immediately without waiting for @claude mentions
- Perfect for scheduled tasks, PR automation, etc. - Perfect for scheduled tasks, PR automation, etc.
2. **If no `prompt` but @claude is mentioned** → Runs in **interactive mode** 2. **If no `prompt` but @claude is mentioned** → Runs in **interactive mode**
- Waits for and responds to @claude mentions - Waits for and responds to @claude mentions
- Creates tracking comments with progress - Creates tracking comments with progress

View File

@@ -75,14 +75,12 @@ Commits will show as verified and attributed to the GitHub account that owns the
``` ```
2. Add the **public key** to your GitHub account: 2. Add the **public key** to your GitHub account:
- Go to GitHub → Settings → SSH and GPG keys - Go to GitHub → Settings → SSH and GPG keys
- Click "New SSH key" - Click "New SSH key"
- Select **Key type: Signing Key** (important) - Select **Key type: Signing Key** (important)
- Paste the contents of `~/.ssh/signing_key.pub` - Paste the contents of `~/.ssh/signing_key.pub`
3. Add the **private key** to your repository secrets: 3. Add the **private key** to your repository secrets:
- Go to your repo → Settings → Secrets and variables → Actions - Go to your repo → Settings → Secrets and variables → Actions
- Create a new secret named `SSH_SIGNING_KEY` - Create a new secret named `SSH_SIGNING_KEY`
- Paste the contents of `~/.ssh/signing_key` - Paste the contents of `~/.ssh/signing_key`

View File

@@ -31,27 +31,23 @@ The fastest way to create a custom GitHub App is using our pre-configured manife
**🚀 [Download the Quick Setup Tool](./create-app.html)** (Right-click → "Save Link As" or "Download Linked File") **🚀 [Download the Quick Setup Tool](./create-app.html)** (Right-click → "Save Link As" or "Download Linked File")
After downloading, open `create-app.html` in your web browser: After downloading, open `create-app.html` in your web browser:
- **For Personal Accounts:** Click the "Create App for Personal Account" button - **For Personal Accounts:** Click the "Create App for Personal Account" button
- **For Organizations:** Enter your organization name and click "Create App for Organization" - **For Organizations:** Enter your organization name and click "Create App for Organization"
The tool will automatically configure all required permissions and submit the manifest. The tool will automatically configure all required permissions and submit the manifest.
Alternatively, you can use the manifest file directly: Alternatively, you can use the manifest file directly:
- Use the [`github-app-manifest.json`](../github-app-manifest.json) file from this repository - Use the [`github-app-manifest.json`](../github-app-manifest.json) file from this repository
- Visit https://github.com/settings/apps/new (for personal) or your organization's app settings - Visit https://github.com/settings/apps/new (for personal) or your organization's app settings
- Look for the "Create from manifest" option and paste the JSON content - Look for the "Create from manifest" option and paste the JSON content
2. **Complete the creation flow:** 2. **Complete the creation flow:**
- GitHub will show you a preview of the app configuration - GitHub will show you a preview of the app configuration
- Confirm the app name (you can customize it) - Confirm the app name (you can customize it)
- Click "Create GitHub App" - Click "Create GitHub App"
- The app will be created with all required permissions automatically configured - The app will be created with all required permissions automatically configured
3. **Generate and download a private key:** 3. **Generate and download a private key:**
- After creating the app, you'll be redirected to the app settings - After creating the app, you'll be redirected to the app settings
- Scroll down to "Private keys" - Scroll down to "Private keys"
- Click "Generate a private key" - Click "Generate a private key"
@@ -64,7 +60,6 @@ The fastest way to create a custom GitHub App is using our pre-configured manife
If you prefer to configure the app manually or need custom permissions: If you prefer to configure the app manually or need custom permissions:
1. **Create a new GitHub App:** 1. **Create a new GitHub App:**
- Go to https://github.com/settings/apps (for personal apps) or your organization's settings - Go to https://github.com/settings/apps (for personal apps) or your organization's settings
- Click "New GitHub App" - Click "New GitHub App"
- Configure the app with these minimum permissions: - Configure the app with these minimum permissions:
@@ -77,19 +72,16 @@ If you prefer to configure the app manually or need custom permissions:
- Create the app - Create the app
2. **Generate and download a private key:** 2. **Generate and download a private key:**
- After creating the app, scroll down to "Private keys" - After creating the app, scroll down to "Private keys"
- Click "Generate a private key" - Click "Generate a private key"
- Download the `.pem` file (keep this secure!) - Download the `.pem` file (keep this secure!)
3. **Install the app on your repository:** 3. **Install the app on your repository:**
- Go to the app's settings page - Go to the app's settings page
- Click "Install App" - Click "Install App"
- Select the repositories where you want to use Claude - Select the repositories where you want to use Claude
4. **Add the app credentials to your repository secrets:** 4. **Add the app credentials to your repository secrets:**
- Go to your repository's Settings → Secrets and variables → Actions - Go to your repository's Settings → Secrets and variables → Actions
- Add these secrets: - Add these secrets:
- `APP_ID`: Your GitHub App's ID (found in the app settings) - `APP_ID`: Your GitHub App's ID (found in the app settings)
@@ -138,7 +130,6 @@ For more information on creating GitHub Apps, see the [GitHub documentation](htt
To securely use your Anthropic API key: To securely use your Anthropic API key:
1. Add your API key as a repository secret: 1. Add your API key as a repository secret:
- Go to your repository's Settings - Go to your repository's Settings
- Navigate to "Secrets and variables" → "Actions" - Navigate to "Secrets and variables" → "Actions"
- Click "New repository secret" - Click "New repository secret"

View File

@@ -21,7 +21,26 @@ jobs:
!startsWith(github.event.workflow_run.head_branch, 'claude-auto-fix-ci-') !startsWith(github.event.workflow_run.head_branch, 'claude-auto-fix-ci-')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check for clawd-stop label
id: check_label
uses: actions/github-script@v7
with:
script: |
const prNumber = ${{ github.event.workflow_run.pull_requests[0].number }};
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const hasClawdStop = pr.labels.some(label => label.name === 'clawd-stop');
if (hasClawdStop) {
console.log('PR has clawd-stop label, skipping auto-fix');
}
return hasClawdStop;
result-encoding: string
- name: Checkout code - name: Checkout code
if: steps.check_label.outputs.result != 'true'
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ github.event.workflow_run.head_branch }} ref: ${{ github.event.workflow_run.head_branch }}
@@ -29,11 +48,13 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup git identity - name: Setup git identity
if: steps.check_label.outputs.result != 'true'
run: | run: |
git config --global user.email "claude[bot]@users.noreply.github.com" git config --global user.email "claude[bot]@users.noreply.github.com"
git config --global user.name "claude[bot]" git config --global user.name "claude[bot]"
- name: Create fix branch - name: Create fix branch
if: steps.check_label.outputs.result != 'true'
id: branch id: branch
run: | run: |
BRANCH_NAME="claude-auto-fix-ci-${{ github.event.workflow_run.head_branch }}-${{ github.run_id }}" BRANCH_NAME="claude-auto-fix-ci-${{ github.event.workflow_run.head_branch }}-${{ github.run_id }}"
@@ -41,6 +62,7 @@ jobs:
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Get CI failure details - name: Get CI failure details
if: steps.check_label.outputs.result != 'true'
id: failure_details id: failure_details
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
@@ -79,6 +101,7 @@ jobs:
}; };
- name: Fix CI failures with Claude - name: Fix CI failures with Claude
if: steps.check_label.outputs.result != 'true'
id: claude id: claude
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-action@v1
with: with:

View File

@@ -3,6 +3,8 @@ import type { Octokits } from "../api/client";
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github"; import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
import { import {
isIssueCommentEvent, isIssueCommentEvent,
isIssuesEvent,
isPullRequestEvent,
isPullRequestReviewEvent, isPullRequestReviewEvent,
isPullRequestReviewCommentEvent, isPullRequestReviewCommentEvent,
type ParsedGitHubContext, type ParsedGitHubContext,
@@ -40,6 +42,31 @@ export function extractTriggerTimestamp(
return undefined; return undefined;
} }
/**
* Extracts the original title from the GitHub webhook payload.
* This is the title as it existed when the trigger event occurred.
*
* @param context - Parsed GitHub context from webhook
* @returns The original title string or undefined if not available
*/
export function extractOriginalTitle(
context: ParsedGitHubContext,
): string | undefined {
if (isIssueCommentEvent(context)) {
return context.payload.issue?.title;
} else if (isPullRequestEvent(context)) {
return context.payload.pull_request?.title;
} else if (isPullRequestReviewEvent(context)) {
return context.payload.pull_request?.title;
} else if (isPullRequestReviewCommentEvent(context)) {
return context.payload.pull_request?.title;
} else if (isIssuesEvent(context)) {
return context.payload.issue?.title;
}
return undefined;
}
/** /**
* Filters comments to only include those that existed in their final state before the trigger time. * Filters comments to only include those that existed in their final state before the trigger time.
* This prevents malicious actors from editing comments after the trigger to inject harmful content. * This prevents malicious actors from editing comments after the trigger to inject harmful content.
@@ -146,6 +173,7 @@ type FetchDataParams = {
isPR: boolean; isPR: boolean;
triggerUsername?: string; triggerUsername?: string;
triggerTime?: string; triggerTime?: string;
originalTitle?: string;
}; };
export type GitHubFileWithSHA = GitHubFile & { export type GitHubFileWithSHA = GitHubFile & {
@@ -169,6 +197,7 @@ export async function fetchGitHubData({
isPR, isPR,
triggerUsername, triggerUsername,
triggerTime, triggerTime,
originalTitle,
}: FetchDataParams): Promise<FetchDataResult> { }: FetchDataParams): Promise<FetchDataResult> {
const [owner, repo] = repository.split("/"); const [owner, repo] = repository.split("/");
if (!owner || !repo) { if (!owner || !repo) {
@@ -354,6 +383,11 @@ export async function fetchGitHubData({
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername); triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
} }
// Use the original title from the webhook payload if provided
if (originalTitle !== undefined) {
contextData.title = originalTitle;
}
return { return {
contextData, contextData,
comments, comments,

View File

@@ -14,7 +14,8 @@ export function formatContext(
): string { ): string {
if (isPR) { if (isPR) {
const prData = contextData as GitHubPullRequest; const prData = contextData as GitHubPullRequest;
return `PR Title: ${prData.title} const sanitizedTitle = sanitizeContent(prData.title);
return `PR Title: ${sanitizedTitle}
PR Author: ${prData.author.login} PR Author: ${prData.author.login}
PR Branch: ${prData.headRefName} -> ${prData.baseRefName} PR Branch: ${prData.headRefName} -> ${prData.baseRefName}
PR State: ${prData.state} PR State: ${prData.state}
@@ -24,7 +25,8 @@ Total Commits: ${prData.commits.totalCount}
Changed Files: ${prData.files.nodes.length} files`; Changed Files: ${prData.files.nodes.length} files`;
} else { } else {
const issueData = contextData as GitHubIssue; const issueData = contextData as GitHubIssue;
return `Issue Title: ${issueData.title} const sanitizedTitle = sanitizeContent(issueData.title);
return `Issue Title: ${sanitizedTitle}
Issue Author: ${issueData.author.login} Issue Author: ${issueData.author.login}
Issue State: ${issueData.state}`; Issue State: ${issueData.state}`;
} }

View File

@@ -4,11 +4,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { readFile, stat } from "fs/promises"; import { readFile, stat } from "fs/promises";
import { join } from "path"; import { resolve } from "path";
import { constants } from "fs"; import { constants } from "fs";
import fetch from "node-fetch"; import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { retryWithBackoff } from "../utils/retry"; import { retryWithBackoff } from "../utils/retry";
import { validatePathWithinRepo } from "./path-validation";
type GitHubRef = { type GitHubRef = {
object: { object: {
@@ -213,12 +214,18 @@ server.tool(
throw new Error("GITHUB_TOKEN environment variable is required"); throw new Error("GITHUB_TOKEN environment variable is required");
} }
const processedFiles = files.map((filePath) => { // Validate all paths are within repository root and get full/relative paths
if (filePath.startsWith("/")) { const resolvedRepoDir = resolve(REPO_DIR);
return filePath.slice(1); const validatedFiles = await Promise.all(
} files.map(async (filePath) => {
return filePath; const fullPath = await validatePathWithinRepo(filePath, REPO_DIR);
}); // Calculate the relative path for the git tree entry
// Use the original filePath (normalized) for the git path, not the symlink-resolved path
const normalizedPath = resolve(resolvedRepoDir, filePath);
const relativePath = normalizedPath.slice(resolvedRepoDir.length + 1);
return { fullPath, relativePath };
}),
);
// 1. Get the branch reference (create if doesn't exist) // 1. Get the branch reference (create if doesn't exist)
const baseSha = await getOrCreateBranchRef( const baseSha = await getOrCreateBranchRef(
@@ -247,18 +254,14 @@ server.tool(
// 3. Create tree entries for all files // 3. Create tree entries for all files
const treeEntries = await Promise.all( const treeEntries = await Promise.all(
processedFiles.map(async (filePath) => { validatedFiles.map(async ({ fullPath, relativePath }) => {
const fullPath = filePath.startsWith("/")
? filePath
: join(REPO_DIR, filePath);
// Get the proper file mode based on file permissions // Get the proper file mode based on file permissions
const fileMode = await getFileMode(fullPath); const fileMode = await getFileMode(fullPath);
// Check if file is binary (images, etc.) // Check if file is binary (images, etc.)
const isBinaryFile = const isBinaryFile =
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test( /\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
filePath, relativePath,
); );
if (isBinaryFile) { if (isBinaryFile) {
@@ -284,7 +287,7 @@ server.tool(
if (!blobResponse.ok) { if (!blobResponse.ok) {
const errorText = await blobResponse.text(); const errorText = await blobResponse.text();
throw new Error( throw new Error(
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`, `Failed to create blob for ${relativePath}: ${blobResponse.status} - ${errorText}`,
); );
} }
@@ -292,7 +295,7 @@ server.tool(
// Return tree entry with blob SHA // Return tree entry with blob SHA
return { return {
path: filePath, path: relativePath,
mode: fileMode, mode: fileMode,
type: "blob", type: "blob",
sha: blobData.sha, sha: blobData.sha,
@@ -301,7 +304,7 @@ server.tool(
// For text files, include content directly in tree // For text files, include content directly in tree
const content = await readFile(fullPath, "utf-8"); const content = await readFile(fullPath, "utf-8");
return { return {
path: filePath, path: relativePath,
mode: fileMode, mode: fileMode,
type: "blob", type: "blob",
content: content, content: content,
@@ -421,7 +424,9 @@ server.tool(
author: newCommitData.author.name, author: newCommitData.author.name,
date: newCommitData.author.date, date: newCommitData.author.date,
}, },
files: processedFiles.map((path) => ({ path })), files: validatedFiles.map(({ relativePath }) => ({
path: relativePath,
})),
tree: { tree: {
sha: treeData.sha, sha: treeData.sha,
}, },

View File

@@ -0,0 +1,64 @@
import { realpath } from "fs/promises";
import { resolve, sep } from "path";
/**
* Validates that a file path resolves within the repository root.
* Prevents path traversal attacks via "../" sequences and symlinks.
* @param filePath - The file path to validate (can be relative or absolute)
* @param repoRoot - The repository root directory
* @returns The resolved absolute path (with symlinks resolved) if valid
* @throws Error if the path resolves outside the repository root
*/
export async function validatePathWithinRepo(
filePath: string,
repoRoot: string,
): Promise<string> {
// First resolve the path string (handles .. and . segments)
const initialPath = resolve(repoRoot, filePath);
// Resolve symlinks to get the real path
// This prevents symlink attacks where a link inside the repo points outside
let resolvedRoot: string;
let resolvedPath: string;
try {
resolvedRoot = await realpath(repoRoot);
} catch {
throw new Error(`Repository root '${repoRoot}' does not exist`);
}
try {
resolvedPath = await realpath(initialPath);
} catch {
// File doesn't exist yet - fall back to checking the parent directory
// This handles the case where we're creating a new file
const parentDir = resolve(initialPath, "..");
try {
const resolvedParent = await realpath(parentDir);
if (
resolvedParent !== resolvedRoot &&
!resolvedParent.startsWith(resolvedRoot + sep)
) {
throw new Error(
`Path '${filePath}' resolves outside the repository root`,
);
}
// Parent is valid, return the initial path since file doesn't exist yet
return initialPath;
} catch {
throw new Error(
`Path '${filePath}' resolves outside the repository root`,
);
}
}
// Path must be within repo root (or be the root itself)
if (
resolvedPath !== resolvedRoot &&
!resolvedPath.startsWith(resolvedRoot + sep)
) {
throw new Error(`Path '${filePath}' resolves outside the repository root`);
}
return resolvedPath;
}

View File

@@ -12,6 +12,7 @@ import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { import {
fetchGitHubData, fetchGitHubData,
extractTriggerTimestamp, extractTriggerTimestamp,
extractOriginalTitle,
} from "../../github/data/fetcher"; } from "../../github/data/fetcher";
import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context"; import { isEntityContext } from "../../github/context";
@@ -78,6 +79,7 @@ export const tagMode: Mode = {
const commentId = commentData.id; const commentId = commentData.id;
const triggerTime = extractTriggerTimestamp(context); const triggerTime = extractTriggerTimestamp(context);
const originalTitle = extractOriginalTitle(context);
const githubData = await fetchGitHubData({ const githubData = await fetchGitHubData({
octokits: octokit, octokits: octokit,
@@ -86,6 +88,7 @@ export const tagMode: Mode = {
isPR: context.isPR, isPR: context.isPR,
triggerUsername: context.actor, triggerUsername: context.actor,
triggerTime, triggerTime,
originalTitle,
}); });
// Setup branch // Setup branch

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, jest } from "bun:test"; import { describe, expect, it, jest } from "bun:test";
import { import {
extractTriggerTimestamp, extractTriggerTimestamp,
extractOriginalTitle,
fetchGitHubData, fetchGitHubData,
filterCommentsToTriggerTime, filterCommentsToTriggerTime,
filterReviewsToTriggerTime, filterReviewsToTriggerTime,
@@ -9,6 +10,7 @@ import {
import { import {
createMockContext, createMockContext,
mockIssueCommentContext, mockIssueCommentContext,
mockPullRequestCommentContext,
mockPullRequestReviewContext, mockPullRequestReviewContext,
mockPullRequestReviewCommentContext, mockPullRequestReviewCommentContext,
mockPullRequestOpenedContext, mockPullRequestOpenedContext,
@@ -63,6 +65,47 @@ describe("extractTriggerTimestamp", () => {
}); });
}); });
describe("extractOriginalTitle", () => {
it("should extract title from IssueCommentEvent on PR", () => {
const title = extractOriginalTitle(mockPullRequestCommentContext);
expect(title).toBe("Fix: Memory leak in user service");
});
it("should extract title from PullRequestReviewEvent", () => {
const title = extractOriginalTitle(mockPullRequestReviewContext);
expect(title).toBe("Refactor: Improve error handling in API layer");
});
it("should extract title from PullRequestReviewCommentEvent", () => {
const title = extractOriginalTitle(mockPullRequestReviewCommentContext);
expect(title).toBe("Performance: Optimize search algorithm");
});
it("should extract title from pull_request event", () => {
const title = extractOriginalTitle(mockPullRequestOpenedContext);
expect(title).toBe("Feature: Add user authentication");
});
it("should extract title from issues event", () => {
const title = extractOriginalTitle(mockIssueOpenedContext);
expect(title).toBe("Bug: Application crashes on startup");
});
it("should return undefined for event without title", () => {
const context = createMockContext({
eventName: "issue_comment",
payload: {
comment: {
id: 123,
body: "test",
},
} as any,
});
const title = extractOriginalTitle(context);
expect(title).toBeUndefined();
});
});
describe("filterCommentsToTriggerTime", () => { describe("filterCommentsToTriggerTime", () => {
const createMockComment = ( const createMockComment = (
createdAt: string, createdAt: string,
@@ -945,4 +988,115 @@ describe("fetchGitHubData integration with time filtering", () => {
); );
expect(hasPrBodyInMap).toBe(false); expect(hasPrBodyInMap).toBe(false);
}); });
it("should use originalTitle when provided instead of fetched title", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Fetched Title From GraphQL",
body: "PR body",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
originalTitle: "Original Title From Webhook",
});
expect(result.contextData.title).toBe("Original Title From Webhook");
});
it("should use fetched title when originalTitle is not provided", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Fetched Title From GraphQL",
body: "PR body",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
});
expect(result.contextData.title).toBe("Fetched Title From GraphQL");
});
it("should use original title from webhook even if title was edited after trigger", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 123,
title: "Edited Title (from GraphQL)",
body: "PR body",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "123",
isPR: true,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
originalTitle: "Original Title (from webhook at trigger time)",
});
expect(result.contextData.title).toBe(
"Original Title (from webhook at trigger time)",
);
});
}); });

View File

@@ -0,0 +1,214 @@
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
import { validatePathWithinRepo } from "../src/mcp/path-validation";
import { resolve } from "path";
import { mkdir, writeFile, symlink, rm, realpath } from "fs/promises";
import { tmpdir } from "os";
describe("validatePathWithinRepo", () => {
// Use a real temp directory for tests that need filesystem access
let testDir: string;
let repoRoot: string;
let outsideDir: string;
// Real paths after symlink resolution (e.g., /tmp -> /private/tmp on macOS)
let realRepoRoot: string;
beforeAll(async () => {
// Create test directory structure
testDir = resolve(tmpdir(), `path-validation-test-${Date.now()}`);
repoRoot = resolve(testDir, "repo");
outsideDir = resolve(testDir, "outside");
await mkdir(repoRoot, { recursive: true });
await mkdir(resolve(repoRoot, "src"), { recursive: true });
await mkdir(outsideDir, { recursive: true });
// Create test files
await writeFile(resolve(repoRoot, "file.txt"), "inside repo");
await writeFile(resolve(repoRoot, "src", "main.js"), "console.log('hi')");
await writeFile(resolve(outsideDir, "secret.txt"), "sensitive data");
// Get real paths after symlink resolution
realRepoRoot = await realpath(repoRoot);
});
afterAll(async () => {
// Cleanup
await rm(testDir, { recursive: true, force: true });
});
describe("valid paths", () => {
it("should accept simple relative paths", async () => {
const result = await validatePathWithinRepo("file.txt", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
});
it("should accept nested relative paths", async () => {
const result = await validatePathWithinRepo("src/main.js", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
});
it("should accept paths with single dot segments", async () => {
const result = await validatePathWithinRepo("./src/main.js", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
});
it("should accept paths that use .. but resolve inside repo", async () => {
// src/../file.txt resolves to file.txt which is still inside repo
const result = await validatePathWithinRepo("src/../file.txt", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
});
it("should accept absolute paths within the repo root", async () => {
const absolutePath = resolve(repoRoot, "file.txt");
const result = await validatePathWithinRepo(absolutePath, repoRoot);
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
});
it("should accept the repo root itself", async () => {
const result = await validatePathWithinRepo(".", repoRoot);
expect(result).toBe(realRepoRoot);
});
it("should handle new files (non-existent) in valid directories", async () => {
const result = await validatePathWithinRepo("src/newfile.js", repoRoot);
// For non-existent files, we validate the parent but return the initial path
// (can't realpath a file that doesn't exist yet)
expect(result).toBe(resolve(repoRoot, "src/newfile.js"));
});
});
describe("path traversal attacks", () => {
it("should reject simple parent directory traversal", async () => {
await expect(
validatePathWithinRepo("../outside/secret.txt", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
it("should reject deeply nested parent directory traversal", async () => {
await expect(
validatePathWithinRepo("../../../etc/passwd", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
it("should reject traversal hidden within path", async () => {
await expect(
validatePathWithinRepo("src/../../outside/secret.txt", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
it("should reject traversal at the end of path", async () => {
await expect(
validatePathWithinRepo("src/../..", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
it("should reject absolute paths outside the repo root", async () => {
await expect(
validatePathWithinRepo("/etc/passwd", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
it("should reject absolute paths to sibling directories", async () => {
await expect(
validatePathWithinRepo(resolve(outsideDir, "secret.txt"), repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
});
});
describe("symlink attacks", () => {
it("should reject symlinks pointing outside the repo", async () => {
// Create a symlink inside the repo that points to a file outside
const symlinkPath = resolve(repoRoot, "evil-link");
await symlink(resolve(outsideDir, "secret.txt"), symlinkPath);
try {
// The symlink path looks like it's inside the repo, but points outside
await expect(
validatePathWithinRepo("evil-link", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
} finally {
await rm(symlinkPath, { force: true });
}
});
it("should reject symlinks to parent directories", async () => {
// Create a symlink to the parent directory
const symlinkPath = resolve(repoRoot, "parent-link");
await symlink(testDir, symlinkPath);
try {
await expect(
validatePathWithinRepo("parent-link/outside/secret.txt", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
} finally {
await rm(symlinkPath, { force: true });
}
});
it("should accept symlinks that resolve within the repo", async () => {
// Create a symlink inside the repo that points to another file inside
const symlinkPath = resolve(repoRoot, "good-link");
await symlink(resolve(repoRoot, "file.txt"), symlinkPath);
try {
const result = await validatePathWithinRepo("good-link", repoRoot);
// Should resolve to the actual file location
expect(result).toBe(resolve(realRepoRoot, "file.txt"));
} finally {
await rm(symlinkPath, { force: true });
}
});
it("should reject directory symlinks that escape the repo", async () => {
// Create a symlink to outside directory
const symlinkPath = resolve(repoRoot, "escape-dir");
await symlink(outsideDir, symlinkPath);
try {
await expect(
validatePathWithinRepo("escape-dir/secret.txt", repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
} finally {
await rm(symlinkPath, { force: true });
}
});
});
describe("edge cases", () => {
it("should handle empty path (current directory)", async () => {
const result = await validatePathWithinRepo("", repoRoot);
expect(result).toBe(realRepoRoot);
});
it("should handle paths with multiple consecutive slashes", async () => {
const result = await validatePathWithinRepo("src//main.js", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "src/main.js"));
});
it("should handle paths with trailing slashes", async () => {
const result = await validatePathWithinRepo("src/", repoRoot);
expect(result).toBe(resolve(realRepoRoot, "src"));
});
it("should reject prefix attack (repo root as prefix but not parent)", async () => {
// Create a sibling directory with repo name as prefix
const evilDir = repoRoot + "-evil";
await mkdir(evilDir, { recursive: true });
await writeFile(resolve(evilDir, "file.txt"), "evil");
try {
await expect(
validatePathWithinRepo(resolve(evilDir, "file.txt"), repoRoot),
).rejects.toThrow(/resolves outside the repository root/);
} finally {
await rm(evilDir, { recursive: true, force: true });
}
});
it("should throw error for non-existent repo root", async () => {
await expect(
validatePathWithinRepo("file.txt", "/nonexistent/repo"),
).rejects.toThrow(/does not exist/);
});
});
});