From 2dcbffe9c7c2a17c4ff6482cd638ff4655012487 Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 17:00:44 +0530 Subject: [PATCH 1/7] chore: Cherry-picked changes from upstream --- .claude/settings.json | 2 +- .github/egress-firewall.yaml | 29 ++ .github/scripts/check_workflow_hardening.py | 339 +++++++++++++ .github/workflows/test-base-action.yml | 17 +- .github/workflows/test-custom-executables.yml | 11 +- .github/workflows/test-mcp-servers.yml | 16 +- .github/workflows/test-settings.yml | 25 +- .github/workflows/test-structured-output.yml | 32 +- .github/workflows/workflow-hardening.yml | 23 + .prettierignore | 4 +- CLAUDE.md | 14 + action.yml | 13 +- base-action/README.md | 191 +++---- base-action/action.yml | 6 +- base-action/package.json | 2 +- base-action/src/parse-sdk-options.ts | 13 +- base-action/src/run-claude-sdk.ts | 41 ++ base-action/test/parse-sdk-options.test.ts | 42 ++ base-action/test/readme.test.ts | 50 ++ base-action/test/run-claude-sdk.test.ts | 156 ++++++ bun.lock | 21 +- docs/cloud-providers.md | 2 +- docs/configuration.md | 23 + docs/security.md | 12 +- package.json | 2 +- src/create-prompt/index.ts | 4 +- src/entrypoints/format-turns.ts | 28 +- .../post-buffered-inline-comments.ts | 3 +- src/entrypoints/prepare.ts | 10 +- src/entrypoints/run.ts | 20 +- src/entrypoints/update-comment-link.ts | 11 +- src/github/api/client.ts | 4 +- src/github/api/config.ts | 13 + src/github/api/queries/github.ts | 7 + src/github/context.ts | 6 + src/github/data/fetcher.ts | 151 +++++- src/github/data/formatter.ts | 13 +- src/github/operations/branch-cleanup.ts | 41 +- src/github/operations/branch.ts | 36 +- src/github/operations/comment-logic.ts | 10 +- src/github/operations/comments/common.ts | 7 +- src/github/operations/fetch-depth.ts | 40 ++ src/github/operations/git-config.ts | 64 ++- src/github/operations/restore-config.ts | 224 ++++++++- src/github/types.ts | 12 +- src/github/utils/actor-filter.ts | 25 + src/github/utils/image-downloader.ts | 115 ++++- src/github/utils/sanitizer.ts | 54 +- src/github/validation/permissions.ts | 48 +- src/github/validation/trigger.ts | 5 +- src/mcp/binary-detection.ts | 22 + src/mcp/github-actions-pagination.ts | 19 + src/mcp/github-actions-server.ts | 93 ++-- src/mcp/github-comment-server.ts | 4 +- src/mcp/github-file-ops-schemas.ts | 24 + src/mcp/github-file-ops-server.ts | 190 ++----- src/mcp/github-inline-comment-server.ts | 6 +- src/mcp/install-mcp-server.ts | 55 +- src/mcp/update-git-reference.ts | 90 ++++ src/modes/agent/index.ts | 11 + src/modes/detector.ts | 1 + src/modes/tag/index.ts | 15 +- src/utils/branch-template.ts | 19 +- test/action-metadata.test.ts | 15 + test/actor-filter.test.ts | 47 ++ test/binary-detection.test.ts | 79 +++ test/branch-cleanup-restored-config.test.ts | 262 ++++++++++ test/branch-template.test.ts | 63 +++ test/comments-common.test.ts | 14 +- test/create-prompt.test.ts | 35 ++ test/data-fetcher.test.ts | 428 +++++++++++++++- test/data-formatter.test.ts | 143 ++++++ test/delete-files-prompt-schema.test.ts | 169 +++++++ test/fetch-depth.test.ts | 116 +++++ test/fixtures/graphql-endpoint-probe.ts | 41 ++ test/format-turns.test.ts | 148 ++++++ test/git-config.test.ts | 193 +++++++ test/github-actions-pagination.test.ts | 49 ++ test/github-actions-server.test.ts | 94 ++++ test/github-context.test.ts | 11 + test/github-graphql-url.test.ts | 121 +++++ test/image-downloader.test.ts | 475 ++++++++++++++---- test/install-mcp-server.test.ts | 138 +++++ test/mockContext.ts | 8 +- test/modes/agent.test.ts | 64 ++- test/modes/detector.test.ts | 14 + test/modes/tag.test.ts | 93 +++- test/permissions.test.ts | 156 ++++++ test/public-comment-redaction.test.ts | 64 +++ test/restore-config.test.ts | 302 ++++++++++- test/sanitizer.test.ts | 141 ++++++ test/setup-branch-validation.test.ts | 80 +++ test/trigger-validation.test.ts | 14 + test/update-git-reference.test.ts | 90 ++++ test/validate-branch-name.test.ts | 9 + 95 files changed, 5682 insertions(+), 580 deletions(-) create mode 100644 .github/egress-firewall.yaml create mode 100755 .github/scripts/check_workflow_hardening.py create mode 100644 .github/workflows/workflow-hardening.yml create mode 100644 base-action/test/readme.test.ts create mode 100644 src/github/operations/fetch-depth.ts create mode 100644 src/mcp/binary-detection.ts create mode 100644 src/mcp/github-actions-pagination.ts create mode 100644 src/mcp/github-file-ops-schemas.ts create mode 100644 src/mcp/update-git-reference.ts create mode 100644 test/action-metadata.test.ts create mode 100644 test/binary-detection.test.ts create mode 100644 test/branch-cleanup-restored-config.test.ts create mode 100644 test/delete-files-prompt-schema.test.ts create mode 100644 test/fetch-depth.test.ts create mode 100644 test/fixtures/graphql-endpoint-probe.ts create mode 100644 test/git-config.test.ts create mode 100644 test/github-actions-pagination.test.ts create mode 100644 test/github-actions-server.test.ts create mode 100644 test/github-graphql-url.test.ts create mode 100644 test/public-comment-redaction.test.ts create mode 100644 test/setup-branch-validation.test.ts create mode 100644 test/update-git-reference.test.ts diff --git a/.claude/settings.json b/.claude/settings.json index 187232f..3bbb2db 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "bun run format" + "command": "bunx prettier@3.5.3 --no-config --write ." } ], "matcher": "Edit|Write|MultiEdit" diff --git a/.github/egress-firewall.yaml b/.github/egress-firewall.yaml new file mode 100644 index 0000000..c2fc588 --- /dev/null +++ b/.github/egress-firewall.yaml @@ -0,0 +1,29 @@ +# Hosts that jobs on GitHub's egress-firewall runner (runs-on: ubuntu-24.04-firewall) +# may reach. All other hosts are blocked, apart from any that GitHub's firewall allows +# by default. Add a host only when a workflow step needs it, name it in full (no '*'), and say what uses it. +mode: enforce +allow: + # Claude API: model requests, trading the workflow's GitHub identity token for a + # short-lived API token, and fetching the Claude GitHub App's token in claude.yml and + # claude-review.yml + - api.anthropic.com + # GitHub API: calls made by the Claude Code action, by gh in scripts/gh.sh and + # scripts/edit-issue-labels.sh, and by the /review-pr command + - api.github.com + # Claude Code install script: fetched by the Claude Code action, and by + # test-custom-executables.yml ("Install Claude Code manually") + - claude.ai + # Claude Code binary, downloaded by the install script + - downloads.claude.ai + # Bun and Node.js release downloads (oven-sh/setup-bun and actions/setup-node) + - release-assets.githubusercontent.com + # npm packages (bun install in action.yml, base-action/action.yml and + # test-mcp-servers.yml) + - registry.npmjs.org + # Bun install script (test-custom-executables.yml, "Install Bun manually") + - bun.sh + # apt packages bubblewrap and socat, which the Claude Code action installs when a + # workflow admits users without write access (issue-triage.yml) + - azure.archive.ubuntu.com + - archive.ubuntu.com + - security.ubuntu.com diff --git a/.github/scripts/check_workflow_hardening.py b/.github/scripts/check_workflow_hardening.py new file mode 100755 index 0000000..4ab0fd0 --- /dev/null +++ b/.github/scripts/check_workflow_hardening.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, breaks a rule in CLAUDE.md, +"Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the +Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID. +""" + +import json +import pathlib +import re +import shlex +import subprocess +import sys + +FIREWALL_RUNNER = "ubuntu-24.04-firewall" +WORKFLOW_DIR = pathlib.Path(".github/workflows") +POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml") +SIGN_IN_MARKER = "anthropic_federation_rule_id" +CLAUDE_ACTIONS = ("step-security/claude-code-action", "step-security/claude-code-base-action") +HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".' +# Claude Code runs auto mode only on claude-opus-4-6 and newer models. On an older model it +# falls back to its default permission mode, with no safety review. +AUTO_MODE_MIN_VERSION = (4, 6) +# The version in a model name: claude-opus-4-6, claude-sonnet-4-5-20250929, claude-3-5-sonnet-latest. +MODEL_VERSION = re.compile( + r"claude-(?:(?:opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?" + r"|(\d+)(?:-(\d{1,2}))?-(?:opus|sonnet|haiku))(?![\d.])" +) + +# Key: ":". Value: why that job is exempt from the table's rule. +EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {} +EXEMPT_FROM_AUTO_MODE: dict[str, str] = {} + + +def stop(message: str): + sys.exit(f"::error::{message}") + + +def load_yaml(path: pathlib.Path): + """Parse a YAML file with PyYAML, or with the yq command if PyYAML is absent.""" + try: + import yaml + except ImportError: + try: + result = subprocess.run( + ["yq", "-o=json", ".", str(path)], check=True, capture_output=True, text=True + ) + except FileNotFoundError: + stop( + f"Cannot read {path}: Python has no 'yaml' module and no 'yq' command was found. " + "Add a step that runs 'pip install pyyaml' before this check." + ) + except subprocess.CalledProcessError: + stop(f"Cannot read {path}: 'yq' could not parse it. Check that the file is valid YAML.") + return json.loads(result.stdout) + try: + with path.open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + except yaml.YAMLError as error: + stop(f"Cannot read {path}: it is not valid YAML ({error}).") + + +def contains_marker(node) -> bool: + """Whether any key or string under node contains SIGN_IN_MARKER, ignoring case.""" + if isinstance(node, dict): + return any(contains_marker(k) or contains_marker(v) for k, v in node.items()) + if isinstance(node, list): + return any(contains_marker(item) for item in node) + return isinstance(node, str) and SIGN_IN_MARKER in node.lower() + + +def load_local_action(uses: str): + """The parsed action file of a local action (uses: ./path), or None.""" + if not uses.startswith("./"): + return None + for name in ("action.yml", "action.yaml"): + action_file = pathlib.Path(uses) / name + if action_file.is_file(): + action = load_yaml(action_file) + return action if isinstance(action, dict) else {} + return None + + +def steps_of(job: dict) -> list[dict]: + return [step for step in job.get("steps") or [] if isinstance(step, dict)] + + +def runs_claude_code_action(step: dict) -> bool: + """Whether the step runs the Claude Code action: the published action, or a + local action that accepts a claude_args input.""" + uses = str(step.get("uses", "")) + if uses.lower().startswith(CLAUDE_ACTIONS): + return True + action = load_local_action(uses) + return action is not None and "claude_args" in (action.get("inputs") or {}) + + +def job_calls_claude(job: dict) -> bool: + if contains_marker(job): + return True + for step in steps_of(job): + if runs_claude_code_action(step): + return True + action = load_local_action(str(step.get("uses", ""))) + if action is not None and contains_marker(action): + return True + return False + + +def permission_mode_problem(step: dict, exempt: bool, inherited_env: dict) -> str | None: + """The message for a step whose permission mode is wrong, or None if it is right. + + A step must set auto mode, on a model that supports it. A step of a job in + EXEMPT_FROM_AUTO_MODE must set no mode at all. inherited_env is the workflow's and the + job's 'env'. + """ + inputs = step.get("with") or {} + lines = str(inputs.get("claude_args", "")).splitlines() + text = " ".join(line for line in lines if not line.strip().startswith("#")) + try: + args = shlex.split(text, comments=True) + except ValueError: + return "'claude_args' has a quote that is never closed. Close it" + modes = [] + for index, arg in enumerate(args): + if arg == "--dangerously-skip-permissions": + return ( + "remove '--dangerously-skip-permissions' from 'claude_args': " + "it turns off permission checks" + ) + if arg == "--permission-mode": + modes.append(args[index + 1] if index + 1 < len(args) else "") + elif arg.startswith("--permission-mode="): + modes.append(arg.split("=", 1)[1]) + if exempt and modes: + return ( + "remove '--permission-mode' from 'claude_args': this job is listed in " + "EXEMPT_FROM_AUTO_MODE (.github/scripts/check_workflow_hardening.py), and a job listed " + "there must not set a permission mode" + ) + if not modes and not exempt: + return "add '--permission-mode auto' to 'claude_args' under the step's 'with:'" + for mode in modes: + if mode == "": + return ( + "'claude_args' has '--permission-mode' with nothing after it. " + "Write '--permission-mode auto'" + ) + if mode != "auto": + return ( + f"'claude_args' has '--permission-mode {mode}'. " + "Change it to '--permission-mode auto'" + ) + env = {**inherited_env, **(step.get("env") or {})} + models = [ + ("the step's 'model'", inputs.get("model", "")), + ("ANTHROPIC_MODEL", env.get("ANTHROPIC_MODEL", "")), + ] + models += [ + (f"'{flag}' in 'claude_args'", value) + for flag in ("--model", "--fallback-model") + for value in flag_values(args, flag) + ] + settings = [("the step's 'settings'", inputs.get("settings", ""))] + settings += [ + ("'--settings' in 'claude_args'", value) for value in flag_values(args, "--settings") + ] + for where, value in settings: + text = settings_text(value) + if text is None: + return ( + f"{where} names a file outside the repository, which this check cannot read. " + "Use inline settings or a file inside the repository" + ) + if "defaultMode" in text: + return f"remove 'defaultMode' from {where}: settings must not set a permission mode" + try: + parsed = json.loads(text) if text else {} + except ValueError: + parsed = {} + if isinstance(parsed, dict) and "model" in parsed: + models.append((f"'model' in {where}", parsed["model"])) + if not exempt: + for where, model in models: + if predates_auto_mode(str(model or "")): + return ( + f"{where} is '{model}', which Claude Code does not run in auto mode: it " + "falls back to the default permission mode. Use claude-opus-4-6 or a newer model" + ) + return None + + +def flag_values(args: list[str], flag: str) -> list[str]: + """The values a flag in claude_args is given, as '--flag value' or '--flag=value'.""" + values = [ + args[index + 1] for index, arg in enumerate(args) if arg == flag and index + 1 < len(args) + ] + return values + [arg.split("=", 1)[1] for arg in args if arg.startswith(f"{flag}=")] + + +def predates_auto_mode(model: str) -> bool: + """Whether the model is older than AUTO_MODE_MIN_VERSION. A name with no version, such as + 'opus' or 'default', stands for a current model, except 'haiku' (claude-haiku-4-5).""" + if model.strip().lower() == "haiku": + return True + match = MODEL_VERSION.search(model.lower()) + if not match: + return False + major, minor = match.group(1, 2) if match.group(1) else match.group(3, 4) + return (int(major), int(minor or 0)) < AUTO_MODE_MIN_VERSION + + +def settings_text(value) -> str | None: + """The settings JSON a 'settings' value stands for: the value itself, or the contents of + the file it names when it is a path inside the repository. None when it names a path + outside the repository.""" + text = str(value or "").strip() + if not text or text.startswith("{"): + return text + path = pathlib.Path(text) + root = pathlib.Path.cwd().resolve() + try: + resolved = path.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + return None + if resolved.is_file(): + return resolved.read_text(encoding="utf-8", errors="replace") + return text + + +def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> list[str]: + key = f"{file_name}:{job_id}" + where = f".github/workflows/{file_name}: job '{job_id}'" + errors = [] + runs_on = job.get("runs-on") + if isinstance(runs_on, list) and len(runs_on) == 1: + runs_on = runs_on[0] + if key in EXEMPT_FROM_FIREWALL_RUNNER: + print( + f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. " + f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}." + ) + elif runs_on != FIREWALL_RUNNER: + if "runs-on" not in job: + has = "no 'runs-on'" + elif isinstance(job["runs-on"], str): + has = f"'runs-on: {job['runs-on']}'" + else: + has = "a 'runs-on' list or group" + errors.append( + f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. " + f"It has {has}. {HELP}" + ) + exempt = key in EXEMPT_FROM_AUTO_MODE + if exempt: + print( + f"Auto permission mode is not required for job '{job_id}' in {file_name}. " + f"Reason: {EXEMPT_FROM_AUTO_MODE[key]}." + ) + if "defaultMode" in json.dumps(job): + # Catches a settings file that an earlier step of the job writes, which the + # step-level check cannot read. + errors.append( + f"{where} mentions 'defaultMode': settings must not set a permission mode. {HELP}" + ) + for index, step in enumerate(steps_of(job), start=1): + if not runs_claude_code_action(step): + continue + inherited_env = {**workflow_env, **(job.get("env") or {})} + problem = permission_mode_problem(step, exempt, inherited_env) + if problem: + step_label = f"step '{step['name']}'" if "name" in step else f"step {index}" + errors.append(f"{where}, {step_label}: {problem}. {HELP}") + return errors + + +def check_policy() -> list[str]: + if not POLICY_PATH.is_file(): + return [ + f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it " + f"to limit outbound network access. {HELP}" + ] + policy = load_yaml(POLICY_PATH) + if not isinstance(policy, dict): + return [ + f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' " + f"and an 'allow:' list of hosts. {HELP}" + ] + errors = [] + if "mode" not in policy: + errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}") + elif policy["mode"] != "enforce": + errors.append( + f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}" + ) + allow = policy.get("allow") + if not isinstance(allow, list) or not allow: + errors.append( + f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' " + f"each host the jobs need. {HELP}" + ) + else: + for host in allow: + if "*" in str(host): + errors.append( + f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. " + f"Name each host in full. {HELP}" + ) + return errors + + +def main() -> int: + if not WORKFLOW_DIR.is_dir(): + stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.") + errors = [] + checked = 0 + for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]): + workflow = load_yaml(path) + jobs = workflow.get("jobs") if isinstance(workflow, dict) else None + for job_id, job in (jobs or {}).items(): + if not isinstance(job, dict) or not job_calls_claude(job): + continue + checked += 1 + errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {})) + if checked: + errors.extend(check_policy()) + for error in errors: + print(f"::error::{error}") + if errors: + return 1 + if checked == 0: + print("OK: no workflow job calls Claude, so there was nothing to check.") + else: + print(f"OK: checked {checked} job(s) that call Claude and found no problems.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/test-base-action.yml b/.github/workflows/test-base-action.yml index 39a5140..e0d5f3e 100644 --- a/.github/workflows/test-base-action.yml +++ b/.github/workflows/test-base-action.yml @@ -13,9 +13,16 @@ on: default: "List the files in the current directory starting with 'package'" workflow_call: +# Pin the model so these tests don't depend on the CLI's default model, +# which can change with each Claude Code release. +env: + ANTHROPIC_MODEL: claude-opus-5 + jobs: test-inline-prompt: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't access secrets for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -30,7 +37,7 @@ jobs: with: prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - allowed_tools: "LS,Read" + claude_args: '--allowedTools "LS,Read" --permission-mode auto' - name: Verify inline prompt output run: | @@ -69,7 +76,9 @@ jobs: fi test-prompt-file: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't access secrets for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -92,7 +101,7 @@ jobs: with: prompt_file: "test-prompt.txt" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - allowed_tools: "LS,Read" + claude_args: '--allowedTools "LS,Read" --permission-mode auto' - name: Verify prompt file output run: | diff --git a/.github/workflows/test-custom-executables.yml b/.github/workflows/test-custom-executables.yml index ddee8b1..87dde40 100644 --- a/.github/workflows/test-custom-executables.yml +++ b/.github/workflows/test-custom-executables.yml @@ -8,9 +8,16 @@ on: workflow_dispatch: workflow_call: +# Pin the model so these tests don't depend on the CLI's default model, +# which can change with each Claude Code release. +env: + ANTHROPIC_MODEL: claude-opus-5 + jobs: test-custom-executables: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -58,7 +65,7 @@ jobs: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} path_to_claude_code_executable: /home/runner/.local/bin/claude path_to_bun_executable: /home/runner/.bun/bin/bun - allowed_tools: "LS,Read" + claude_args: '--allowedTools "LS,Read" --permission-mode auto' - name: Verify custom executables worked run: | diff --git a/.github/workflows/test-mcp-servers.yml b/.github/workflows/test-mcp-servers.yml index ad3d5e9..fa034da 100644 --- a/.github/workflows/test-mcp-servers.yml +++ b/.github/workflows/test-mcp-servers.yml @@ -8,9 +8,16 @@ on: workflow_dispatch: workflow_call: +# Pin the model so these tests don't depend on the CLI's default model, +# which can change with each Claude Code release. +env: + ANTHROPIC_MODEL: claude-opus-5 + jobs: test-mcp-integration: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -35,7 +42,7 @@ jobs: with: prompt: "Call the test_tool tool and report its response." anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - claude_args: --allowedTools mcp__test-server__test_tool + claude_args: --allowedTools mcp__test-server__test_tool --permission-mode auto env: # Change to test directory so it finds .mcp.json CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test @@ -93,7 +100,9 @@ jobs: echo "✓ All MCP server checks passed!" test-mcp-config-flag: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -131,6 +140,7 @@ jobs: prompt: "Call the test_tool tool and report its response." anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools mcp__test-server__test_tool --mcp-config '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}' env: diff --git a/.github/workflows/test-settings.yml b/.github/workflows/test-settings.yml index b7c0da2..d7abd32 100644 --- a/.github/workflows/test-settings.yml +++ b/.github/workflows/test-settings.yml @@ -8,9 +8,16 @@ on: workflow_dispatch: workflow_call: +# Pin the model so these tests don't depend on the CLI's default model, +# which can change with each Claude Code release. +env: + ANTHROPIC_MODEL: claude-opus-5 + jobs: test-settings-inline-allow: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -26,6 +33,7 @@ jobs: prompt: | Use Bash to echo "Hello from settings test" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--permission-mode auto" settings: | { "permissions": { @@ -64,7 +72,9 @@ jobs: fi test-settings-inline-deny: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -80,6 +90,7 @@ jobs: prompt: | Run the command `echo $HOME` to check the home directory path anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--permission-mode auto" settings: | { "permissions": { @@ -101,7 +112,9 @@ jobs: fi test-settings-file-allow: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -127,6 +140,7 @@ jobs: prompt: | Use Bash to echo "Hello from settings file test" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--permission-mode auto" settings: "test-settings.json" - name: Verify echo worked @@ -160,7 +174,9 @@ jobs: fi test-settings-file-deny: - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -186,6 +202,7 @@ jobs: prompt: | Run the command `echo $HOME` to check the home directory path anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--permission-mode auto" settings: "test-settings.json" - name: Verify echo was denied diff --git a/.github/workflows/test-structured-output.yml b/.github/workflows/test-structured-output.yml index 1c04e9d..14d9612 100644 --- a/.github/workflows/test-structured-output.yml +++ b/.github/workflows/test-structured-output.yml @@ -5,10 +5,17 @@ on: workflow_dispatch: workflow_call: +# Pin the model so these tests don't depend on the CLI's default model, +# which can change with each Claude Code release. +env: + ANTHROPIC_MODEL: claude-opus-5 + jobs: test-basic-types: name: Test Basic Type Conversions - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -32,6 +39,7 @@ jobs: - boolean_false: false anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools Bash --json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}' @@ -72,7 +80,9 @@ jobs: test-complex-types: name: Test Arrays and Objects - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -95,6 +105,7 @@ jobs: - empty_array: [] anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools Bash --json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}' @@ -128,7 +139,9 @@ jobs: test-edge-cases: name: Test Edge Cases - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -152,6 +165,7 @@ jobs: - decimal: 3.14 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools Bash --json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}' @@ -192,7 +206,9 @@ jobs: test-name-sanitization: name: Test Output Name Sanitization - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -211,6 +227,7 @@ jobs: Return EXACTLY: {test-result: "passed", item_count: 10} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools Bash --json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}' @@ -237,7 +254,9 @@ jobs: test-execution-file-structure: name: Test Execution File Format - runs-on: ubuntu-latest + # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04-firewall steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -254,6 +273,7 @@ jobs: prompt: "Run: echo 'complete'. Return: {done: true}" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} claude_args: | + --permission-mode auto --allowedTools Bash --json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}' @@ -292,7 +312,7 @@ jobs: - test-edge-cases - test-name-sanitization - test-execution-file-structure - if: always() + if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/.github/workflows/workflow-hardening.yml b/.github/workflows/workflow-hardening.yml new file mode 100644 index 0000000..1ffc1e8 --- /dev/null +++ b/.github/workflows/workflow-hardening.yml @@ -0,0 +1,23 @@ +# Fails when a workflow job that calls Claude lacks its security settings. +# The rules are in CLAUDE.md, "Security hardening for GitHub Actions". +name: Security check for workflows that call Claude + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + workflow-hardening: + name: Check security settings + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check security settings of workflows that call Claude + run: python3 .github/scripts/check_workflow_hardening.py diff --git a/.prettierignore b/.prettierignore index 493026b..8c47f3c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ # Test fixtures should not be formatted to preserve exact output matching test/fixtures/ -.github/workflows \ No newline at end of file +.github/workflows +# Snapshot of PR-authored config kept for review; do not reformat +.claude-pr/ diff --git a/CLAUDE.md b/CLAUDE.md index 86de847..95256ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,3 +44,17 @@ Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare - `moduleResolution: "bundler"` — imports don't need `.js` extensions. - GitHub API calls should use retry logic (`src/utils/retry.ts`). - MCP servers are auto-installed at runtime to `~/.claude/mcp/github-{type}-server/`. + +## Security hardening for GitHub Actions + +Workflow jobs in this repository that call Claude run with three protections. Keep them when you add or edit a workflow. + +1. **Egress-firewall runner.** The job has `runs-on: ubuntu-24.04-firewall`, a GitHub-hosted runner that filters the job's outbound network traffic. Do not move a job that calls Claude to another runner. +2. **Network allow list.** `.github/egress-firewall.yaml` lists the hosts those jobs may reach, besides any that GitHub's firewall allows by default. Keep `mode: enforce`, which is what makes the firewall block the rest. Follow that file's header when you add a host. +3. **Auto permission mode.** Every step that runs the Claude Code action (`uses: step-security/claude-code-action`, or this repository's own `./` and `./base-action`) passes `--permission-mode auto` in `claude_args`. A tool call that needs permission and that the allowed tools do not cover then runs only if Claude Code's safety review passes it. Allow only the tools the job needs, and keep any `--disallowedTools` list a step has. Use `claude-opus-4-6` or a newer model: on an older one Claude Code falls back to its default permission mode. + +`claude.yml` answers `@claude` mentions. The Claude Code action sets `--permission-mode acceptEdits` for those, and the `--permission-mode auto` in the workflow's `claude_args`, which comes after it, replaces it. + +`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3, or when the allow list is missing, empty, not `mode: enforce`, or names a host with `*`. It cannot see a job that calls Claude another way, so check new workflows by hand too. If a job cannot meet protection 1 or 3, add it with the reason to the matching exemption table in `.github/scripts/check_workflow_hardening.py`. A job in `EXEMPT_FROM_AUTO_MODE` must set no permission mode at all. Do not skip or weaken the check. + +Keep each workflow's `permissions:` block minimal, and never print tokens or environment variables in workflow logs. diff --git a/action.yml b/action.yml index 7d84487..c3994c4 100644 --- a/action.yml +++ b/action.yml @@ -134,7 +134,7 @@ inputs: required: false default: "claude[bot]" track_progress: - description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events." + description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened, labeled) and issue (opened, edited, labeled, assigned) events." required: false default: "false" include_fix_links: @@ -167,6 +167,9 @@ inputs: default: "" outputs: + conclusion: + description: "Execution status of Claude Code ('success' or 'failure')" + value: ${{ steps.run.outputs.conclusion }} execution_file: description: "Path to the Claude Code execution output file" value: ${{ steps.run.outputs.execution_file }} @@ -238,6 +241,14 @@ runs: with: bun-version: 1.3.14 token: ${{ inputs.github_token || github.token }} + # Disable setup-bun's cache. The upstream save step uses a deterministic + # key (Bun version) and isn't ref-aware: on every second-and-subsequent + # run against the same PR ref the GitHub cache API rejects the duplicate + # key+ref with a 409 (HTML body), and @actions/cache treats the unparsable + # response as transient and burns ~20-30s on 5 retries before warning. + # The 35 MB Bun binary downloads in 2-3s, so disabling the cache is a net + # wallclock win and removes the noisy warning. See issue #1252. + no-cache: true - name: Setup Custom Bun Path if: inputs.path_to_bun_executable != '' diff --git a/base-action/README.md b/base-action/README.md index d7038fd..516f542 100644 --- a/base-action/README.md +++ b/base-action/README.md @@ -22,7 +22,7 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Or using a prompt from a file @@ -30,7 +30,7 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt_file: "/path/to/prompt.txt" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Or limiting the conversation turns @@ -38,8 +38,9 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" - max_turns: "5" # Limit conversation to 5 turns + claude_args: | + --allowedTools "Bash(git:*),Read,Glob,Grep" + --max-turns 5 anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Using custom system prompts @@ -47,8 +48,9 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Build a REST API" - system_prompt: "You are a senior backend engineer. Focus on security, performance, and maintainability." - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --system-prompt "You are a senior backend engineer. Focus on security, performance, and maintainability." + --allowedTools "Bash(git:*),Read,Glob,Grep" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Or appending to the default system prompt @@ -56,8 +58,9 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Create a database schema" - append_system_prompt: "After writing code, be sure to code review yourself." - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --append-system-prompt "After writing code, be sure to code review yourself." + --allowedTools "Bash(git:*),Read,Glob,Grep" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Using custom environment variables @@ -65,11 +68,15 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Deploy to staging environment" - claude_env: | - ENVIRONMENT: staging - API_URL: https://api-staging.example.com - DEBUG: true - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + settings: | + { + "env": { + "ENVIRONMENT": "staging", + "API_URL": "https://api-staging.example.com", + "DEBUG": "true" + } + } + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Using fallback model for handling API errors @@ -77,9 +84,10 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Review and fix TypeScript errors" - model: "claude-opus-4-1-20250805" - fallback_model: "claude-sonnet-4-20250514" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --model "claude-opus-4-1-20250805" + --fallback-model "claude-sonnet-4-20250514" + --allowedTools "Bash(git:*),Read,Glob,Grep" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Using OAuth token instead of API key @@ -87,7 +95,7 @@ Add the following to your workflow file: uses: step-security/claude-code-base-action@v1 with: prompt: "Update dependencies" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} ``` @@ -114,32 +122,28 @@ Do not set `anthropic_api_key` or `claude_code_oauth_token` alongside the federa ## Inputs -| Input | Description | Required | Default | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | -| `prompt` | 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 | '' | -| `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 | '' | -| `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 | '' | -| `system_prompt` | Override 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 | '' | -| `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' | -| `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 | '' | -| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' | -| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | '' | -| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | '' | -| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as (optional) | No | '' | -| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation. Optional when the rule targets a single workspace | No | '' | -| `anthropic_oidc_audience` | Audience to request on the GitHub OIDC token. Defaults to https://api.anthropic.com | No | '' | -| `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_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'\*\* | +| Input | Description | Required | Default | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ------------- | +| `prompt` | The prompt to send to Claude Code | No\* | `''` | +| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | `''` | +| `settings` | Claude Code settings as a JSON string or path to a settings JSON file | No | `''` | +| `claude_args` | Additional arguments to pass directly to the Claude CLI | No | `''` | +| `anthropic_api_key` | Anthropic API key for direct Anthropic API authentication | No | `''` | +| `claude_code_oauth_token` | Claude Code OAuth token as an alternative to an Anthropic API key | No | `''` | +| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | `''` | +| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | `''` | +| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as | No | `''` | +| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation | No | `''` | +| `anthropic_oidc_audience` | Audience for the GitHub OIDC token request | No | `''` | +| `use_bedrock` | Use Amazon Bedrock with OIDC authentication | No | `'false'` | +| `use_vertex` | Use Google Vertex AI with OIDC authentication | No | `'false'` | +| `use_foundry` | Use Microsoft Foundry with OIDC authentication | No | `'false'` | +| `use_node_cache` | Enable Node.js dependency caching for projects with lock files | No | `'false'` | +| `path_to_claude_code_executable` | Path to a custom Claude Code executable | No | `''` | +| `path_to_bun_executable` | Path to a custom Bun executable | No | `''` | +| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | `'false'`\*\* | +| `plugins` | Newline-separated Claude Code plugin names to install | No | `''` | +| `plugin_marketplaces` | Newline-separated plugin marketplace Git URLs to install | No | `''` | \*Either `prompt` or `prompt_file` must be provided, but not both. @@ -176,55 +180,28 @@ Example usage: ## Custom Environment Variables -You can pass custom environment variables to Claude Code execution using the `claude_env` input. This allows Claude to access environment-specific configuration during its execution. - -The `claude_env` input accepts YAML multiline format with key-value pairs: +You can pass custom environment variables to Claude Code through the `env` object in `settings`: ```yaml - name: Deploy with custom environment uses: step-security/claude-code-base-action@v1 with: prompt: "Deploy the application to the staging environment" - claude_env: | - ENVIRONMENT: staging - API_BASE_URL: https://api-staging.example.com - DATABASE_URL: ${{ secrets.STAGING_DB_URL }} - DEBUG: true - LOG_LEVEL: debug - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + settings: | + { + "env": { + "ENVIRONMENT": "staging", + "API_BASE_URL": "https://api-staging.example.com", + "DATABASE_URL": "${{ secrets.STAGING_DB_URL }}", + "DEBUG": "true", + "LOG_LEVEL": "debug" + } + } + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` -### Features: - -- **YAML Format**: Use standard YAML key-value syntax (`KEY: value`) -- **Multiline Support**: Define multiple environment variables in a single input -- **Comments**: Lines starting with `#` are ignored -- **GitHub Secrets**: Can reference GitHub secrets using `${{ secrets.SECRET_NAME }}` -- **Runtime Access**: Environment variables are available to Claude during execution - -### Example Use Cases: - -```yaml -# Development configuration -claude_env: | - NODE_ENV: development - API_URL: http://localhost:3000 - DEBUG: true - -# Production deployment -claude_env: | - NODE_ENV: production - API_URL: https://api.example.com - DATABASE_URL: ${{ secrets.PROD_DB_URL }} - REDIS_URL: ${{ secrets.REDIS_URL }} - -# Feature flags and configuration -claude_env: | - FEATURE_NEW_UI: enabled - MAX_RETRIES: 3 - TIMEOUT_MS: 5000 -``` +The `settings` input accepts either inline JSON or a path to a settings JSON file. Values in the `env` object are available during the Claude Code session and can reference GitHub secrets. ## Using Settings Configuration @@ -240,7 +217,7 @@ Provide a path to a JSON file containing Claude Code settings: with: prompt: "Your prompt here" settings: "path/to/settings.json" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` @@ -274,7 +251,7 @@ Provide the settings configuration directly as a JSON string: }] } } - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: '--allowedTools "Bash(git:*),Read,Glob,Grep"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` @@ -302,8 +279,9 @@ Provide a path to a JSON file containing MCP configuration: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - mcp_config: "path/to/mcp-config.json" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --mcp-config "path/to/mcp-config.json" + --allowedTools "Bash(git:*),Read,Glob,Grep" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` @@ -316,19 +294,9 @@ Provide the MCP configuration directly as a JSON string: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - mcp_config: | - { - "mcpServers": { - "server-name": { - "command": "node", - "args": ["./server.js"], - "env": { - "API_KEY": "your-api-key" - } - } - } - } - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: >- + --mcp-config '{"mcpServers":{"server-name":{"command":"node","args":["./server.js"],"env":{"API_KEY":"your-api-key"}}}}' + --allowedTools "Bash(git:*),Read,Glob,Grep" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` @@ -356,8 +324,9 @@ You can combine MCP config with other inputs like allowed tools: uses: step-security/claude-code-base-action@v1 with: prompt: "Access the custom MCP server and use its tools" - mcp_config: "mcp-config.json" - allowed_tools: "Bash(git:*),View,mcp__server-name__custom_tool" + claude_args: | + --mcp-config "mcp-config.json" + --allowedTools "Bash(git:*),Read,mcp__server-name__custom_tool" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ``` @@ -384,7 +353,7 @@ jobs: uses: step-security/claude-code-base-action@v1 with: prompt: "Review the PR changes. Focus on code quality, potential bugs, and performance issues. Suggest improvements where appropriate. Write your review as markdown text." - allowed_tools: "Bash(git diff --name-only HEAD~1),Bash(git diff HEAD~1),View,GlobTool,GrepTool,Write" + claude_args: '--allowedTools "Bash(git diff --name-only HEAD~1),Bash(git diff HEAD~1),Read,Glob,Grep,Write"' anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - name: Extract and Comment PR Review @@ -472,7 +441,7 @@ Use provider-specific model names based on your chosen provider: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - model: "claude-3-7-sonnet-20250219" + claude_args: "--model claude-3-7-sonnet-20250219" anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # For Amazon Bedrock (requires OIDC authentication) @@ -486,7 +455,7 @@ Use provider-specific model names based on your chosen provider: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - model: "anthropic.claude-3-7-sonnet-20250219-v1:0" + claude_args: "--model anthropic.claude-3-7-sonnet-20250219-v1:0" use_bedrock: "true" # For Google Vertex AI (requires OIDC authentication) @@ -500,7 +469,7 @@ Use provider-specific model names based on your chosen provider: uses: step-security/claude-code-base-action@v1 with: prompt: "Your prompt here" - model: "claude-3-7-sonnet@20250219" + claude_args: "--model claude-3-7-sonnet@20250219" use_vertex: "true" ``` @@ -520,8 +489,9 @@ This example shows how to use OIDC authentication with AWS Bedrock: with: prompt: "Your prompt here" use_bedrock: "true" - model: "anthropic.claude-3-7-sonnet-20250219-v1:0" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --model "anthropic.claude-3-7-sonnet-20250219-v1:0" + --allowedTools "Bash(git:*),Read,Glob,Grep" ``` ## Example: Using OIDC Authentication for GCP Vertex AI @@ -540,8 +510,9 @@ This example shows how to use OIDC authentication with GCP Vertex AI: with: prompt: "Your prompt here" use_vertex: "true" - model: "claude-3-7-sonnet@20250219" - allowed_tools: "Bash(git:*),View,GlobTool,GrepTool,BatchTool" + claude_args: | + --model "claude-3-7-sonnet@20250219" + --allowedTools "Bash(git:*),Read,Glob,Grep" ``` ## Security Best Practices diff --git a/base-action/action.yml b/base-action/action.yml index 7ec09ec..969b179 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -166,6 +166,8 @@ runs: uses: step-security/setup-bun@f6f5dadeac34f70c7828f731569e8d6e8330b8fb #v2.1.3 with: bun-version: 1.3.14 + # Disable setup-bun's cache. See action.yml for details and issue #1252. + no-cache: true - name: Setup Custom Bun Path if: inputs.path_to_bun_executable != '' @@ -190,7 +192,7 @@ runs: PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} run: | if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then - CLAUDE_CODE_VERSION="2.1.220" + CLAUDE_CODE_VERSION="2.1.286" echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." for attempt in 1 2 3; do echo "Installation attempt $attempt..." @@ -208,6 +210,8 @@ runs: sleep 5 done echo "Claude Code installed successfully" + # Add ~/.local/bin to PATH so the claude executable is available in subsequent steps + echo "$HOME/.local/bin" >> "$GITHUB_PATH" else echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE" # Add the directory containing the custom executable to PATH diff --git a/base-action/package.json b/base-action/package.json index 91ddeb6..8148f0f 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", + "@anthropic-ai/claude-agent-sdk": "^0.3.286", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/base-action/src/parse-sdk-options.ts b/base-action/src/parse-sdk-options.ts index a2c58d5..d9a5e74 100644 --- a/base-action/src/parse-sdk-options.ts +++ b/base-action/src/parse-sdk-options.ts @@ -204,6 +204,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { const modelFromClaudeArgs = extraArgs["model"] || undefined; delete extraArgs["model"]; + const maxTurnsFromClaudeArgs = extraArgs["max-turns"] || undefined; + delete extraArgs["max-turns"]; + const additionalDirectories = extraArgs["add-dir"] ? extraArgs["add-dir"] .split(ACCUMULATE_DELIMITER) @@ -286,6 +289,10 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { delete env.ACTIONS_ID_TOKEN_REQUEST_URL; delete env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + // Remove ALL_INPUTS as it is only needed during initial setup to determine + // input presence (collectActionInputsPresence) and contains serialized workflow inputs. + delete env.ALL_INPUTS; + // Build system prompt option - default to claude_code preset let systemPrompt: SdkOptions["systemPrompt"]; if (options.systemPrompt) { @@ -308,7 +315,11 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { const sdkOptions: SdkOptions = { // Direct options from ClaudeOptions inputs model: options.model || modelFromClaudeArgs, - maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined, + maxTurns: options.maxTurns + ? parseInt(options.maxTurns, 10) + : maxTurnsFromClaudeArgs + ? parseInt(maxTurnsFromClaudeArgs, 10) + : undefined, allowedTools: mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined, disallowedTools: diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index f83edf5..78d8665 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -82,6 +82,35 @@ async function createPromptConfig( return createMultiBlockMessage(); } +type ModelUsageSummary = Record< + string, + { + contextWindow: number; + maxOutputTokens: number; + } +>; + +/** + * Keep resolved model limits visible without exposing token usage or cost details. + */ +function sanitizeModelUsage( + modelUsage: SDKResultMessage["modelUsage"] | undefined, +): ModelUsageSummary | undefined { + if (!modelUsage) { + return undefined; + } + + return Object.fromEntries( + Object.entries(modelUsage).map(([model, usage]) => [ + model, + { + contextWindow: usage.contextWindow, + maxOutputTokens: usage.maxOutputTokens, + }, + ]), + ); +} + /** * Sanitizes SDK output to match CLI sanitization behavior */ @@ -119,6 +148,7 @@ function sanitizeSdkOutput( num_turns: resultMsg.num_turns, total_cost_usd: resultMsg.total_cost_usd, permission_denials_count: resultMsg.permission_denials?.length ?? 0, + modelUsage: sanitizeModelUsage(resultMsg.modelUsage), }, null, 2, @@ -208,6 +238,17 @@ export async function runClaudeWithSdk( throw new Error("No result message received from Claude"); } + if ( + resultMessage.subtype === "success" && + !resultMessage.is_error && + sdkOptions.maxTurns !== undefined && + resultMessage.num_turns > sdkOptions.maxTurns + ) { + const message = `Claude reported a successful result after ${resultMessage.num_turns} turns, exceeding the configured maximum of ${sdkOptions.maxTurns}`; + core.error(message); + throw new Error(message); + } + // subtype "success" with is_error:true means the run errored without producing // a real result — treat it as failure so CI does not show a misleading green check. const isSuccess = diff --git a/base-action/test/parse-sdk-options.test.ts b/base-action/test/parse-sdk-options.test.ts index 813bafd..25e8902 100644 --- a/base-action/test/parse-sdk-options.test.ts +++ b/base-action/test/parse-sdk-options.test.ts @@ -521,6 +521,31 @@ describe("parseSdkOptions", () => { }); }); + describe("max turns handling", () => { + test("should map --max-turns from claudeArgs to sdkOptions.maxTurns", () => { + const options: ClaudeOptions = { + claudeArgs: "--max-turns 60", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.maxTurns).toBe(60); + expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined(); + }); + + test("should prefer the direct maxTurns option", () => { + const options: ClaudeOptions = { + maxTurns: "25", + claudeArgs: "--max-turns 60", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.maxTurns).toBe(25); + expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined(); + }); + }); + describe("environment variables passthrough", () => { test("should include OTEL environment variables in sdkOptions.env", () => { // Set up test environment variables @@ -595,5 +620,22 @@ describe("parseSdkOptions", () => { process.env = originalEnv; } }); + + test("should strip ALL_INPUTS from env", () => { + const originalEnv = { ...process.env }; + process.env.ALL_INPUTS = JSON.stringify({ + anthropic_api_key: "sk-ant-test-key", + github_token: "ghp_test_token", + }); + + try { + const options: ClaudeOptions = {}; + const result = parseSdkOptions(options); + + expect(result.sdkOptions.env?.ALL_INPUTS).toBeUndefined(); + } finally { + process.env = originalEnv; + } + }); }); }); diff --git a/base-action/test/readme.test.ts b/base-action/test/readme.test.ts new file mode 100644 index 0000000..f82a9e1 --- /dev/null +++ b/base-action/test/readme.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const actionMetadata = readFileSync( + new URL("../action.yml", import.meta.url), + "utf8", +); +const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8"); + +describe("base action README", () => { + test("should document every input declared in the action metadata", () => { + const inputMetadata = actionMetadata.match( + /^inputs:\n([\s\S]*?)^outputs:/m, + )?.[1]; + const inputReference = readme.match( + /^## Inputs\n([\s\S]*?)^## Outputs/m, + )?.[1]; + + expect(inputMetadata).toBeDefined(); + expect(inputReference).toBeDefined(); + + const declaredInputs = [ + ...(inputMetadata?.matchAll(/^ ([a-z0-9_]+):$/gm) ?? []), + ].map((match) => match[1]); + const documentedInputs = [ + ...(inputReference?.matchAll(/^\| `([^`]+)`/gm) ?? []), + ].map((match) => match[1]); + + expect(documentedInputs).toEqual(declaredInputs); + }); + + test("should not use removed legacy inputs in workflow examples", () => { + const removedInputs = [ + "allowed_tools", + "disallowed_tools", + "max_turns", + "mcp_config", + "system_prompt", + "append_system_prompt", + "claude_env", + "model", + "anthropic_model", + "fallback_model", + ]; + + for (const input of removedInputs) { + expect(readme).not.toMatch(new RegExp(`^\\s+${input}:`, "m")); + } + }); +}); diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts index cb50318..25adfa1 100644 --- a/base-action/test/run-claude-sdk.test.ts +++ b/base-action/test/run-claude-sdk.test.ts @@ -64,6 +64,95 @@ describe("runClaudeWithSdk", () => { } }); + test("logs resolved model limits without exposing token usage", async () => { + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + + tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-")); + process.env.RUNNER_TEMP = tempDir; + + const promptPath = join(tempDir, "prompt.txt"); + await writeFile(promptPath, "test prompt"); + + const initMessage = { + type: "system", + subtype: "init", + session_id: "session-123", + model: "claude-opus-5", + }; + + const resultMessage = { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 434, + num_turns: 1, + total_cost_usd: 1.23, + permission_denials: [], + modelUsage: { + "claude-opus-5": { + inputTokens: 96209, + outputTokens: 55324, + cacheReadInputTokens: 1135701, + cacheCreationInputTokens: 149043, + webSearchRequests: 0, + costUSD: 1.23, + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + yield resultMessage; + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: {}, + showFullOutput: false, + hasJsonSchema: false, + }), + ).resolves.toMatchObject({ conclusion: "success" }); + + const sanitizedResult = consoleLogSpy.mock.calls + .map(([message]) => message) + .find( + (message) => + typeof message === "string" && message.includes('"type": "result"'), + ); + + expect(sanitizedResult).toBeDefined(); + if (typeof sanitizedResult !== "string") { + throw new Error("Sanitized result output was not logged"); + } + expect(JSON.parse(sanitizedResult)).toEqual({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 434, + num_turns: 1, + total_cost_usd: 1.23, + permission_denials_count: 0, + modelUsage: { + "claude-opus-5": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + }); + expect(sanitizedResult).not.toContain("inputTokens"); + expect(sanitizedResult).not.toContain("costUSD"); + } finally { + consoleLogSpy.mockRestore(); + } + }); + test("fails when result subtype is success but is_error is true", async () => { const consoleErrorSpy = spyOn(console, "error").mockImplementation( () => {}, @@ -128,4 +217,71 @@ describe("runClaudeWithSdk", () => { coreErrorSpy.mockRestore(); } }); + + test("fails closed when a successful result exceeds maxTurns", async () => { + const consoleErrorSpy = spyOn(console, "error").mockImplementation( + () => {}, + ); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + const coreErrorSpy = spyOn( + await import("@actions/core"), + "error", + ).mockImplementation(() => {}); + + tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-")); + process.env.RUNNER_TEMP = tempDir; + + const promptPath = join(tempDir, "prompt.txt"); + await writeFile(promptPath, "test prompt"); + + const initMessage = { + type: "system", + subtype: "init", + session_id: "session-123", + model: "claude-opus-4-7", + }; + + const successResultMessage = { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 960000, + num_turns: 73, + total_cost_usd: 0, + permission_denials: [], + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + yield successResultMessage; + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: { maxTurns: 60 }, + showFullOutput: false, + hasJsonSchema: false, + }), + ).rejects.toThrow( + "Claude reported a successful result after 73 turns, exceeding the configured maximum of 60", + ); + + const executionFile = join(tempDir, "claude-execution-output.json"); + await expect(readFile(executionFile, "utf-8")).resolves.toBe( + JSON.stringify([initMessage, successResultMessage], null, 2), + ); + expect(coreErrorSpy).toHaveBeenCalledWith( + "Claude reported a successful result after 73 turns, exceeding the configured maximum of 60", + ); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + coreErrorSpy.mockRestore(); + } + }); }); diff --git a/bun.lock b/bun.lock index eac4d32..5320d1b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,12 +1,13 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@step-security/claude-code-action", "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", + "@anthropic-ai/claude-agent-sdk": "^0.3.286", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -37,23 +38,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.286", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.286" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-InL/UNmRGSwBM/81PME0J0TZDsDBBlweWqRZgq2XSViSIg2hBi8nIL8j9Hm6MHRH85wgDJQE5n6Vo/r9hIO0NQ=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.286", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gkxWcJ+Z23UxwghI1V3dL09PkELIZmB2vPelR8XsdfhS+yP1KvoW7FThvRLojcxXb3fj0ddYawjQSQYIkXFbxw=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.286", "", { "os": "darwin", "cpu": "x64" }, "sha512-eMdni7sy1ud2IISI4QSsfVBCxotzSe62zCGdXISejL9MxDIwcRgEGZpO5OV+j7t8mNGMTe6Opl1t/3Z/1RUJaQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h+WWGek9beZ6i1qbIjjwYEFbs46D6qumjSBc6cLsEvnufcoi7mc0iBwbFSh9yRb3upxoi4ZTJdl53T3gLTdUQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-fG8Cqx53jkFyEL86ETA0tdLH3p06yFQKHcoxnMpiU+VmJ5g6uGFhsUZbJ6gFvuT3EgyMcNI2kZwhCCDF/DTnpQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-kNczbWhWJ1G8sPRZd4Nsx/Ozr/kx16lT2NGp/EEmTV4Hn732xfLkUOlg+tFcS78i6lOPBknjDB/cz/J5Ha4wDQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-WeO/wG2uPh95BhOQi1IsIECdW5gJgC1Q9xtGw4RjV04it1fBMAbUe7aVwP4DQgH8PHnmduA7dRnLtbnZnrGsjg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.286", "", { "os": "win32", "cpu": "arm64" }, "sha512-N4p7Gw5Qg3q9+Y5sEht1cIHHZkmBAUfWXJnqfmDD7N8twqF3XNVK3w65hDLqli4i0ttuZu79bGmRGkgNGm9Imw=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.286", "", { "os": "win32", "cpu": "x64" }, "sha512-pg35GRPBKyviod0i8Z3EVMzDnTiiiucuWUbyH1bVIFFN0UWCQQ+PRUJ15qrPKjL6+vlFxOX2ei9FfsWYjGYZwA=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/docs/cloud-providers.md b/docs/cloud-providers.md index bcd44ef..5b61b45 100644 --- a/docs/cloud-providers.md +++ b/docs/cloud-providers.md @@ -7,7 +7,7 @@ You can authenticate with Claude using any of these four methods: 3. Google Vertex AI with OIDC authentication 4. Microsoft Foundry with OIDC authentication -For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#for-aws-bedrock:). +For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://code.claude.com/docs/en/github-actions#using-with-amazon-bedrock-and-google-cloud). **Note**: diff --git a/docs/configuration.md b/docs/configuration.md index f4c311f..cb73c43 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -275,6 +275,29 @@ For provider-specific models: # ... other inputs ``` +### 1M context models through an API gateway + +When `ANTHROPIC_BASE_URL` points to an Anthropic-compatible API gateway, +Claude Code may not be able to verify that the gateway supports a model's native +1M context window and can budget the session at 200K instead. Append the +`[1m]` selector to explicitly use the 1M context window for supported models, +including Claude Opus 5 and Claude Sonnet 5: + +```yaml +- uses: step-security/claude-code-action@v1 + with: + claude_args: | + --model "claude-opus-5[1m]" + # ... other inputs +``` + +Use the same selector when setting a model through `ANTHROPIC_MODEL` or another +Claude Code model environment variable. The selector is resolved by Claude Code +before requests are sent to the provider. The action's sanitized result output +includes each model's resolved +`contextWindow` and `maxOutputTokens` under `modelUsage`, so these limits are +visible without enabling `show_full_output`. + ## Claude Code Settings You can provide Claude Code settings to customize behavior such as model selection, environment variables, permissions, and hooks. Settings can be provided either as a JSON string or a path to a settings file. diff --git a/docs/security.md b/docs/security.md index 6b0a768..a66439e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ ## Access Control -- **Repository Access**: The action can only be triggered by users with write access to the repository +- **Repository Access**: The action can only be triggered by users with write access to the repository. This is checked for issue, pull request, comment, and review events, and for `workflow_run` events, where both the workflow actor and the actor that started the upstream run are checked. `workflow_dispatch`, `repository_dispatch`, and `schedule` events are not checked separately — GitHub itself requires write access to dispatch a workflow, and scheduled runs have no external actor. - **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots - **⚠️ Allowed bots are not checked for repository permissions.** A bot that matches an entry does **not** need to be installed on your repository or have write access. On a **public repository**, external parties — including GitHub Apps created by anyone — may be able to trigger workflow events such as opening issues, commenting, or reviewing pull requests. If your workflow listens on those events and `allowed_bots` is set to `'*'`, any such App can invoke this action with a prompt it controls. - Prefer an explicit list over `'*'` @@ -22,6 +22,8 @@ ## Using this action with `pull_request_target` or `workflow_run` +For `workflow_run` events, the action checks the repository access of the actor that started the upstream run (for example, the author of the fork pull request that triggered your CI workflow) in addition to the workflow actor. If that actor does not have write access, the action stops before running Claude. To run on `workflow_run` events downstream of pull requests from contributors without write access, add those users to `allowed_non_write_users` and pass `github_token: ${{ secrets.GITHUB_TOKEN }}` — see the notes on that input above and keep the workflow's permissions minimal. + `pull_request_target` and `workflow_run` execute with the **base repository's secrets**. If your workflow checks out the PR head (`ref: ${{ github.event.pull_request.head.sha }}` for `pull_request_target`, `ref: ${{ github.event.workflow_run.head_sha }}` for `workflow_run`) into `$GITHUB_WORKSPACE` before this action, the action and Claude run with that checkout as the working directory. **Do not check out an untrusted ref into the workspace root before this action.** Use one of these patterns instead: @@ -49,6 +51,14 @@ This is general guidance for these event types — see [GitHub's documentation](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/). +### Which files come from the base branch on pull requests + +When the action runs against a pull request, it restores a fixed list of Claude configuration paths from the PR base branch before starting Claude: `.claude/`, `.mcp.json`, `.claude.json`, `.gitmodules`, `.ripgreprc`, `CLAUDE.md`, `CLAUDE.local.md`, and `.husky/`. Paths in that list that do not exist on the base branch are removed, and the PR-authored versions are kept under `.claude-pr/` for reference only. + +Everything else in the working tree — including `package.json`, lockfiles, `Makefile`, `node_modules/`, and formatter/linter config files — stays at the PR head. If a hook, `apiKeyHelper`, or `statusLine` command in your base-branch `.claude/settings.json` runs a package-manager script (`bun run …`, `npm run …`, `yarn …`, `pnpm run …`), a `make` target, a repo-relative script, or a tool that loads executable project config, that command resolves through files the pull request supplies. Keep such commands self-contained: invoke the tool directly with a pinned version and pass its configuration on the command line (for example `bunx prettier@3.5.3 --no-config --write .` rather than `bun run format`). + +Note that the runtime executing the tool also reads project config. `bunx ` runs the tool's script under `node` when `node` is on `PATH` (as it is on GitHub-hosted runners); when only Bun is available, Bun executes the script itself and reads `bunfig.toml` from the checkout — including `preload` entries — which comes from the PR head. On such runners, make sure `node` is on `PATH` for the hook, and treat `bunfig.toml` and `.npmrc` in the checkout as PR-controlled runtime config. + ### `claude-code-action` vs `claude-code-base-action` `claude-code-base-action` is a lower-level building block that installs and runs Claude Code with the inputs you provide. It does not perform actor permission checks or restore project configuration from the base ref. If you need those behaviors, use this action (`claude-code-action`). See the [base-action README](../base-action/README.md#trust-model) for details. diff --git a/package.json b/package.json index 5272f79..3ba8778 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", + "@anthropic-ai/claude-agent-sdk": "^0.3.286", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index c7c7ba0..7f03ed0 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -824,7 +824,7 @@ ${ ? `- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk. Tool usage examples: - mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"} - - mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}` + - mcp__github_file_ops__delete_files: {"paths": ["path/to/old.js"], "message": "chore: remove deprecated file"}` : `- Use git commands via the Bash tool for version control (remember that you have access to these git commands): - Stage files: Bash(git add ) - Commit changes: Bash(git commit -m "") @@ -856,7 +856,7 @@ What You CANNOT Do: - Submit formal GitHub PR reviews - Approve pull requests (for security reasons) - Post multiple comments (you only update your initial comment) -- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)" : ""} +- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via claude_args with --allowedTools)" : ""} - Perform branch operations (cannot merge branches, rebase, or perform other git operations beyond creating and pushing commits) - Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications) diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index c18ab49..c8d63e3 100755 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -2,6 +2,7 @@ import { readFileSync, existsSync } from "fs"; import { exit } from "process"; +import { redactSecrets } from "../github/utils/sanitizer"; export type ToolUse = { type: string; @@ -163,8 +164,15 @@ export function formatResultContent(content: any): string { typeof parsedContent[0] === "object" && parsedContent[0]?.type === "text" ) { - // Extract the text field from the first item - contentStr = parsedContent[0]?.text || ""; + // Keep every text block, not just the first: a tool result may split its + // output across several, and dropping the rest silently loses findings, + // file paths and follow-up instructions from the rendered summary. Blocks + // of other types (for example images) are skipped. Tool output is + // arbitrary, so `text` is not guaranteed to be a string. + contentStr = parsedContent + .filter((block: any) => block?.type === "text") + .map((block: any) => String(block?.text || "")) + .join("\n"); } else { contentStr = String(content).trim(); } @@ -172,6 +180,10 @@ export function formatResultContent(content: any): string { contentStr = String(content).trim(); } + // Redact before truncating so a credential cannot be split at the cut and + // slip past the final redaction pass. + contentStr = redactSecrets(contentStr); + // Truncate very long results if (contentStr.length > 3000) { contentStr = contentStr.substring(0, 2997) + "..."; @@ -420,7 +432,9 @@ export function formatTurnsFromData(data: Turn[]): string { // Generate markdown const markdown = formatGroupedContent(groupedContent); - return markdown; + // Runtime output may contain credentials that are not registered as + // workflow secrets, so redact known formats before this gets published. + return redactSecrets(markdown); } function main(): void { @@ -447,14 +461,8 @@ function main(): void { const fileContent = readFileSync(jsonFile, "utf-8"); const data: Turn[] = JSON.parse(fileContent); - // Group turns naturally - const groupedContent = groupTurnsNaturally(data); - - // Generate markdown - const markdown = formatGroupedContent(groupedContent); - // Print to stdout (so it can be captured by shell) - console.log(markdown); + console.log(formatTurnsFromData(data)); } catch (error) { console.error(`Error processing file: ${error}`); exit(1); diff --git a/src/entrypoints/post-buffered-inline-comments.ts b/src/entrypoints/post-buffered-inline-comments.ts index 763dae7..9283baf 100644 --- a/src/entrypoints/post-buffered-inline-comments.ts +++ b/src/entrypoints/post-buffered-inline-comments.ts @@ -11,6 +11,7 @@ */ import { readFileSync } from "fs"; import { createOctokit } from "../github/api/client"; +import { redactSecrets } from "../github/utils/sanitizer"; const BUFFER_PATH = "/tmp/inline-comments-buffer.jsonl"; @@ -120,7 +121,7 @@ async function postComment( owner, repo, pull_number, - body: c.body, + body: redactSecrets(c.body), path: c.path, side: c.side || "RIGHT", commit_id: c.commit_id || headSha, diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index a0b0aad..553bdb6 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -9,7 +9,11 @@ import * as core from "@actions/core"; import { setupGitHubToken } from "../github/token"; import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; -import { parseGitHubContext, isEntityContext } from "../github/context"; +import { + parseGitHubContext, + isEntityContext, + isWorkflowRunEvent, +} from "../github/context"; import { detectMode } from "../modes/detector"; import { prepareTagMode } from "../modes/tag"; import { prepareAgentMode } from "../modes/agent"; @@ -33,8 +37,8 @@ async function run() { const githubToken = await setupGitHubToken(); const octokit = createOctokit(githubToken); - // Step 3: Check write permissions (only for entity contexts) - if (isEntityContext(context)) { + // Step 3: Check write permissions (entity contexts and workflow_run) + if (isEntityContext(context) || isWorkflowRunEvent(context)) { // Check if github_token was provided as input (not from app) const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN; const hasWritePermissions = await checkWritePermissions( diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 42c4e0b..e24048e 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -22,6 +22,7 @@ import { isPullRequestEvent, isPullRequestReviewEvent, isPullRequestReviewCommentEvent, + isWorkflowRunEvent, } from "../github/context"; import type { GitHubContext } from "../github/context"; import { detectMode } from "../modes/detector"; @@ -34,6 +35,7 @@ import { collectActionInputsPresence } from "./collect-inputs"; import { updateCommentLink } from "./update-comment-link"; import { formatTurnsFromData } from "./format-turns"; import type { Turn } from "./format-turns"; +import { redactSecrets } from "../github/utils/sanitizer"; // Base-action imports (used directly instead of subprocess) import { setupWorkloadIdentity } from "../../base-action/src/workload-identity"; import type { WorkloadIdentityHandle } from "../../base-action/src/workload-identity"; @@ -76,7 +78,7 @@ async function installClaudeCode(): Promise { return customExecutable; } - const claudeCodeVersion = "2.1.220"; + const claudeCodeVersion = "2.1.286"; console.log(`Installing Claude Code v${claudeCodeVersion}...`); for (let attempt = 1; attempt <= 3; attempt++) { @@ -137,7 +139,7 @@ async function writeStepSummary(executionFile: string): Promise { fallback += "Failed to format output (please report). Here's the raw JSON:\n\n"; fallback += "```json\n"; - fallback += readFileSync(executionFile, "utf-8"); + fallback += redactSecrets(readFileSync(executionFile, "utf-8")); fallback += "\n```\n"; await appendFile(summaryFile, fallback); } catch { @@ -205,6 +207,9 @@ async function run() { let context: GitHubContext | undefined; let octokit: Octokits | undefined; let workloadIdentity: WorkloadIdentityHandle | undefined; + // Paths reverted to the PR base branch, which cleanup must not commit back + // onto the PR author's branch. Empty unless restoreConfigFromBase ran. + let restoredConfigPaths: string[] = []; // Track whether we've completed prepare phase, so we can attribute errors correctly let prepareCompleted = false; try { @@ -233,8 +238,10 @@ async function run() { process.env.GITHUB_TOKEN = githubToken; process.env.GH_TOKEN = githubToken; - // Check write permissions (only for entity contexts) - if (isEntityContext(context)) { + // Check write permissions for entity contexts, and for workflow_run + // events, whose upstream run may have been started by an actor without + // write access (e.g. the author of a fork pull request) + if (isEntityContext(context) || isWorkflowRunEvent(context)) { const hasWritePermissions = await checkWritePermissions( octokit.rest, context, @@ -312,7 +319,7 @@ async function run() { validateBranchName(restoreBase); } if (restoreBase) { - restoreConfigFromBase(restoreBase); + restoredConfigPaths = restoreConfigFromBase(restoreBase); } } @@ -362,7 +369,7 @@ async function run() { prepareSuccess = false; prepareError = errorMessage; } - core.setFailed(`Action failed with error: ${errorMessage}`); + core.setFailed(`Action failed with error: ${redactSecrets(errorMessage)}`); } finally { // Phase 4: Cleanup (always runs) @@ -392,6 +399,7 @@ async function run() { prepareSuccess, prepareError, useCommitSigning: context.inputs.useCommitSigning, + restoredConfigPaths, }); } catch (error) { console.error("Error updating comment with job link:", error); diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index c0963e8..286d9b1 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -16,6 +16,7 @@ import type { ParsedGitHubContext } from "../github/context"; import { GITHUB_SERVER_URL } from "../github/api/config"; import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; +import { encodeBranchNameForUrl } from "../github/operations/comments/common"; export type UpdateCommentLinkParams = { commentId: number; @@ -30,6 +31,12 @@ export type UpdateCommentLinkParams = { prepareSuccess: boolean; prepareError?: string; useCommitSigning: boolean; + /** + * Paths restored from the PR base branch by restoreConfigFromBase. The + * auto-commit in checkAndCommitOrDeleteBranch must leave these alone, or it + * commits the revert onto the PR author's branch. + */ + restoredConfigPaths?: string[]; }; export async function updateCommentLink( @@ -43,6 +50,7 @@ export async function updateCommentLink( context, octokit, useCommitSigning, + restoredConfigPaths = [], } = params; const { owner, repo } = context.repository; @@ -116,6 +124,7 @@ export async function updateCommentLink( claudeBranch, baseBranch, useCommitSigning, + restoredConfigPaths, ); // Check if we need to add PR URL when we have a new branch @@ -151,7 +160,7 @@ export async function updateCommentLink( const prBody = encodeURIComponent( `This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`, ); - const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`; + const prUrl = `${serverUrl}/${owner}/${repo}/compare/${encodeBranchNameForUrl(baseBranch)}...${encodeBranchNameForUrl(claudeBranch)}?quick_pull=1&title=${prTitle}&body=${prBody}`; prLink = `\n[Create a PR](${prUrl})`; } } catch (error) { diff --git a/src/github/api/client.ts b/src/github/api/client.ts index 5437058..e154c07 100644 --- a/src/github/api/client.ts +++ b/src/github/api/client.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest"; import { graphql } from "@octokit/graphql"; -import { GITHUB_API_URL } from "./config"; +import { GITHUB_API_URL, GITHUB_GRAPHQL_URL } from "./config"; export type Octokits = { rest: Octokit; @@ -14,7 +14,7 @@ export function createOctokit(token: string): Octokits { baseUrl: GITHUB_API_URL, }), graphql: graphql.defaults({ - baseUrl: GITHUB_API_URL, + baseUrl: GITHUB_GRAPHQL_URL, headers: { authorization: `token ${token}`, }, diff --git a/src/github/api/config.ts b/src/github/api/config.ts index 9e533e5..56e6639 100644 --- a/src/github/api/config.ts +++ b/src/github/api/config.ts @@ -2,3 +2,16 @@ export const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com"; export const GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL || "https://github.com"; + +// GraphQL base URL for @octokit/graphql. GitHub Actions exposes the full GraphQL +// endpoint in GITHUB_GRAPHQL_URL (e.g. "https://HOST/api/graphql"), while +// @octokit/graphql appends "/graphql" to whatever baseUrl it is given, so a +// single trailing "/graphql" is stripped here to avoid "/graphql/graphql". +// When GITHUB_GRAPHQL_URL is unset we fall back to GITHUB_API_URL, preserving the +// existing behavior where @octokit/graphql rewrites a REST ".../api/v3" base to +// ".../api/graphql". The trailing-slash trim keeps that rewrite working. +export const GITHUB_GRAPHQL_URL = ( + process.env.GITHUB_GRAPHQL_URL || GITHUB_API_URL +) + .replace(/\/+$/, "") + .replace(/\/graphql$/, ""); diff --git a/src/github/api/queries/github.ts b/src/github/api/queries/github.ts index 1702061..eacae45 100644 --- a/src/github/api/queries/github.ts +++ b/src/github/api/queries/github.ts @@ -7,6 +7,7 @@ export const PR_QUERY = ` title body author { + __typename login } baseRefName @@ -57,6 +58,7 @@ export const PR_QUERY = ` databaseId body author { + __typename login } createdAt @@ -70,6 +72,7 @@ export const PR_QUERY = ` id databaseId author { + __typename login } body @@ -84,7 +87,9 @@ export const PR_QUERY = ` body path line + diffHunk author { + __typename login } createdAt @@ -107,6 +112,7 @@ export const ISSUE_QUERY = ` title body author { + __typename login } createdAt @@ -124,6 +130,7 @@ export const ISSUE_QUERY = ` databaseId body author { + __typename login } createdAt diff --git a/src/github/context.ts b/src/github/context.ts index eeefb99..9826b0c 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -282,6 +282,12 @@ export function isPullRequestReviewCommentEvent( return context.eventName === "pull_request_review_comment"; } +export function isWorkflowRunEvent( + context: GitHubContext, +): context is AutomationContext & { payload: WorkflowRunEvent } { + return context.eventName === "workflow_run"; +} + export function isIssuesAssignedEvent( context: GitHubContext, ): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } { diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index cea2ea2..0de9978 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -1,4 +1,5 @@ import { execFileSync } from "child_process"; +import type { IssuesEvent } from "@octokit/webhooks-types"; import type { Octokits } from "../api/client"; import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github"; import { @@ -22,6 +23,7 @@ import type { CommentWithImages } from "../utils/image-downloader"; import { downloadCommentImages } from "../utils/image-downloader"; import { parseActorFilter, + resolveActorName, shouldIncludeCommentByActor, } from "../utils/actor-filter"; @@ -29,6 +31,12 @@ import { * Extracts the trigger timestamp from the GitHub webhook payload. * This timestamp represents when the triggering comment/review/event was created. * + * For `issues` and `pull_request` events there is no dedicated trigger + * object in the payload, so the issue/PR's own timestamps from the webhook + * snapshot are used: `created_at` for opened events, otherwise `updated_at` + * (falling back to `created_at`). For issues labeled/assigned events, + * prefer resolveTriggerTimestamp() which looks up the exact event time. + * * @param context - Parsed GitHub context from webhook * @returns ISO timestamp string or undefined if not available */ @@ -41,11 +49,138 @@ export function extractTriggerTimestamp( return context.payload.review.submitted_at || undefined; } else if (isPullRequestReviewCommentEvent(context)) { return context.payload.comment.created_at || undefined; + } else if (isIssuesEvent(context)) { + const issue = context.payload.issue; + if (context.eventAction === "opened") { + return issue?.created_at || issue?.updated_at || undefined; + } + // updated_at reflects the last comment or edit on the issue, so the + // newest pre-existing comment can share this timestamp and be excluded + // along with anything newer. + return issue?.updated_at || issue?.created_at || undefined; + } else if (isPullRequestEvent(context)) { + const pullRequest = context.payload.pull_request; + if (context.eventAction === "opened") { + return pullRequest?.created_at || pullRequest?.updated_at || undefined; + } + return pullRequest?.updated_at || pullRequest?.created_at || undefined; } return undefined; } +/** + * Resolves the trigger timestamp for the event, consulting the GitHub API + * where the webhook payload does not carry an exact time for the triggering + * action. + * + * For issues labeled/assigned events the label/assignment carries no + * timestamp of its own in the payload, so the matching entry in the issue's + * event history is looked up and its `created_at` is used. If the lookup + * fails, this falls back to extractTriggerTimestamp(). + * + * @param context - Parsed GitHub context from webhook + * @param octokits - GitHub API clients + * @returns ISO timestamp string or undefined if not available + */ +export async function resolveTriggerTimestamp( + context: ParsedGitHubContext, + octokits: Octokits, +): Promise { + if ( + isIssuesEvent(context) && + (context.eventAction === "labeled" || context.eventAction === "assigned") + ) { + const eventTime = await findIssueEventTime(context, octokits); + if (eventTime) { + return eventTime; + } + console.warn( + `Could not resolve the ${context.eventAction} event time for issue #${context.entityNumber}; falling back to the webhook payload timestamps`, + ); + } + + return extractTriggerTimestamp(context); +} + +/** + * Looks up the most recent labeled/assigned event on the issue that matches + * the label or assignee in the webhook payload, returning its created_at. + */ +async function findIssueEventTime( + context: ParsedGitHubContext & { payload: IssuesEvent }, + octokits: Octokits, +): Promise { + const payload = context.payload; + let matches: (event: { + event: string; + label?: { name?: string | null }; + assignee?: { login?: string } | null; + }) => boolean; + + if (payload.action === "labeled") { + const labelName = payload.label?.name; + if (!labelName) return undefined; + matches = (event) => + event.event === "labeled" && event.label?.name === labelName; + } else if (payload.action === "assigned") { + const assigneeLogin = payload.assignee?.login; + if (!assigneeLogin) return undefined; + matches = (event) => + event.event === "assigned" && event.assignee?.login === assigneeLogin; + } else { + return undefined; + } + + try { + const events = await octokits.rest.paginate( + octokits.rest.issues.listEvents, + { + owner: context.repository.owner, + repo: context.repository.repo, + issue_number: context.entityNumber, + per_page: 100, + }, + ); + + let latest: (typeof events)[number] | undefined; + for (const event of events.filter(matches)) { + if ( + !latest || + new Date(event.created_at).getTime() > + new Date(latest.created_at).getTime() + ) { + latest = event; + } + } + + // Labeling/assignment does not bump the issue's updated_at, so the event + // that fired this webhook cannot predate the payload snapshot's + // updated_at. An older match means the current event is not visible in + // the events API yet; ignore it rather than adopt a stale boundary. + const snapshotUpdatedAt = payload.issue?.updated_at; + if ( + latest && + snapshotUpdatedAt && + new Date(latest.created_at).getTime() < + new Date(snapshotUpdatedAt).getTime() + ) { + console.warn( + `Latest matching ${payload.action} event on issue #${context.entityNumber} predates the issue's updated_at; treating it as stale`, + ); + return undefined; + } + + return latest?.created_at || undefined; + } catch (error) { + console.warn( + `Failed to fetch events for issue #${context.entityNumber}:`, + error, + ); + return undefined; + } +} + /** * Extracts the original title from the GitHub webhook payload. * This is the title as it existed when the trigger event occurred. @@ -205,7 +340,7 @@ export function isBodySafeToUse( * @returns Filtered array of comments */ export function filterCommentsByActor< - T extends { author: { login: string } | null }, + T extends { author: { login: string; __typename?: string } | null }, >(comments: T[], includeActors: string = "", excludeActors: string = ""): T[] { const includeParsed = parseActorFilter(includeActors); const excludeParsed = parseActorFilter(excludeActors); @@ -217,9 +352,10 @@ export function filterCommentsByActor< return comments.filter((comment) => shouldIncludeCommentByActor( - // author is null for comments from deleted ("ghost") accounts; treat them - // as the "ghost" login so filtering never dereferences null and crashes. - comment.author?.login ?? "ghost", + // Normalizes App actors to their "[bot]"-suffixed name, which is the form + // filter patterns are written in. Also maps deleted ("ghost") accounts, + // whose author is null, to "ghost" so filtering never dereferences null. + resolveActorName(comment.author), includeParsed, excludeParsed, ), @@ -290,7 +426,12 @@ export async function fetchGitHubData({ if (prResult.repository.pullRequest) { const pullRequest = prResult.repository.pullRequest; contextData = pullRequest; - changedFiles = pullRequest.files.nodes || []; + if (pullRequest.files === null) { + console.warn( + `GitHub did not return the file list for PR #${prNumber} (diff likely too large); proceeding without file-level context`, + ); + } + changedFiles = pullRequest.files?.nodes ?? []; comments = filterCommentsByActor( filterCommentsToTriggerTime( pullRequest.comments?.nodes || [], diff --git a/src/github/data/formatter.ts b/src/github/data/formatter.ts index 95d5603..ad428eb 100644 --- a/src/github/data/formatter.ts +++ b/src/github/data/formatter.ts @@ -28,7 +28,7 @@ PR Labels: ${formatLabels(prData.labels.nodes)} PR Additions: ${prData.additions} PR Deletions: ${prData.deletions} Total Commits: ${prData.commits.totalCount} -Changed Files: ${prData.files.nodes.length} files`; +Changed Files: ${prData.files ? `${prData.files.nodes.length} files` : "unknown (file list unavailable)"}`; } else { const issueData = contextData as GitHubIssue; const sanitizedTitle = sanitizeContent(issueData.title); @@ -118,7 +118,16 @@ export function formatReviewComments( body = sanitizeContent(body); - return ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`; + let formatted = ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`; + + // The diff hunk is the code the comment was left on. Without it the + // comment arrives without the context it was written against. + if (comment.diffHunk) { + const diffHunk = sanitizeContent(comment.diffHunk); + formatted += `\n Diff context:\n\`\`\`diff\n${diffHunk}\n\`\`\``; + } + + return formatted; }) .join("\n"); if (comments) { diff --git a/src/github/operations/branch-cleanup.ts b/src/github/operations/branch-cleanup.ts index 88de6de..b6a8d47 100644 --- a/src/github/operations/branch-cleanup.ts +++ b/src/github/operations/branch-cleanup.ts @@ -1,5 +1,6 @@ import type { Octokits } from "../api/client"; import { GITHUB_SERVER_URL } from "../api/config"; +import { encodeBranchNameForUrl } from "./comments/common"; import { $ } from "bun"; export async function checkAndCommitOrDeleteBranch( @@ -9,10 +10,32 @@ export async function checkAndCommitOrDeleteBranch( claudeBranch: string | undefined, baseBranch: string, useCommitSigning: boolean, + restoredConfigPaths: string[] = [], ): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> { let branchLink = ""; let shouldDeleteBranch = false; + // On pull requests, restoreConfigFromBase replaces .claude/, CLAUDE.md and + // friends with the base branch's versions and leaves them unstaged so the + // revert does not reach a commit. Auto-committing with a bare `git add -A` + // would stage them anyway and push a silent revert of the PR author's own + // config onto their branch. + // + // The exclusion is driven by what was actually restored rather than applied + // unconditionally: this path also runs for issues, where no restore happens + // and Claude may legitimately have been asked to edit CLAUDE.md or + // .claude/settings.json. Excluding those there would silently drop the work. + const pathspecArgs = + restoredConfigPaths.length > 0 + ? ["--", ".", ...restoredConfigPaths.map((p) => `:(exclude)${p}`)] + : []; + + if (pathspecArgs.length > 0) { + console.log( + `Excluding base-restored config from auto-commit: ${restoredConfigPaths.join(", ")}`, + ); + } + if (claudeBranch) { // First check if the branch exists remotely let branchExistsRemotely = false; @@ -57,15 +80,19 @@ export async function checkAndCommitOrDeleteBranch( // Check for uncommitted changes using git status try { - const gitStatus = await $`git status --porcelain`.quiet(); + // Scoped the same way as the staging below: if the restored config + // is the only dirty entry there is no real work, and the branch + // should be treated as empty rather than receiving a pure revert. + const gitStatus = + await $`git status --porcelain ${pathspecArgs}`.quiet(); const hasUncommittedChanges = gitStatus.stdout.toString().trim().length > 0; if (hasUncommittedChanges) { console.log("Found uncommitted changes, committing them..."); - // Add all changes - await $`git add -A`; + // Add all changes, minus anything restored from the base branch + await $`git add -A ${pathspecArgs}`; // Commit with a descriptive message const runId = process.env.GITHUB_RUN_ID || "unknown"; @@ -80,7 +107,7 @@ export async function checkAndCommitOrDeleteBranch( ); // Set branch link since we now have commits - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`; branchLink = `\n[View branch](${branchUrl})`; } else { console.log( @@ -91,7 +118,7 @@ export async function checkAndCommitOrDeleteBranch( } catch (gitError) { console.error("Error checking/committing changes:", gitError); // If we can't check git status, assume the branch might have changes - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`; branchLink = `\n[View branch](${branchUrl})`; } } else { @@ -102,13 +129,13 @@ export async function checkAndCommitOrDeleteBranch( } } else { // Only add branch link if there are commits - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`; branchLink = `\n[View branch](${branchUrl})`; } } catch (error) { console.error("Error comparing commits on Claude branch:", error); // If we can't compare but the branch exists remotely, include the branch link - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; + const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(claudeBranch)}`; branchLink = `\n[View branch](${branchUrl})`; } } diff --git a/src/github/operations/branch.ts b/src/github/operations/branch.ts index e095280..880c91c 100644 --- a/src/github/operations/branch.ts +++ b/src/github/operations/branch.ts @@ -13,6 +13,7 @@ import type { GitHubPullRequest } from "../types"; import type { Octokits } from "../api/client"; import type { FetchDataResult } from "../data/fetcher"; import { generateBranchName } from "../../utils/branch-template"; +import { fetchDepthArgs } from "./fetch-depth"; /** * Extracts the first label from GitHub data, or returns undefined if no labels exist @@ -28,7 +29,7 @@ function extractFirstLabel(githubData: FetchDataResult): string | undefined { * * Valid branch names: * - Start with alphanumeric character, underscore, or @ (not dash, to prevent option injection) - * - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), or at sign (@) + * - Contain only alphanumeric, forward slash, hyphen, underscore, period, hash (#), plus (+), comma (,), at sign (@), or parentheses * - Do not start or end with a period * - Do not end with a slash * - Do not contain '..' (path traversal) @@ -59,7 +60,7 @@ export function validateBranchName(branchName: string): void { ); } - // Strict whitelist pattern: alphanumeric or @ start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma/at-sign. + // Strict whitelist pattern: alphanumeric or @ start, then alphanumeric/slash/hyphen/underscore/period/hash/plus/comma/at-sign/parentheses. // # is valid per git-check-ref-format and commonly used in branch names like "fix/#123-description". // + is valid per git-check-ref-format and generated by Claude Code's EnterWorktree tool when // converting worktree names containing "/" (e.g. "feat/foo" becomes "worktree-feat+foo"). @@ -71,12 +72,14 @@ export function validateBranchName(branchName: string): void { // _ is valid per git-check-ref-format anywhere in a ref name, including the first character; // leading underscores are a common convention for release/internal branches (e.g. // "_release/v1.2.3"), which previously failed validation as a PR's base branch. + // Parentheses are valid per git-check-ref-format and commonly appear in branch names that + // use Conventional Commit-style scopes (e.g. "feat(parser)-handle-empty-input"). // All git calls use execFileSync (not shell interpolation), so none of these characters carry injection risk. - const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@-]*$/; + const validPattern = /^[a-zA-Z0-9@_][a-zA-Z0-9/_.#+,@()-]*$/; if (!validPattern.test(branchName)) { throw new Error( - `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), or at signs (@).`, + `Invalid branch name: "${branchName}". Branch names must start with an alphanumeric character, underscore, or '@' and contain only alphanumeric characters, forward slashes, hyphens, underscores, periods, hashes (#), plus signs (+), commas (,), at signs (@), or parentheses.`, ); } @@ -175,12 +178,19 @@ export async function setupBranch( const branchName = prData.headRefName; - // Determine optimal fetch depth based on PR commit count, with a minimum of 20 + // Determine optimal fetch depth based on PR commit count, with a minimum + // of 20. Only applied to a checkout that is already shallow — see + // fetchDepthArgs. const commitCount = prData.commits.totalCount; const fetchDepth = Math.max(commitCount, 20); + const depthArgs = fetchDepthArgs(fetchDepth); console.log( - `PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`, + `PR #${entityNumber}: ${commitCount} commits, ${ + depthArgs.length > 0 + ? `using fetch depth ${fetchDepth}` + : "fetching without a depth limit (checkout has full history)" + }`, ); // Validate branch names before use to prevent command injection @@ -195,13 +205,13 @@ export async function setupBranch( execGit([ "fetch", "origin", - `--depth=${fetchDepth}`, + ...depthArgs, `pull/${entityNumber}/head:${branchName}`, ]); } else { // Execute git commands to checkout PR branch (dynamic depth based on PR size) // Using execFileSync instead of shell template literals for security - execGit(["fetch", "origin", `--depth=${fetchDepth}`, branchName]); + execGit(["fetch", "origin", ...depthArgs, branchName]); } execGit(["checkout", branchName, "--"]); @@ -288,6 +298,11 @@ export async function setupBranch( // Branch doesn't exist (non-zero exit code), continue with generated name } + // Validate before either path uses the name. The signing path hands it to + // the file ops server rather than to git, so without this an invalid + // template only surfaces as a 422 on the first commit. + validateBranchName(newBranch); + // For commit signing, defer branch creation to the file ops server if (context.inputs.useCommitSigning) { console.log( @@ -297,7 +312,7 @@ export async function setupBranch( // Ensure we're on the source branch console.log(`Fetching and checking out source branch: ${sourceBranch}`); validateBranchName(sourceBranch); - execGit(["fetch", "origin", sourceBranch, "--depth=1"]); + execGit(["fetch", "origin", sourceBranch, ...fetchDepthArgs(1)]); execGit(["checkout", sourceBranch, "--"]); return { @@ -315,8 +330,7 @@ export async function setupBranch( // Fetch and checkout the source branch first to ensure we branch from the correct base console.log(`Fetching and checking out source branch: ${sourceBranch}`); validateBranchName(sourceBranch); - validateBranchName(newBranch); - execGit(["fetch", "origin", sourceBranch, "--depth=1"]); + execGit(["fetch", "origin", sourceBranch, ...fetchDepthArgs(1)]); execGit(["checkout", sourceBranch, "--"]); // Create and checkout the new branch from the source branch diff --git a/src/github/operations/comment-logic.ts b/src/github/operations/comment-logic.ts index 03b5d86..558026c 100644 --- a/src/github/operations/comment-logic.ts +++ b/src/github/operations/comment-logic.ts @@ -1,4 +1,6 @@ import { GITHUB_SERVER_URL } from "../api/config"; +import { redactSecrets } from "../utils/sanitizer"; +import { encodeBranchNameForUrl } from "./comments/common"; export type ExecutionDetails = { total_cost_usd?: number; @@ -160,7 +162,7 @@ export function updateCommentBody(input: CommentUpdateInput): string { // Extract owner/repo from jobUrl const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//); if (repoMatch) { - branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`; + branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${encodeBranchNameForUrl(finalBranchName)}`; } } @@ -181,9 +183,11 @@ export function updateCommentBody(input: CommentUpdateInput): string { // Build the new body with blank line between header and separator let newBody = `${header}${links}`; - // Add error details if available + // Add error details if available. The message may embed runtime credentials + // (e.g. a token in a git remote URL) that are not registered as workflow + // secrets, so redact known formats before posting. if (actionFailed && errorDetails) { - newBody += `\n\n\`\`\`\n${errorDetails}\n\`\`\``; + newBody += `\n\n\`\`\`\n${redactSecrets(errorDetails)}\n\`\`\``; } newBody += `\n\n---\n`; diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index df24c03..3421bc5 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -12,12 +12,17 @@ export function createJobRunLink( return `[View job run](${jobRunUrl})`; } +/** Encode Git-ref path segments without turning `/` into `%2F`. */ +export function encodeBranchNameForUrl(branchName: string): string { + return branchName.split("/").map(encodeURIComponent).join("/"); +} + export function createBranchLink( owner: string, repo: string, branchName: string, ): string { - const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`; + const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${encodeBranchNameForUrl(branchName)}`; return `\n[View branch](${branchUrl})`; } diff --git a/src/github/operations/fetch-depth.ts b/src/github/operations/fetch-depth.ts new file mode 100644 index 0000000..7352fe3 --- /dev/null +++ b/src/github/operations/fetch-depth.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "child_process"; + +/** + * Builds the `--depth` argument for a `git fetch`, unless the checkout still + * has its full history. + * + * `--depth` does not only cap what gets downloaded. Against a complete checkout + * (`actions/checkout` with `fetch-depth: 0`) it also truncates the history that + * is already there and marks the repository shallow, which drops the merge base + * with the base branch: `git log origin/..HEAD` then quietly lists + * commits that are already merged, and `git diff origin/...HEAD` fails + * with "no merge base". Those are the commands the prompt tells Claude to run + * to scope its work to the PR. + * + * A shallow checkout (the `fetch-depth: 1` default) has no history left to + * lose, so the limit still applies there and large repositories keep the fetch + * savings it was added for. + */ +export function fetchDepthArgs(depth: number): string[] { + return isShallowRepository() ? [`--depth=${depth}`] : []; +} + +function isShallowRepository(): boolean { + try { + const output = execFileSync( + "git", + ["rev-parse", "--is-shallow-repository"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return output.trim() === "true"; + } catch { + // No repository yet, or a git old enough not to know the flag. Treat the + // checkout as complete: fetching more than necessary is recoverable, + // truncating history is not. + return false; + } +} diff --git a/src/github/operations/git-config.ts b/src/github/operations/git-config.ts index 3df584b..bf59763 100644 --- a/src/github/operations/git-config.ts +++ b/src/github/operations/git-config.ts @@ -42,14 +42,68 @@ export async function configureGitAuth( await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`; console.log(`✓ Set git user as ${botName}`); + await replaceCheckoutCredentials(githubToken, context); + + console.log("Git authentication configured successfully"); +} + +/** + * Replace the credential that actions/checkout persisted in the working tree. + * + * actions/checkout stores its token as an `http./.extraheader` entry + * in .git/config for the duration of the job. Claude and the tools it invokes + * run inside this working tree, so remove that entry and back git with the + * action's own token instead (a credential helper when non-write users are + * allowed, otherwise the origin URL). This applies to every mode, including API + * commit signing where no other git configuration is needed. + * + * actions/checkout < v6 stored the header directly in the repo-local config, + * where `git config --unset-all` removes it. Since v6.0.0 (backported to + * v5.0.1 and v4.3.1) the header is written to a separate file under + * RUNNER_TEMP that the repo config pulls in via `include.path`; `--unset-all` + * on the local config cannot touch an include-provided value, so the removal + * was a silent no-op and the checkout credential (typically the workflow + * GITHUB_TOKEN) stayed usable by git for the rest of the job. Clear the + * header from the local config AND from every included file so it can no + * longer authenticate while Claude runs. + */ +export async function replaceCheckoutCredentials( + githubToken: string, + context: GitHubContext, +) { + const serverUrl = new URL(GITHUB_SERVER_URL); + // Remove the authorization header that actions/checkout sets console.log("Removing existing git authentication headers..."); + const extraheaderKey = `http.${GITHUB_SERVER_URL}/.extraheader`; + let removedHeader = false; try { - await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`; - console.log("✓ Removed existing authentication headers"); - } catch (e) { - console.log("No existing authentication headers to remove"); + await $`git config --unset-all ${extraheaderKey}`; + removedHeader = true; + } catch { + // No extraheader in the local config (expected on the v6+ include layout). } + try { + const includePaths = + await $`git config --local --get-all include.path`.text(); + for (const includePath of includePaths.split("\n")) { + const path = includePath.trim(); + if (!path) continue; + try { + await $`git config --file ${path} --unset-all ${extraheaderKey}`; + removedHeader = true; + } catch { + // This include does not define the header; leave it untouched. + } + } + } catch { + // No include.path entries in the local config. + } + console.log( + removedHeader + ? "✓ Removed existing authentication headers" + : "No existing authentication headers to remove", + ); if (process.env.ALLOWED_NON_WRITE_USERS) { // When processing content from non-write users, use a credential helper @@ -79,8 +133,6 @@ export async function configureGitAuth( await $`git remote set-url origin ${remoteUrl}`; console.log("✓ Updated remote URL with authentication token"); } - - console.log("Git authentication configured successfully"); } /** diff --git a/src/github/operations/restore-config.ts b/src/github/operations/restore-config.ts index 92ab2be..bdd5576 100644 --- a/src/github/operations/restore-config.ts +++ b/src/github/operations/restore-config.ts @@ -3,11 +3,17 @@ import { appendFileSync, cpSync, existsSync, + lstatSync, mkdirSync, readFileSync, + readlinkSync, + realpathSync, rmSync, + statSync, + writeFileSync, } from "fs"; -import { dirname } from "path"; +import { dirname, join, posix, relative, sep } from "path"; +import { fetchDepthArgs } from "./fetch-depth"; // Paths that are both PR-controllable and read from cwd at CLI startup. // @@ -17,7 +23,7 @@ import { dirname } from "path"; // .gitconfig — git reads ~/.gitconfig and .git/config, never cwd/.gitconfig. // .bashrc etc. — shells source these from $HOME; checkout cannot reach $HOME. // .vscode/.idea— IDE config; nothing in the CLI's startup path reads them. -const SENSITIVE_PATHS = [ +export const SENSITIVE_PATHS = [ ".claude", ".mcp.json", ".claude.json", @@ -30,18 +36,173 @@ const SENSITIVE_PATHS = [ const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/"; -function snapshotSensitivePath(src: string, dest: string): void { +function isSameOrInside(child: string, parent: string): boolean { + return child === parent || child.startsWith(`${parent}${sep}`); +} + +// Repository paths (relative to cwd, `/`-separated) that a link may resolve +// to: `files` are tracked files whose working-tree content is unchanged from +// HEAD, `dirs` are directories that contain at least one tracked file. +type TrackedPaths = { files: Set; dirs: Set }; + +// Built from the superproject only: `git ls-files` reports a submodule as a +// single entry, so paths inside a checked-out submodule are in neither set and +// links into one are recorded as placeholders. +function listTrackedPaths(): TrackedPaths { + const gitPathList = (args: string[]) => + execFileSync("git", args, { encoding: "utf8", maxBuffer: Infinity }) + .split("\0") + .filter(Boolean); + const modified = new Set( + gitPathList([ + "diff", + "--name-only", + "-z", + "--relative", + "--ignore-submodules", + "HEAD", + "--", + ]), + ); + const tracked: TrackedPaths = { files: new Set(), dirs: new Set() }; + for (const file of gitPathList(["ls-files", "-z"])) { + if (!modified.has(file)) { + tracked.files.add(file); + } + for ( + let dir = posix.dirname(file); + dir !== "." && !tracked.dirs.has(dir); + dir = posix.dirname(dir) + ) { + tracked.dirs.add(dir); + } + } + return tracked; +} + +// The snapshot is scoped to tracked repository content and never contains +// links. An entry is copied with its content only when all of these hold: +// 1. its real target (through any links) lies inside the working tree; +// 2. no component of the target's path inside the tree is `.git`, and the +// target is not inside the snapshot directory itself; +// 3. the target does not contain a directory already on the entry's own +// path (which would recurse); +// 4. if the entry is reached through a link (it is one, or a directory above +// it inside the sensitive path is), a file target must be tracked in the +// checkout with its content unchanged from HEAD, and a directory target +// must contain at least one tracked file (see listTrackedPaths). Directory +// targets that pass are descended into and their children are checked +// individually. +// Files and directories at their literal, non-linked location are unaffected +// by rule 4 and are copied as-is. Every other entry — targets outside the +// tree, dangling or looping links, git metadata, submodule contents, untracked +// or locally modified files reached through a link — is recorded as a +// placeholder file (see recordPlaceholder), so nothing in the snapshot +// resolves anywhere else. +function shouldSnapshotContent( + entryPath: string, + workTreeRealPath: string, + tracked: TrackedPaths, +): boolean { try { - cpSync(src, dest, { recursive: true, dereference: true }); + const targetRealPath = realpathSync(entryPath); + if (!isSameOrInside(targetRealPath, workTreeRealPath)) { + return false; + } + const targetParts = relative(workTreeRealPath, targetRealPath).split(sep); + if (targetParts.includes(".git") || targetParts[0] === ".claude-pr") { + return false; + } + for (let dir = dirname(entryPath); ; dir = dirname(dir)) { + if (isSameOrInside(realpathSync(dir), targetRealPath)) { + return false; + } + if (dir === dirname(dir)) { + break; + } + } + const literalPath = join( + workTreeRealPath, + relative(process.cwd(), entryPath), + ); + if (targetRealPath === literalPath) { + return true; + } + const targetRepoPath = targetParts.join("/"); + return statSync(targetRealPath).isDirectory() + ? tracked.dirs.has(targetRepoPath) + : tracked.files.has(targetRepoPath); + } catch { + return false; + } +} + +// Writes a short regular file at `dest` describing the entry that was left +// out, so the snapshot records that something was there without linking to it. +function recordPlaceholder(src: string, dest: string): void { + console.warn( + `Snapshot: ${src} not included in snapshot; recording a placeholder`, + ); + let description = "is not included in this snapshot"; + try { + description = `was a symbolic link to ${JSON.stringify(readlinkSync(src))}; the link target is not included in this snapshot`; + } catch { + // Not a link (or no longer present). + } + mkdirSync(dirname(dest), { recursive: true }); + writeFileSync(dest, `Snapshot placeholder: ${src} ${description}.\n`); +} + +/** + * Copies a sensitive path into the review snapshot. Entries that pass the + * check above are copied dereferenced (reviewers see the effective content); + * every other entry is recorded as a placeholder file, never as a link. + * Applies per entry, including links nested inside a real directory. + */ +function snapshotSensitivePath( + src: string, + dest: string, + workTreeRealPath: string, + tracked: TrackedPaths, +): void { + const excluded: Array<{ src: string; dest: string }> = []; + const keepOrExclude = + (keep: (entry: string) => boolean) => + (entrySrc: string, entryDest: string) => { + if (keep(entrySrc)) { + return true; + } + excluded.push({ src: entrySrc, dest: entryDest }); + return false; + }; + try { + cpSync(src, dest, { + recursive: true, + dereference: true, + filter: keepOrExclude((entry) => + shouldSnapshotContent(entry, workTreeRealPath, tracked), + ), + }); } catch (error) { - // Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md` - // -> `../AGENTS.md` when the PR deleted the target) make dereferenced - // copies throw ENOENT. Preserve the symlink for the review snapshot instead. - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - cpSync(src, dest, { recursive: true }); - return; + // Dangling links are normally caught by the filter above. If a target + // disappears between that check and the copy, the dereferencing copy + // throws ENOENT; start over without following links, recording every link + // as a placeholder, instead of failing the restore. + if ( + !(error instanceof Error && "code" in error && error.code === "ENOENT") + ) { + throw error; } - throw error; + rmSync(dest, { recursive: true, force: true }); + excluded.length = 0; + cpSync(src, dest, { + recursive: true, + filter: keepOrExclude((entry) => !lstatSync(entry).isSymbolicLink()), + }); + } + + for (const entry of excluded) { + recordPlaceholder(entry.src, entry.dest); } } @@ -86,10 +247,26 @@ function ensureClaudePrExcludedFromGit(): void { * commits with `git add -A`, the revert will be included in that commit. This * is a narrow UX tradeoff for closing the RCE surface. * + * Only the paths listed in SENSITIVE_PATHS come from the base branch; the rest + * of the working tree stays at the PR head. A base-branch hook or setting that + * calls out through files a PR can change — package-manager scripts + * (`bun run`, `npm run`, `yarn`, `pnpm run`), Makefile or task-runner targets, + * repo-relative script paths, or tools that load executable project config — + * therefore runs whatever the PR head provides. Keep restored hooks + * self-contained: invoke the tool binary directly, pin its version, and pass + * config on the command line rather than reading it from the checkout. This + * extends to the runtime itself: `bunx ` runs the tool under `node` when + * `node` is on PATH, but on a Bun-only runner Bun executes the script and reads + * `bunfig.toml` (e.g. `preload`) from the checkout, so `bunfig.toml` and + * `.npmrc` there are PR-controlled runtime config too. + * * @param baseBranch - PR base branch name. Must be pre-validated (branch.ts * calls validateBranchName on it before returning). + * @returns The paths whose working-tree state now comes from the base branch + * rather than the PR. Callers that stage files must exclude these, or they + * will commit the revert back onto the PR author's branch. */ -export function restoreConfigFromBase(baseBranch: string): void { +export function restoreConfigFromBase(baseBranch: string): string[] { console.log( `Restoring ${SENSITIVE_PATHS.join(", ")} from origin/${baseBranch} (PR head is untrusted)`, ); @@ -97,11 +274,15 @@ export function restoreConfigFromBase(baseBranch: string): void { // Snapshot every PR-authored sensitive path into .claude-pr/ before deletion // so review agents can inspect what the PR changes without those files ever // being executed. Captured before the security delete so it reflects the - // PR-authored version. + // PR-authored version. Links are followed only to tracked, unmodified content + // inside the working tree; anything else is recorded as a placeholder file, + // so the snapshot itself never contains links. rmSync(".claude-pr", { recursive: true, force: true }); + const workTreeRealPath = realpathSync(process.cwd()); + const tracked = listTrackedPaths(); for (const p of SENSITIVE_PATHS) { - if (existsSync(p)) { - snapshotSensitivePath(p, `.claude-pr/${p}`); + if (lstatSync(p, { throwIfNoEntry: false })) { + snapshotSensitivePath(p, `.claude-pr/${p}`, workTreeRealPath, tracked); } } if (existsSync(".claude-pr")) { @@ -128,7 +309,13 @@ export function restoreConfigFromBase(baseBranch: string): void { // fetch.recurseSubmodules config. Defense-in-depth alongside the delete above. execFileSync( "git", - ["fetch", "origin", baseBranch, "--depth=1", "--no-recurse-submodules"], + [ + "fetch", + "origin", + baseBranch, + ...fetchDepthArgs(1), + "--no-recurse-submodules", + ], { stdio: "inherit", env: process.env, @@ -154,4 +341,9 @@ export function restoreConfigFromBase(baseBranch: string): void { } catch { // Nothing was staged, or paths don't exist on HEAD — either is fine. } + + // Every sensitive path is reported, not just the ones that changed: the + // restore also deletes paths the PR added that are absent on base, and those + // deletions are stageable too. + return [...SENSITIVE_PATHS]; } diff --git a/src/github/types.ts b/src/github/types.ts index feeb7d7..280073c 100644 --- a/src/github/types.ts +++ b/src/github/types.ts @@ -3,9 +3,14 @@ // GitHub's GraphQL `author`/`actor` fields resolve to null when the underlying // account has been deleted (the "ghost" user). Any field typed as // `GitHubAuthor | null` can therefore be null at runtime and must be guarded. +// `__typename` distinguishes an App/bot actor from a human. GraphQL's +// `Actor.login` returns the bare name for bots ("dependabot"), unlike REST which +// appends a suffix ("dependabot[bot]"), so the typename is the only reliable bot +// signal on this data. See `resolveActorName` in `utils/actor-filter.ts`. export type GitHubAuthor = { login: string; name?: string; + __typename?: string; }; export type GitHubComment = { @@ -22,6 +27,7 @@ export type GitHubComment = { export type GitHubReviewComment = GitHubComment & { path: string; line: number | null; + diffHunk?: string | null; }; export type GitHubCommit = { @@ -85,9 +91,13 @@ export type GitHubPullRequest = { commit: GitHubCommit; }>; }; + // GitHub's GraphQL `files` field resolves to null when the PR's diff is too + // large for GitHub to compute (very large PRs). `changedFiles` is also + // misreported as 0 in that case, so the null must be guarded and treated as + // "file list unavailable" rather than "no files changed". files: { nodes: GitHubFile[]; - }; + } | null; comments: { nodes: GitHubComment[]; }; diff --git a/src/github/utils/actor-filter.ts b/src/github/utils/actor-filter.ts index 2aebae7..91db5dc 100644 --- a/src/github/utils/actor-filter.ts +++ b/src/github/utils/actor-filter.ts @@ -11,6 +11,31 @@ export function parseActorFilter(filterString: string): string[] { .filter((actor) => actor.length > 0); } +/** + * Resolves the name to match actor filter patterns against. + * + * GitHub's GraphQL API returns the bare login for App actors ("dependabot"), + * whereas REST and the GitHub UI use a "[bot]" suffix ("dependabot[bot]"). Users + * write filter patterns in the suffixed form, both the documented "*[bot]" + * wildcard and exact entries like "renovate[bot]", so GraphQL bot logins are + * normalized to that form before matching. Without this no "[bot]" pattern can + * ever match, because the suffix is simply absent from the data. + * + * @param author - Comment author; null for deleted ("ghost") accounts + * @returns Actor name, "[bot]"-suffixed for App actors + */ +export function resolveActorName( + author: { login: string; __typename?: string } | null | undefined, +): string { + if (!author) return "ghost"; + + if (author.__typename === "Bot" && !author.login.endsWith("[bot]")) { + return `${author.login}[bot]`; + } + + return author.login; +} + /** * Checks if an actor matches a pattern * Supports wildcards: "*[bot]" matches all bots, "dependabot[bot]" matches specific diff --git a/src/github/utils/image-downloader.ts b/src/github/utils/image-downloader.ts index 4cfa11c..ccd9dbd 100644 --- a/src/github/utils/image-downloader.ts +++ b/src/github/utils/image-downloader.ts @@ -14,6 +14,41 @@ const HTML_IMG_REGEX = new RegExp( "gi", ); +const SIGNED_URL_REGEX = + /https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g; + +// GitHub identifies an uploaded asset by a GUID that appears both in the +// user-attachment URL and in the signed download URL rendered in body_html. +const ASSET_GUID_REGEX = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +function extractAssetGuid(url: string): string | undefined { + return url.match(ASSET_GUID_REGEX)?.[0]?.toLowerCase(); +} + +const SIGNED_URL_HOST = "private-user-images.githubusercontent.com"; + +// Signed download URLs have the shape //-.. +// The GUID must come from the resolved filename, not from anywhere in the raw +// string, so text that merely embeds a GUID cannot claim another asset. +const SIGNED_URL_PATH_REGEX = + /^\/[^/]+\/[^/]*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i; + +const DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS = 30_000; + +function extractSignedUrlAssetGuid(signedUrl: string): string | undefined { + let parsed: URL; + try { + parsed = new URL(signedUrl); + } catch { + return undefined; + } + if (parsed.host !== SIGNED_URL_HOST) { + return undefined; + } + return parsed.pathname.match(SIGNED_URL_PATH_REGEX)?.[1]?.toLowerCase(); +} + type IssueComment = { type: "issue_comment"; id: string; @@ -52,13 +87,19 @@ export type CommentWithImages = | IssueBody | PullRequestBody; +type ImageDownloadOptions = { + timeoutMs?: number; +}; + export async function downloadCommentImages( octokits: Octokits, owner: string, repo: string, comments: CommentWithImages[], + options: ImageDownloadOptions = {}, ): Promise> { const urlToPathMap = new Map(); + const timeoutMs = options.timeoutMs ?? DEFAULT_IMAGE_DOWNLOAD_TIMEOUT_MS; const downloadsDir = "/tmp/github-images"; await fs.mkdir(downloadsDir, { recursive: true }); @@ -174,36 +215,41 @@ export async function downloadCommentImages( } // Extract signed URLs from HTML - const signedUrlRegex = - /https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g; - const signedUrls = bodyHtml.match(signedUrlRegex) || []; - - // Download each image - for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) { - const signedUrl = signedUrls[i]; - const originalUrl = urls[i]; + const signedUrls = bodyHtml.match(SIGNED_URL_REGEX) || []; - if (!signedUrl || !originalUrl) { - continue; + // Index the signed URLs by the asset GUID they reference. The signed + // URLs come from a separate render of the body, so their order and + // count are not guaranteed to line up with the URLs extracted from the + // markdown; pairing by asset identifier keeps each download tied to the + // URL it actually belongs to. + const signedUrlByGuid = new Map(); + for (const signedUrl of signedUrls) { + const guid = extractSignedUrlAssetGuid(signedUrl); + if (guid && !signedUrlByGuid.has(guid)) { + signedUrlByGuid.set(guid, signedUrl); } + } + // Download each image + for (const [i, originalUrl] of urls.entries()) { // Check if we've already downloaded this URL if (urlToPathMap.has(originalUrl)) { continue; } + const guid = extractAssetGuid(originalUrl); + const signedUrl = guid ? signedUrlByGuid.get(guid) : undefined; + if (!signedUrl) { + console.warn( + `No matching signed URL found for ${originalUrl}, skipping`, + ); + continue; + } + try { console.log(`Downloading ${originalUrl}...`); - const imageResponse = await fetch(signedUrl); - if (!imageResponse.ok) { - throw new Error( - `HTTP ${imageResponse.status}: ${imageResponse.statusText}`, - ); - } - - const arrayBuffer = await imageResponse.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); + const buffer = await fetchImage(signedUrl, timeoutMs); // GitHub user-attachment URLs (/user-attachments/assets/) carry // no file extension, so the URL-based guess silently falls back to @@ -243,6 +289,37 @@ export async function downloadCommentImages( return urlToPathMap; } +async function fetchImage(url: string, timeoutMs: number): Promise { + const controller = new AbortController(); + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + controller.abort(); + reject(new Error(`Image download timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + const response = await Promise.race([ + fetch(url, { signal: controller.signal }), + timeoutPromise, + ]); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const arrayBuffer = await Promise.race([ + response.arrayBuffer(), + timeoutPromise, + ]); + return Buffer.from(arrayBuffer); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + } +} + function getImageExtension(url: string): string { const urlParts = url.split("/"); const filename = urlParts[urlParts.length - 1]; diff --git a/src/github/utils/sanitizer.ts b/src/github/utils/sanitizer.ts index 47f60ab..ac8b863 100644 --- a/src/github/utils/sanitizer.ts +++ b/src/github/utils/sanitizer.ts @@ -76,40 +76,82 @@ export function sanitizeContent(content: string): string { return content; } +/** + * Redact well-known credential formats (GitHub, Anthropic, AWS, Slack, JWTs) + * from arbitrary text. Callers don't need to know which vendor a value belongs to. + * + * Vendor-prefixed formats are matched without a leading word boundary: the + * prefix already anchors them, and runtime output frequently puts a word + * character directly against the value (e.g. an ANSI color code ending in `m`, + * or a serialized JSON escape such as `\n`). + */ +export function redactSecrets(content: string): string { + content = redactGitHubTokens(content); + + // Anthropic API keys: sk-ant-... + content = content.replace( + /sk-ant-[A-Za-z0-9_-]{20,}/g, + "[REDACTED_ANTHROPIC_KEY]", + ); + + // AWS access key ids: AKIA/ASIA followed by 16 uppercase alphanumerics. All + // uppercase alphanumeric, so keep a leading boundary to avoid matching inside + // larger blobs; also treat a JSON escape or ANSI color code as a boundary. + content = content.replace( + /(?:\b|(?<=\\(?:[nrtbf"\\/]|u[0-9a-fA-F]{4}))|(?<=\[[0-9;]*m))(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + "[REDACTED_AWS_KEY_ID]", + ); + + // Slack tokens: xoxb-, xoxp-, xoxa-, xoxs-, xoxr- + content = content.replace( + /xox[abpsr]-[A-Za-z0-9-]{10,}/g, + "[REDACTED_SLACK_TOKEN]", + ); + + // JWT-shaped strings: three base64url segments, the first two starting + // with eyJ (base64 of `{"`). + content = content.replace( + /eyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{10,2000}\b/g, + "[REDACTED_JWT]", + ); + + return content; +} + export function redactGitHubTokens(content: string): string { // GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bghp_[A-Za-z0-9]{36}\b/g, + /ghp_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bgho_[A-Za-z0-9]{36}\b/g, + /gho_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub user-to-server tokens: ghu_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bghu_[A-Za-z0-9]{36}\b/g, + /ghu_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bghs_[A-Za-z0-9]{36}\b/g, + /ghs_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars) content = content.replace( - /\bghr_[A-Za-z0-9]{36}\b/g, + /ghr_[A-Za-z0-9]{36}\b/g, "[REDACTED_GITHUB_TOKEN]", ); // GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars) content = content.replace( - /\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g, + /github_pat_[A-Za-z0-9_]{11,221}\b/g, "[REDACTED_GITHUB_TOKEN]", ); diff --git a/src/github/validation/permissions.ts b/src/github/validation/permissions.ts index 9b6600a..6fd8551 100644 --- a/src/github/validation/permissions.ts +++ b/src/github/validation/permissions.ts @@ -1,5 +1,5 @@ import * as core from "@actions/core"; -import type { ParsedGitHubContext } from "../context"; +import { isWorkflowRunEvent, type GitHubContext } from "../context"; import type { Octokit } from "@octokit/rest"; /** @@ -24,6 +24,28 @@ function isAllowedBot(actor: string, allowedBots: string): boolean { return allowedList.includes(normalizedActor); } +/** + * Collect the actors whose repository access should be checked. This is + * normally just the workflow actor (GITHUB_ACTOR). For workflow_run events + * the actor that started the upstream run is checked as well when it + * differs, since that is the account the run originates from. + */ +function getActorsToCheck(context: GitHubContext): string[] { + const actors = [context.actor]; + + if (isWorkflowRunEvent(context)) { + const runActor = context.payload.workflow_run?.actor?.login; + if (runActor && !actors.includes(runActor)) { + core.info( + `workflow_run was started by ${runActor}; checking permissions for that actor as well`, + ); + actors.push(runActor); + } + } + + return actors; +} + /** * Check if the actor has write permissions to the repository * @param octokit - The Octokit REST client @@ -34,11 +56,31 @@ function isAllowedBot(actor: string, allowedBots: string): boolean { */ export async function checkWritePermissions( octokit: Octokit, - context: ParsedGitHubContext, + context: GitHubContext, + allowedNonWriteUsers?: string, + githubTokenProvided?: boolean, +): Promise { + for (const actor of getActorsToCheck(context)) { + const allowed = await checkActorWritePermissions( + octokit, + context, + actor, + allowedNonWriteUsers, + githubTokenProvided, + ); + if (!allowed) return false; + } + return true; +} + +async function checkActorWritePermissions( + octokit: Octokit, + context: GitHubContext, + actor: string, allowedNonWriteUsers?: string, githubTokenProvided?: boolean, ): Promise { - const { repository, actor } = context; + const { repository } = context; const allowedBots = context.inputs.allowedBots ?? ""; try { diff --git a/src/github/validation/trigger.ts b/src/github/validation/trigger.ts index 01724e0..41c9e32 100644 --- a/src/github/validation/trigger.ts +++ b/src/github/validation/trigger.ts @@ -38,7 +38,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean { if (isIssuesEvent(context) && context.eventAction === "labeled") { const labelName = (context.payload as any).label?.name || ""; - if (labelTrigger && labelName === labelTrigger) { + if ( + labelTrigger && + labelName.toLowerCase() === labelTrigger.toLowerCase() + ) { console.log(`Issue labeled with trigger label '${labelTrigger}'`); return true; } diff --git a/src/mcp/binary-detection.ts b/src/mcp/binary-detection.ts new file mode 100644 index 0000000..8a87aa5 --- /dev/null +++ b/src/mcp/binary-detection.ts @@ -0,0 +1,22 @@ +/** + * Decides whether a file has to be committed as a base64 blob instead of being + * inlined in the Git tree as UTF-8 text. + * + * Inlining is only safe for content that survives a UTF-8 decode untouched; + * anything else gets its invalid bytes replaced during the decode, which + * silently corrupts the committed file. A NUL byte is treated as binary for the + * same reason Git does it: no text file carries one, and it is the cheapest + * signal available. + */ +export function isBinaryContent(content: Buffer): boolean { + if (content.includes(0)) { + return true; + } + + try { + new TextDecoder("utf-8", { fatal: true }).decode(content); + return false; + } catch { + return true; + } +} diff --git a/src/mcp/github-actions-pagination.ts b/src/mcp/github-actions-pagination.ts new file mode 100644 index 0000000..1bab170 --- /dev/null +++ b/src/mcp/github-actions-pagination.ts @@ -0,0 +1,19 @@ +import type { Octokit } from "@octokit/rest"; + +type ActionsClient = Octokit["actions"]; + +export type WorkflowRunsParams = Parameters< + ActionsClient["listWorkflowRunsForRepo"] +>[0]; + +export type WorkflowJobsParams = Parameters< + ActionsClient["listJobsForWorkflowRun"] +>[0]; + +export function listWorkflowRuns(client: Octokit, params: WorkflowRunsParams) { + return client.paginate(client.actions.listWorkflowRunsForRepo, params); +} + +export function listWorkflowJobs(client: Octokit, params: WorkflowJobsParams) { + return client.paginate(client.actions.listJobsForWorkflowRun, params); +} diff --git a/src/mcp/github-actions-server.ts b/src/mcp/github-actions-server.ts index e600624..e7421e1 100644 --- a/src/mcp/github-actions-server.ts +++ b/src/mcp/github-actions-server.ts @@ -6,6 +6,10 @@ import { z } from "zod"; import { GITHUB_API_URL } from "../github/api/config"; import { mkdir, writeFile } from "fs/promises"; import { Octokit } from "@octokit/rest"; +import { + listWorkflowJobs, + listWorkflowRuns, +} from "./github-actions-pagination"; const REPO_OWNER = process.env.REPO_OWNER; const REPO_NAME = process.env.REPO_NAME; @@ -13,11 +17,18 @@ const PR_NUMBER = process.env.PR_NUMBER; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp"; -if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) { - console.error( - "[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required", - ); - process.exit(1); +// Job logs are fetched by ID from GitHub-hosted storage; bound the request so a +// stalled fetch can't hang this MCP call forever. Mirrors the timeout added to +// fetchImage() in src/github/utils/image-downloader.ts (#1625). +const DOWNLOAD_JOB_LOG_TIMEOUT_MS = 30_000; + +if (import.meta.main) { + if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) { + console.error( + "[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required", + ); + process.exit(1); + } } const server = new McpServer({ @@ -66,7 +77,7 @@ server.tool( }); const headSha = prData.head.sha; - const { data: runsData } = await client.actions.listWorkflowRunsForRepo({ + const runs = await listWorkflowRuns(client, { owner: REPO_OWNER!, repo: REPO_NAME!, head_sha: headSha, @@ -74,7 +85,6 @@ server.tool( }); // Process runs to create summary - const runs = runsData.workflow_runs || []; const summary = { total_runs: runs.length, failed: 0, @@ -148,13 +158,13 @@ server.tool( }); // Get jobs for this workflow run - const { data: jobsData } = await client.actions.listJobsForWorkflowRun({ + const jobs = await listWorkflowJobs(client, { owner: REPO_OWNER!, repo: REPO_NAME!, run_id, }); - const processedJobs = jobsData.jobs.map((job: any) => { + const processedJobs = jobs.map((job: any) => { // Extract failed steps const failedSteps = (job.steps || []) .filter((step: any) => step.conclusion === "failure") @@ -202,6 +212,40 @@ server.tool( }, ); +export async function downloadJobLog( + client: Octokit, + params: { owner: string; repo: string; job_id: number }, + runnerTemp: string, + timeoutMs: number = DOWNLOAD_JOB_LOG_TIMEOUT_MS, +): Promise<{ path: string; size_bytes: number }> { + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await client.actions.downloadJobLogsForWorkflowRun({ + owner: params.owner, + repo: params.repo, + job_id: params.job_id, + request: { signal: controller.signal }, + }); + + const logsText = response.data as unknown as string; + + const logsDir = `${runnerTemp}/github-ci-logs`; + await mkdir(logsDir, { recursive: true }); + + const logPath = `${logsDir}/job-${params.job_id}.log`; + await writeFile(logPath, logsText, "utf-8"); + + return { + path: logPath, + size_bytes: Buffer.byteLength(logsText, "utf-8"), + }; + } finally { + clearTimeout(timeoutHandle); + } +} + server.tool( "download_job_log", "Download job logs to disk", @@ -215,24 +259,11 @@ server.tool( baseUrl: GITHUB_API_URL, }); - const response = await client.actions.downloadJobLogsForWorkflowRun({ - owner: REPO_OWNER!, - repo: REPO_NAME!, - job_id, - }); - - const logsText = response.data as unknown as string; - - const logsDir = `${RUNNER_TEMP}/github-ci-logs`; - await mkdir(logsDir, { recursive: true }); - - const logPath = `${logsDir}/job-${job_id}.log`; - await writeFile(logPath, logsText, "utf-8"); - - const result = { - path: logPath, - size_bytes: Buffer.byteLength(logsText, "utf-8"), - }; + const result = await downloadJobLog( + client, + { owner: REPO_OWNER!, repo: REPO_NAME!, job_id }, + RUNNER_TEMP, + ); return { content: [ @@ -274,6 +305,8 @@ async function runServer() { } } -runServer().catch(() => { - process.exit(1); -}); +if (import.meta.main) { + runServer().catch(() => { + process.exit(1); + }); +} diff --git a/src/mcp/github-comment-server.ts b/src/mcp/github-comment-server.ts index ef6728c..8195eed 100644 --- a/src/mcp/github-comment-server.ts +++ b/src/mcp/github-comment-server.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { GITHUB_API_URL } from "../github/api/config"; import { Octokit } from "@octokit/rest"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; -import { sanitizeContent } from "../github/utils/sanitizer"; +import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer"; // Get repository information from environment variables const REPO_OWNER = process.env.REPO_OWNER; @@ -55,7 +55,7 @@ server.tool( const isPullRequestReviewComment = eventName === "pull_request_review_comment"; - const sanitizedBody = sanitizeContent(body); + const sanitizedBody = redactSecrets(sanitizeContent(body)); const result = await updateClaudeComment(octokit, { owner, diff --git a/src/mcp/github-file-ops-schemas.ts b/src/mcp/github-file-ops-schemas.ts new file mode 100644 index 0000000..cf6fa92 --- /dev/null +++ b/src/mcp/github-file-ops-schemas.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +/** Raw shape passed to `server.tool` for commit_files. */ +export const commitFilesInputSchema = { + files: z + .array(z.string()) + .describe( + 'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.', + ), + message: z.string().describe("Commit message"), +}; + +/** Raw shape passed to `server.tool` for delete_files. */ +export const deleteFilesInputSchema = { + paths: z + .array(z.string()) + .describe( + 'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])', + ), + message: z.string().describe("Commit message"), +}; + +export const commitFilesPayloadSchema = z.object(commitFilesInputSchema); +export const deleteFilesPayloadSchema = z.object(deleteFilesInputSchema); diff --git a/src/mcp/github-file-ops-server.ts b/src/mcp/github-file-ops-server.ts index 4d61621..e341e1d 100644 --- a/src/mcp/github-file-ops-server.ts +++ b/src/mcp/github-file-ops-server.ts @@ -2,14 +2,18 @@ // GitHub File Operations MCP Server import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; import { readFile, stat } from "fs/promises"; import { resolve } from "path"; import { constants } from "fs"; import fetch from "node-fetch"; import { GITHUB_API_URL } from "../github/api/config"; -import { retryWithBackoff } from "../utils/retry"; +import { isBinaryContent } from "./binary-detection"; import { validatePathWithinRepo } from "./path-validation"; +import { updateGitReference } from "./update-git-reference"; +import { + commitFilesInputSchema, + deleteFilesInputSchema, +} from "./github-file-ops-schemas"; type GitHubRef = { object: { @@ -196,14 +200,7 @@ async function getFileMode(filePath: string): Promise { server.tool( "commit_files", "Commit one or more files to a repository in a single commit (this will commit them atomically in the remote repository)", - { - files: z - .array(z.string()) - .describe( - 'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.', - ), - message: z.string().describe("Commit message"), - }, + commitFilesInputSchema, async ({ files, message }) => { const owner = REPO_OWNER; const repo = REPO_NAME; @@ -258,17 +255,14 @@ server.tool( // Get the proper file mode based on file permissions const fileMode = await getFileMode(fullPath); - // Check if file is binary (images, etc.) - const isBinaryFile = - /\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test( - relativePath, - ); + // Check if the file is binary by inspecting its contents. An + // extension allowlist used to decide this, which corrupted every + // binary type that wasn't on the list. + const fileContent = await readFile(fullPath); - if (isBinaryFile) { + if (isBinaryContent(fileContent)) { // For binary files, create a blob first using the Blobs API - const binaryContent = await readFile(fullPath); - - // Create blob using Blobs API (supports encoding parameter) + // (supports the encoding parameter) const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`; const blobResponse = await fetch(blobUrl, { method: "POST", @@ -279,7 +273,7 @@ server.tool( "Content-Type": "application/json", }, body: JSON.stringify({ - content: binaryContent.toString("base64"), + content: fileContent.toString("base64"), encoding: "base64", }), }); @@ -302,12 +296,11 @@ server.tool( }; } else { // For text files, include content directly in tree - const content = await readFile(fullPath, "utf-8"); return { path: relativePath, mode: fileMode, type: "blob", - content: content, + content: fileContent.toString("utf-8"), }; } }), @@ -365,57 +358,13 @@ server.tool( const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit; // 6. Update the reference to point to the new commit - const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; - - // We're seeing intermittent 403 "Resource not accessible by integration" errors - // on certain repos when updating git references. These appear to be transient - // GitHub API issues that succeed on retry. - await retryWithBackoff( - async () => { - const updateRefResponse = await fetch(updateRefUrl, { - method: "PATCH", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sha: newCommitData.sha, - force: false, - }), - }); - - if (!updateRefResponse.ok) { - const errorText = await updateRefResponse.text(); - - // Provide a more helpful error message for 403 permission errors - if (updateRefResponse.status === 403) { - const permissionError = new Error( - `Permission denied: Unable to push commits to branch '${branch}'. ` + - `Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` + - `Original error: ${errorText}`, - ); - throw permissionError; - } - - // For other errors, use the original message - const error = new Error( - `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, - ); - - // For non-403 errors, fail immediately without retry - console.error("Non-retryable error:", updateRefResponse.status); - throw error; - } - }, - { - maxAttempts: 3, - initialDelayMs: 1000, // Start with 1 second delay - maxDelayMs: 5000, // Max 5 seconds delay - backoffFactor: 2, // Double the delay each time - }, - ); + await updateGitReference({ + owner, + repo, + branch, + sha: newCommitData.sha, + githubToken, + }); const simplifiedResult = { commit: { @@ -461,14 +410,7 @@ server.tool( server.tool( "delete_files", "Delete one or more files from a repository in a single commit", - { - paths: z - .array(z.string()) - .describe( - 'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])', - ), - message: z.string().describe("Commit message"), - }, + deleteFilesInputSchema, async ({ paths, message }) => { const owner = REPO_OWNER; const repo = REPO_NAME; @@ -479,21 +421,18 @@ server.tool( throw new Error("GITHUB_TOKEN environment variable is required"); } - // Convert absolute paths to relative if they match CWD - const cwd = process.cwd(); - const processedPaths = paths.map((filePath) => { - if (filePath.startsWith("/")) { - if (filePath.startsWith(cwd)) { - // Strip CWD from absolute path - return filePath.slice(cwd.length + 1); - } else { - throw new Error( - `Path '${filePath}' must be relative to repository root or within current working directory`, - ); - } - } - return filePath; - }); + // Validate all paths are within the repository root and normalize them to + // repo-relative paths for the git tree entries. This mirrors the validation + // already performed by the commit_files tool and rejects path traversal + // ("../") and symlinked escapes as defense-in-depth. + const resolvedRepoDir = resolve(REPO_DIR); + const processedPaths = await Promise.all( + paths.map(async (filePath) => { + await validatePathWithinRepo(filePath, REPO_DIR); + const normalizedPath = resolve(resolvedRepoDir, filePath); + return normalizedPath.slice(resolvedRepoDir.length + 1); + }), + ); // 1. Get the branch reference (create if doesn't exist) const baseSha = await getOrCreateBranchRef( @@ -580,58 +519,13 @@ server.tool( const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit; // 6. Update the reference to point to the new commit - const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; - - // We're seeing intermittent 403 "Resource not accessible by integration" errors - // on certain repos when updating git references. These appear to be transient - // GitHub API issues that succeed on retry. - await retryWithBackoff( - async () => { - const updateRefResponse = await fetch(updateRefUrl, { - method: "PATCH", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sha: newCommitData.sha, - force: false, - }), - }); - - if (!updateRefResponse.ok) { - const errorText = await updateRefResponse.text(); - - // Provide a more helpful error message for 403 permission errors - if (updateRefResponse.status === 403) { - console.log("Received 403 error, will retry..."); - const permissionError = new Error( - `Permission denied: Unable to push commits to branch '${branch}'. ` + - `Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` + - `Original error: ${errorText}`, - ); - throw permissionError; - } - - // For other errors, use the original message - const error = new Error( - `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, - ); - - // For non-403 errors, fail immediately without retry - console.error("Non-retryable error:", updateRefResponse.status); - throw error; - } - }, - { - maxAttempts: 3, - initialDelayMs: 1000, // Start with 1 second delay - maxDelayMs: 5000, // Max 5 seconds delay - backoffFactor: 2, // Double the delay each time - }, - ); + await updateGitReference({ + owner, + repo, + branch, + sha: newCommitData.sha, + githubToken, + }); const simplifiedResult = { commit: { diff --git a/src/mcp/github-inline-comment-server.ts b/src/mcp/github-inline-comment-server.ts index a023d91..129fc40 100644 --- a/src/mcp/github-inline-comment-server.ts +++ b/src/mcp/github-inline-comment-server.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { appendFileSync } from "fs"; import { z } from "zod"; import { createOctokit } from "../github/api/client"; -import { sanitizeContent } from "../github/utils/sanitizer"; +import { redactSecrets, sanitizeContent } from "../github/utils/sanitizer"; import { removeBufferedComment } from "./inline-comment-buffer"; // Get repository and PR information from environment variables @@ -98,8 +98,8 @@ server.tool( const repo = REPO_NAME; const pull_number = parseInt(PR_NUMBER, 10); - // Sanitize the comment body to remove any potential GitHub tokens - const sanitizedBody = sanitizeContent(body); + // Sanitize the comment body to remove potential prompt injections and redact secrets + const sanitizedBody = redactSecrets(sanitizeContent(body)); // Validate that either line or both startLine and line are provided if (!line && !startLine) { diff --git a/src/mcp/install-mcp-server.ts b/src/mcp/install-mcp-server.ts index e40b53f..402617e 100644 --- a/src/mcp/install-mcp-server.ts +++ b/src/mcp/install-mcp-server.ts @@ -17,6 +17,20 @@ type PrepareConfigParams = { context: GitHubContext; }; +// Build the bun invocation for one of the action's own MCP servers. The +// flags mirror the entrypoint invocation in action.yml so the server process +// reads its runtime config from the action directory rather than from the +// process working directory. +function bunServerArgs(scriptPath: string): string[] { + const actionPath = process.env.GITHUB_ACTION_PATH; + return [ + "--no-env-file", + `--config=${actionPath}/bunfig.toml`, + "run", + `${actionPath}/${scriptPath}`, + ]; +} + async function checkActionsReadPermission( token: string, owner: string, @@ -69,20 +83,25 @@ export async function prepareMcpConfig( // Detect if we're in agent mode (explicit prompt provided) const isAgentMode = mode === "agent"; - const hasGitHubCommentTools = allowedToolsList.some((tool) => - tool.startsWith("mcp__github_comment__"), + const hasGitHubCommentTools = allowedToolsList.some( + (tool) => + tool === "mcp__github_comment" || + tool.startsWith("mcp__github_comment__"), ); - const hasGitHubMcpTools = allowedToolsList.some((tool) => - tool.startsWith("mcp__github__"), + const hasGitHubMcpTools = allowedToolsList.some( + (tool) => tool === "mcp__github" || tool.startsWith("mcp__github__"), ); - const hasInlineCommentTools = allowedToolsList.some((tool) => - tool.startsWith("mcp__github_inline_comment__"), + const hasInlineCommentTools = allowedToolsList.some( + (tool) => + tool === "mcp__github_inline_comment" || + tool.startsWith("mcp__github_inline_comment__"), ); - const hasGitHubCITools = allowedToolsList.some((tool) => - tool.startsWith("mcp__github_ci__"), + const hasGitHubCITools = allowedToolsList.some( + (tool) => + tool === "mcp__github_ci" || tool.startsWith("mcp__github_ci__"), ); const baseMcpConfig: { mcpServers: Record } = { @@ -97,10 +116,7 @@ export async function prepareMcpConfig( if (shouldIncludeCommentServer) { baseMcpConfig.mcpServers.github_comment = { command: "bun", - args: [ - "run", - `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`, - ], + args: bunServerArgs("src/mcp/github-comment-server.ts"), env: { GITHUB_TOKEN: githubToken, REPO_OWNER: owner, @@ -116,10 +132,7 @@ export async function prepareMcpConfig( if (context.inputs.useCommitSigning) { baseMcpConfig.mcpServers.github_file_ops = { command: "bun", - args: [ - "run", - `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`, - ], + args: bunServerArgs("src/mcp/github-file-ops-server.ts"), env: { GITHUB_TOKEN: githubToken, REPO_OWNER: owner, @@ -142,10 +155,7 @@ export async function prepareMcpConfig( ) { baseMcpConfig.mcpServers.github_inline_comment = { command: "bun", - args: [ - "run", - `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`, - ], + args: bunServerArgs("src/mcp/github-inline-comment-server.ts"), env: { GITHUB_TOKEN: githubToken, REPO_OWNER: owner, @@ -187,10 +197,7 @@ export async function prepareMcpConfig( } else { baseMcpConfig.mcpServers.github_ci = { command: "bun", - args: [ - "run", - `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`, - ], + args: bunServerArgs("src/mcp/github-actions-server.ts"), env: { // Use workflow github token, not app token GITHUB_TOKEN: process.env.DEFAULT_WORKFLOW_TOKEN, diff --git a/src/mcp/update-git-reference.ts b/src/mcp/update-git-reference.ts new file mode 100644 index 0000000..9b3bc99 --- /dev/null +++ b/src/mcp/update-git-reference.ts @@ -0,0 +1,90 @@ +import fetch, { type RequestInit, type Response } from "node-fetch"; +import { GITHUB_API_URL } from "../github/api/config"; +import { retryWithBackoff, type RetryOptions } from "../utils/retry"; + +type GitHubFetch = ( + url: string, + init: RequestInit, +) => Promise>; + +type UpdateGitReferenceOptions = { + owner: string; + repo: string; + branch: string; + sha: string; + githubToken: string; + fetchFn?: GitHubFetch; + retryOptions?: Omit; +}; + +class GitReferenceUpdateError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "GitReferenceUpdateError"; + } +} + +function shouldRetryGitReferenceUpdate(error: Error): boolean { + if (!(error instanceof GitReferenceUpdateError)) { + return true; + } + + return error.status === 403 || error.status === 429 || error.status >= 500; +} + +export async function updateGitReference({ + owner, + repo, + branch, + sha, + githubToken, + fetchFn = fetch, + retryOptions, +}: UpdateGitReferenceOptions): Promise { + const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; + + await retryWithBackoff( + async () => { + const response = await fetchFn(updateRefUrl, { + method: "PATCH", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ sha, force: false }), + }); + + if (response.ok) { + return; + } + + const errorText = await response.text(); + if (response.status === 403) { + throw new GitReferenceUpdateError( + response.status, + `Permission denied: Unable to push commits to branch '${branch}'. ` + + `Please rebase your branch from the main/master branch to allow Claude to commit.\n\n` + + `Original error: ${errorText}`, + ); + } + + throw new GitReferenceUpdateError( + response.status, + `Failed to update reference: ${response.status} - ${errorText}`, + ); + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + maxDelayMs: 5000, + backoffFactor: 2, + ...retryOptions, + shouldRetry: shouldRetryGitReferenceUpdate, + }, + ); +} diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index aca8d53..d52ac0c 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -3,6 +3,7 @@ import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { parseAllowedTools } from "./parse-tools"; import { configureGitAuth, + replaceCheckoutCredentials, setupSshSigning, } from "../../github/operations/git-config"; import { checkHumanActor } from "../../github/validation/actor"; @@ -62,6 +63,16 @@ export async function prepareAgentMode({ console.error("Failed to configure git authentication:", error); // Continue anyway - git operations may still work with default config } + } else { + // Commits go through the GitHub API, so no git user setup is needed, but + // the credential actions/checkout left in git config should still be + // replaced with the action's own. + try { + await replaceCheckoutCredentials(githubToken, context); + } catch (error) { + console.error("Failed to configure git credentials:", error); + // Continue anyway - git operations may still work with default config + } } // Create prompt directory. Clear any stale files from a prior invocation first — diff --git a/src/modes/detector.ts b/src/modes/detector.ts index c15ce88..46e1366 100644 --- a/src/modes/detector.ts +++ b/src/modes/detector.ts @@ -103,6 +103,7 @@ function validateTrackProgressEvent(context: GitHubContext): void { "synchronize", "ready_for_review", "reopened", + "labeled", ]; if (!validActions.includes(context.eventAction)) { throw new Error( diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index bfbeaea..54e838a 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -3,12 +3,13 @@ import { createInitialComment } from "../../github/operations/comments/create-in import { setupBranch } from "../../github/operations/branch"; import { configureGitAuth, + replaceCheckoutCredentials, setupSshSigning, } from "../../github/operations/git-config"; import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { fetchGitHubData, - extractTriggerTimestamp, + resolveTriggerTimestamp, extractOriginalTitle, extractOriginalBody, } from "../../github/data/fetcher"; @@ -45,7 +46,7 @@ export async function prepareTagMode({ const commentData = await createInitialComment(octokit.rest, context); const commentId = commentData.id; - const triggerTime = extractTriggerTimestamp(context); + const triggerTime = await resolveTriggerTimestamp(context, octokit); const originalTitle = extractOriginalTitle(context); const originalBody = extractOriginalBody(context); @@ -98,6 +99,16 @@ export async function prepareTagMode({ console.error("Failed to configure git authentication:", error); throw error; } + } else { + // Commits go through the GitHub API, so no git user setup is needed, but + // the credential actions/checkout left in git config should still be + // replaced with the action's own. + try { + await replaceCheckoutCredentials(githubToken, context); + } catch (error) { + console.error("Failed to configure git credentials:", error); + throw error; + } } // Create prompt file diff --git a/src/utils/branch-template.ts b/src/utils/branch-template.ts index fecfacd..f9fac93 100644 --- a/src/utils/branch-template.ts +++ b/src/utils/branch-template.ts @@ -72,6 +72,21 @@ export function applyBranchTemplate( return result; } +/** + * Collapses empty path segments produced when a template variable resolves to + * an empty string. For example, an issue title with no alphanumeric characters + * (emoji-only, CJK-only, or punctuation-only) makes `{{description}}` empty, so + * a template like `{{prefix}}{{description}}/{{entityNumber}}` yields + * `claude//123`. Consecutive slashes — and a leading or trailing slash — are + * rejected by `validateBranchName`, which aborts the whole run, so normalize + * them into a valid branch name instead of crashing. + */ +function collapseEmptyPathSegments(branchName: string): string { + return branchName + .replace(/\/{2,}/g, "/") // collapse runs of slashes left by empty segments + .replace(/^\/+|\/+$/g, ""); // drop leading/trailing slashes +} + /** * Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result. */ @@ -97,7 +112,9 @@ export function generateBranchName( }; if (template?.trim()) { - const branchName = applyBranchTemplate(template, variables); + const branchName = collapseEmptyPathSegments( + applyBranchTemplate(template, variables), + ); // Some templates could produce empty results- validate if (branchName.trim().length > 0) return branchName; diff --git a/test/action-metadata.test.ts b/test/action-metadata.test.ts new file mode 100644 index 0000000..008216b --- /dev/null +++ b/test/action-metadata.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +describe("action metadata", () => { + test("should expose the conclusion output from the run step", () => { + const metadata = readFileSync( + new URL("../action.yml", import.meta.url), + "utf8", + ); + + expect(metadata).toMatch( + /^ conclusion:\n description: .+\n value: \$\{\{ steps\.run\.outputs\.conclusion \}\}$/m, + ); + }); +}); diff --git a/test/actor-filter.test.ts b/test/actor-filter.test.ts index e15cb04..3f1a441 100644 --- a/test/actor-filter.test.ts +++ b/test/actor-filter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { parseActorFilter, actorMatchesPattern, + resolveActorName, shouldIncludeCommentByActor, } from "../src/github/utils/actor-filter"; @@ -170,3 +171,49 @@ describe("shouldIncludeCommentByActor", () => { ).toBe(false); }); }); + +describe("resolveActorName", () => { + test("appends the [bot] suffix to GraphQL App actors", () => { + // GraphQL returns the bare login for bots; REST would say "dependabot[bot]". + expect(resolveActorName({ __typename: "Bot", login: "dependabot" })).toBe( + "dependabot[bot]", + ); + }); + + test("leaves human logins untouched", () => { + expect(resolveActorName({ __typename: "User", login: "octocat" })).toBe( + "octocat", + ); + }); + + test("does not double-suffix a login that already ends with [bot]", () => { + expect( + resolveActorName({ __typename: "Bot", login: "dependabot[bot]" }), + ).toBe("dependabot[bot]"); + }); + + test("maps deleted accounts to ghost", () => { + expect(resolveActorName(null)).toBe("ghost"); + expect(resolveActorName(undefined)).toBe("ghost"); + }); + + test("falls back to the login when __typename is absent", () => { + expect(resolveActorName({ login: "octocat" })).toBe("octocat"); + }); + + test("a bot actor matches the *[bot] wildcard once resolved", () => { + const actor = resolveActorName({ __typename: "Bot", login: "renovate" }); + + expect(actorMatchesPattern(actor, "*[bot]")).toBe(true); + // The raw GraphQL login never matches, which is the bug being fixed. + expect(actorMatchesPattern("renovate", "*[bot]")).toBe(false); + }); + + test("a bot actor matches an exact [bot] pattern once resolved", () => { + const actor = resolveActorName({ __typename: "Bot", login: "dependabot" }); + + expect(shouldIncludeCommentByActor(actor, [], ["dependabot[bot]"])).toBe( + false, + ); + }); +}); diff --git a/test/binary-detection.test.ts b/test/binary-detection.test.ts new file mode 100644 index 0000000..3737c8b --- /dev/null +++ b/test/binary-detection.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { isBinaryContent } from "../src/mcp/binary-detection"; + +describe("isBinaryContent", () => { + describe("text content", () => { + it("treats ASCII as text", () => { + expect(isBinaryContent(Buffer.from("hello world\n"))).toBe(false); + }); + + it("treats multibyte UTF-8 as text", () => { + expect(isBinaryContent(Buffer.from("café — 日本語 🎉\n"))).toBe(false); + }); + + it("treats an empty file as text", () => { + expect(isBinaryContent(Buffer.from(""))).toBe(false); + }); + + it("treats CRLF and tabs as text", () => { + expect(isBinaryContent(Buffer.from("a\tb\r\nc\r\n"))).toBe(false); + }); + }); + + describe("binary content", () => { + // The extensions below are the ones the previous allowlist covered, so + // these files were already committed correctly. + it("detects PNG", () => { + expect( + isBinaryContent(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a])), + ).toBe(true); + }); + + // These are the regression cases: binary formats that were not on the + // allowlist and got decoded as UTF-8, corrupting the committed bytes. + it("detects BMP", () => { + expect( + isBinaryContent(Buffer.from([0x42, 0x4d, 0x36, 0x00, 0x00, 0x00])), + ).toBe(true); + }); + + it("detects SQLite databases", () => { + expect(isBinaryContent(Buffer.from("SQLite format 3\0", "binary"))).toBe( + true, + ); + }); + + it("detects WebAssembly modules", () => { + expect( + isBinaryContent(Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00])), + ).toBe(true); + }); + + it("detects arbitrary invalid UTF-8 without NUL bytes", () => { + // Lone continuation bytes: no NUL, but not decodable as UTF-8 either. + expect(isBinaryContent(Buffer.from([0xc3, 0x28, 0xa0, 0xa1]))).toBe(true); + }); + + it("detects a truncated multibyte sequence", () => { + // First two bytes of a 3-byte character, cut short. + expect(isBinaryContent(Buffer.from([0xe6, 0x97]))).toBe(true); + }); + }); + + it("round-trips text through UTF-8 without loss", () => { + const original = "acentuação, emoji 🚀, símbolos ±≠"; + const buffer = Buffer.from(original); + + expect(isBinaryContent(buffer)).toBe(false); + expect(buffer.toString("utf-8")).toBe(original); + }); + + it("preserves bytes that a UTF-8 decode would have replaced", () => { + const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x10, 0x4a]); + + expect(isBinaryContent(bytes)).toBe(true); + // What the old text path would have produced, versus base64. + expect(Buffer.from(bytes.toString("utf-8"), "utf-8")).not.toEqual(bytes); + expect(Buffer.from(bytes.toString("base64"), "base64")).toEqual(bytes); + }); +}); diff --git a/test/branch-cleanup-restored-config.test.ts b/test/branch-cleanup-restored-config.test.ts new file mode 100644 index 0000000..54dda18 --- /dev/null +++ b/test/branch-cleanup-restored-config.test.ts @@ -0,0 +1,262 @@ +#!/usr/bin/env bun + +/** + * Tests the interaction between restoreConfigFromBase and the auto-commit in + * checkAndCommitOrDeleteBranch. + * + * On pull requests the restore replaces .claude/, CLAUDE.md and friends with + * the base branch's versions and leaves them unstaged, so the revert does not + * reach a commit. A bare `git add -A` re-staged them anyway and pushed a silent + * revert of the PR author's own config onto their branch. + * + * These run against real git — the fix is a pathspec, so a mock would only + * assert that the arguments were passed, not that git honours them. + */ + +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup"; +import { SENSITIVE_PATHS } from "../src/github/operations/restore-config"; +import type { Octokits } from "../src/github/api/client"; + +const BRANCH = "claude/issue-1-20260101-0000"; + +let workDir: string; +let remoteDir: string; +let originalCwd: string; +let logSpy: ReturnType; +let errorSpy: ReturnType; + +function git(...args: string[]): string { + return execFileSync("git", args, { cwd: workDir, encoding: "utf-8" }).trim(); +} + +function write(relative: string, contents: string) { + const full = join(workDir, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, contents); +} + +/** Branch exists, and has no commits ahead of base, so cleanup inspects git. */ +const mockOctokit = { + rest: { + repos: { + getBranch: async () => ({ data: {} }), + compareCommitsWithBasehead: async () => ({ + data: { total_commits: 0 }, + }), + }, + git: { deleteRef: async () => ({ data: {} }) }, + }, +} as unknown as Octokits; + +beforeEach(() => { + originalCwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "branch-cleanup-")); + remoteDir = join(root, "remote.git"); + workDir = join(root, "work"); + + execFileSync("git", ["init", "-q", "--bare", remoteDir]); + execFileSync("git", ["init", "-q", "-b", "main", workDir]); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("remote", "add", "origin", remoteDir); + + write(".claude/settings.json", '{"from":"base"}\n'); + write("CLAUDE.md", "base docs\n"); + write("src/app.ts", "base code\n"); + git("add", "-A"); + git("commit", "-qm", "base"); + git("push", "-q", "origin", "main"); + git("checkout", "-qb", BRANCH); + git("push", "-q", "origin", BRANCH); + + process.chdir(workDir); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + process.chdir(originalCwd); + logSpy.mockRestore(); + errorSpy.mockRestore(); + rmSync(join(workDir, ".."), { recursive: true, force: true }); +}); + +/** Files touched by the most recent commit. */ +function filesInHeadCommit(): string[] { + return git("show", "--name-only", "--format=", "HEAD") + .split("\n") + .filter(Boolean) + .sort(); +} + +/** + * Reproduce the working-tree state restoreConfigFromBase leaves behind: the + * PR-authored config overwritten with the base branch's content, unstaged, so + * git reports it as a plain modification. + */ +function simulateRestoredConfig() { + write(".claude/settings.json", '{"from":"base"}\n'); + write("CLAUDE.md", "base docs\n"); +} + +function authorPrConfigEdits() { + write(".claude/settings.json", '{"from":"pr-author"}\n'); + write("CLAUDE.md", "pr author docs\n"); + git("commit", "-qam", "PR author edits config"); +} + +describe("auto-commit with restored config paths", () => { + test("does not commit the base-branch revert onto the PR branch", async () => { + authorPrConfigEdits(); + simulateRestoredConfig(); // config now reverted + unstaged + write("src/app.ts", "claude's real change\n"); + + const result = await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [...SENSITIVE_PATHS], + ); + + expect(filesInHeadCommit()).toEqual(["src/app.ts"]); + expect(result.shouldDeleteBranch).toBe(false); + }); + + test("leaves the reverted config dirty in the working tree", async () => { + authorPrConfigEdits(); + simulateRestoredConfig(); + write("src/app.ts", "claude's real change\n"); + + await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [...SENSITIVE_PATHS], + ); + + // --name-only gives bare paths, avoiding porcelain's status-column prefix. + const stillDirty = git("diff", "--name-only") + .split("\n") + .filter(Boolean) + .sort(); + expect(stillDirty).toEqual([".claude/settings.json", "CLAUDE.md"]); + }); + + test("treats a branch whose only change is the revert as empty", async () => { + // No real work — just the reverted config. Committing here would push a + // pure revert and keep an otherwise-empty branch alive. + authorPrConfigEdits(); + simulateRestoredConfig(); + + const before = git("rev-parse", "HEAD"); + const result = await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [...SENSITIVE_PATHS], + ); + + expect(git("rev-parse", "HEAD")).toBe(before); + expect(result.shouldDeleteBranch).toBe(true); + expect(result.branchLink).toBe(""); + }); + + test("still commits Claude's own changes to non-config files", async () => { + authorPrConfigEdits(); + simulateRestoredConfig(); + write("src/app.ts", "changed\n"); + write("src/new-file.ts", "added\n"); + + await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [...SENSITIVE_PATHS], + ); + + expect(filesInHeadCommit()).toEqual(["src/app.ts", "src/new-file.ts"]); + }); + + test("pushes the commit to the branch", async () => { + authorPrConfigEdits(); + simulateRestoredConfig(); + write("src/app.ts", "claude's real change\n"); + + const result = await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [...SENSITIVE_PATHS], + ); + + const remoteHead = execFileSync( + "git", + ["--git-dir", remoteDir, "rev-parse", BRANCH], + { encoding: "utf-8" }, + ).trim(); + expect(remoteHead).toBe(git("rev-parse", "HEAD")); + expect(result.branchLink).toContain(BRANCH); + }); +}); + +describe("without restored config paths (the issue path)", () => { + test("commits config changes normally, since no revert happened", async () => { + // Reached for issues, where restoreConfigFromBase never runs and Claude may + // have been asked to edit CLAUDE.md. Excluding it here would drop the work. + write("CLAUDE.md", "claude wrote these docs\n"); + write(".claude/settings.json", '{"written":"by claude"}\n'); + write("src/app.ts", "and some code\n"); + + await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + [], + ); + + expect(filesInHeadCommit()).toEqual([ + ".claude/settings.json", + "CLAUDE.md", + "src/app.ts", + ]); + }); + + test("defaults to committing everything when the argument is omitted", async () => { + // Backwards compatibility: the parameter is optional. + write("CLAUDE.md", "claude wrote these docs\n"); + + await checkAndCommitOrDeleteBranch( + mockOctokit, + "owner", + "repo", + BRANCH, + "main", + false, + ); + + expect(filesInHeadCommit()).toEqual(["CLAUDE.md"]); + }); +}); diff --git a/test/branch-template.test.ts b/test/branch-template.test.ts index 418eaae..dbb7d52 100644 --- a/test/branch-template.test.ts +++ b/test/branch-template.test.ts @@ -291,5 +291,68 @@ describe("branch template utilities", () => { expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/); expect(result.length).toBeLessThanOrEqual(50); }); + + // Regression: a title with no ASCII-alphanumeric content makes + // {{description}} sanitize to an empty string. Around a slash separator this + // previously produced "claude//123", which validateBranchName rejects + // ("cannot contain consecutive slashes"), aborting the entire run. + it("should collapse the double slash from an empty description (emoji-only title)", () => { + const template = "{{prefix}}{{description}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "🎉🎉🎉", + ); + + expect(result).toBe("claude/123"); + }); + + it("should collapse the double slash for a CJK-only title", () => { + const template = "{{prefix}}{{description}}/{{entityNumber}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "日本語のタイトル", + ); + + expect(result).toBe("claude/123"); + }); + + it("should drop a trailing slash left by an empty trailing segment", () => { + const template = "{{prefix}}{{entityNumber}}/{{description}}"; + const result = generateBranchName( + template, + "claude/", + "issue", + 123, + undefined, + undefined, + "!!! ???", + ); + + expect(result).toBe("claude/123"); + }); + + it("should produce a name that passes validateBranchName when a segment is empty", () => { + const result = generateBranchName( + "{{prefix}}{{description}}/{{entityNumber}}", + "claude/", + "issue", + 123, + undefined, + undefined, + "🎉", + ); + + expect(() => validateBranchName(result)).not.toThrow(); + }); }); }); diff --git a/test/comments-common.test.ts b/test/comments-common.test.ts index b37dec9..6406341 100644 --- a/test/comments-common.test.ts +++ b/test/comments-common.test.ts @@ -10,9 +10,9 @@ import { GITHUB_SERVER_URL } from "../src/github/api/config"; describe("comments/common", () => { describe("createJobRunLink", () => { test("builds a markdown link to the workflow run", () => { - const result = createJobRunLink("anthropics", "claude-code-action", "42"); + const result = createJobRunLink("step-security", "claude-code-action", "42"); expect(result).toBe( - `[View job run](${GITHUB_SERVER_URL}/anthropics/claude-code-action/actions/runs/42)`, + `[View job run](${GITHUB_SERVER_URL}/step-security/claude-code-action/actions/runs/42)`, ); }); @@ -27,12 +27,18 @@ describe("comments/common", () => { describe("createBranchLink", () => { test("builds a leading-newline markdown link to the branch tree", () => { const result = createBranchLink( - "anthropics", + "step-security", "claude-code-action", "feature/x", ); expect(result).toBe( - `\n[View branch](${GITHUB_SERVER_URL}/anthropics/claude-code-action/tree/feature/x)`, + `\n[View branch](${GITHUB_SERVER_URL}/step-security/claude-code-action/tree/feature/x)`, + ); + }); + + test("encodes URL-significant characters in a branch name", () => { + expect(createBranchLink("o", "r", "claude/fix#123")).toBe( + `\n[View branch](${GITHUB_SERVER_URL}/o/r/tree/claude/fix%23123)`, ); }); diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 046a78f..8cbf69e 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -819,11 +819,46 @@ describe("generatePrompt", () => { // Should have commit signing tool instructions expect(prompt).toContain("mcp__github_file_ops__commit_files"); expect(prompt).toContain("mcp__github_file_ops__delete_files"); + expect(prompt).toContain( + 'mcp__github_file_ops__delete_files: {"paths": ["path/to/old.js"]', + ); + expect(prompt).not.toContain( + 'mcp__github_file_ops__delete_files: {"files":', + ); // Comment tool should always be from comment server, not file ops expect(prompt).toContain("mcp__github_comment__update_claude_comment"); // Should not have git command instructions expect(prompt).not.toContain("Use git commands via the Bash tool"); + + // Bash is off unless the user passes --allowedTools through claude_args. + // allowed_tools was removed in v1.0 and must not appear as live guidance. + expect(prompt).toContain( + "Run arbitrary Bash commands (unless explicitly allowed via claude_args with --allowedTools)", + ); + expect(prompt).not.toContain("allowed_tools configuration"); + }); + + test("does not mention allowed_tools when commit signing is off", async () => { + const envVars: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "issue_comment", + commentId: "67890", + isPR: true, + prNumber: "123", + commentBody: "@claude fix the bug", + }, + }; + + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); + + expect(prompt).not.toContain("allowed_tools"); + expect(prompt).not.toContain( + "Run arbitrary Bash commands (unless explicitly allowed", + ); }); describe("simplified prompt (USE_SIMPLE_PROMPT)", () => { diff --git a/test/data-fetcher.test.ts b/test/data-fetcher.test.ts index c054039..5bacb14 100644 --- a/test/data-fetcher.test.ts +++ b/test/data-fetcher.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, test } from "bun:test"; import { extractTriggerTimestamp, + resolveTriggerTimestamp, extractOriginalTitle, extractOriginalBody, fetchGitHubData, @@ -8,6 +9,7 @@ import { filterReviewsToTriggerTime, isBodySafeToUse, } from "../src/github/data/fetcher"; +import type { ParsedGitHubContext } from "../src/github/context"; import { createMockContext, mockIssueCommentContext, @@ -16,6 +18,8 @@ import { mockPullRequestReviewCommentContext, mockPullRequestOpenedContext, mockIssueOpenedContext, + mockIssueAssignedContext, + mockIssueLabeledContext, } from "./mockContext"; import type { GitHubComment, GitHubReview } from "../src/github/types"; @@ -38,15 +42,112 @@ describe("extractTriggerTimestamp", () => { expect(timestamp).toBe("2024-01-15T16:45:00Z"); }); - it("should return undefined for pull_request event", () => { + it("should extract created_at timestamp from pull_request opened event", () => { const context = mockPullRequestOpenedContext; const timestamp = extractTriggerTimestamp(context); - expect(timestamp).toBeUndefined(); + expect(timestamp).toBe("2024-01-15T14:00:00Z"); + }); + + it("should extract updated_at timestamp from pull_request synchronize event", () => { + const context: ParsedGitHubContext = { + ...mockPullRequestOpenedContext, + eventAction: "synchronize", + payload: { + ...(mockPullRequestOpenedContext.payload as any), + action: "synchronize", + }, + }; + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T14:05:00Z"); + }); + + it("should extract updated_at timestamp from pull_request edited event", () => { + const context: ParsedGitHubContext = { + ...mockPullRequestOpenedContext, + eventAction: "edited", + payload: { + ...(mockPullRequestOpenedContext.payload as any), + action: "edited", + }, + }; + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T14:05:00Z"); }); - it("should return undefined for issues event", () => { + it("should extract created_at timestamp from issues opened event", () => { const context = mockIssueOpenedContext; const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T10:30:00Z"); + }); + + it("should fall back to updated_at for issues labeled event", () => { + const context = mockIssueLabeledContext; + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T11:30:00Z"); + }); + + it("should fall back to updated_at for issues assigned event", () => { + const context = mockIssueAssignedContext; + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T11:00:00Z"); + }); + + it("should fall back to created_at for issues labeled event without updated_at", () => { + const context = createMockContext({ + eventName: "issues", + eventAction: "labeled", + payload: { + action: "labeled", + issue: { + number: 1, + title: "test", + body: "test", + created_at: "2024-01-15T08:00:00Z", + }, + } as any, + entityNumber: 1, + isPR: false, + }); + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T08:00:00Z"); + }); + + it("should fall back to created_at for pull_request synchronize event without updated_at", () => { + const context = createMockContext({ + eventName: "pull_request", + eventAction: "synchronize", + payload: { + action: "synchronize", + pull_request: { + number: 1, + title: "test", + body: "test", + created_at: "2024-01-15T08:30:00Z", + }, + } as any, + entityNumber: 1, + isPR: true, + }); + const timestamp = extractTriggerTimestamp(context); + expect(timestamp).toBe("2024-01-15T08:30:00Z"); + }); + + it("should return undefined for issues event without timestamps", () => { + const context = createMockContext({ + eventName: "issues", + eventAction: "labeled", + payload: { + action: "labeled", + issue: { + number: 1, + title: "test", + body: "test", + }, + } as any, + entityNumber: 1, + isPR: false, + }); + const timestamp = extractTriggerTimestamp(context); expect(timestamp).toBeUndefined(); }); @@ -66,6 +167,195 @@ describe("extractTriggerTimestamp", () => { }); }); +describe("resolveTriggerTimestamp", () => { + const createEventsOctokits = (events: any[]) => { + const paginate = jest.fn().mockResolvedValue(events); + return { + octokits: { + rest: { + paginate, + issues: { listEvents: jest.fn() }, + }, + graphql: jest.fn(), + } as any, + paginate, + }; + }; + + it("should use the labeled event time for issues labeled event", async () => { + const { octokits, paginate } = createEventsOctokits([ + { + event: "labeled", + label: { name: "other-label" }, + created_at: "2024-01-15T10:45:00Z", + }, + { + event: "labeled", + label: { name: "claude-task" }, + created_at: "2024-01-15T10:50:00Z", + }, + { + event: "labeled", + label: { name: "claude-task" }, + created_at: "2024-01-15T11:45:00Z", + }, + { + event: "assigned", + assignee: { login: "claude-bot" }, + created_at: "2024-01-15T11:50:00Z", + }, + ]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueLabeledContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T11:45:00Z"); + expect(paginate).toHaveBeenCalledWith(octokits.rest.issues.listEvents, { + owner: "test-owner", + repo: "test-repo", + issue_number: 1234, + per_page: 100, + }); + }); + + it("should use the assigned event time for issues assigned event", async () => { + const { octokits } = createEventsOctokits([ + { + event: "assigned", + assignee: { login: "someone-else" }, + created_at: "2024-01-15T10:40:00Z", + }, + { + event: "assigned", + assignee: { login: "claude-bot" }, + created_at: "2024-01-15T11:05:00Z", + }, + ]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueAssignedContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T11:05:00Z"); + }); + + it("should ignore a matching event that predates the issue's updated_at", async () => { + // A match older than the payload's updated_at is a previous + // labeling, not the one that fired this webhook. + const { octokits } = createEventsOctokits([ + { + event: "labeled", + label: { name: "claude-task" }, + created_at: "2024-01-15T10:50:00Z", + }, + ]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueLabeledContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T11:30:00Z"); + }); + + it("should fall back to updated_at when no matching labeled event exists", async () => { + const { octokits } = createEventsOctokits([ + { + event: "labeled", + label: { name: "unrelated" }, + created_at: "2024-01-15T10:45:00Z", + }, + ]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueLabeledContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T11:30:00Z"); + }); + + it("should fall back to updated_at when the events lookup fails", async () => { + const octokits = { + rest: { + paginate: jest.fn().mockRejectedValue(new Error("API failure")), + issues: { listEvents: jest.fn() }, + }, + graphql: jest.fn(), + } as any; + + const timestamp = await resolveTriggerTimestamp( + mockIssueAssignedContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T11:00:00Z"); + }); + + it("should fall back to created_at when updated_at is also missing", async () => { + const context = createMockContext({ + eventName: "issues", + eventAction: "labeled", + payload: { + action: "labeled", + label: { name: "claude-task" }, + issue: { + number: 1, + title: "test", + body: "test", + created_at: "2024-01-15T08:00:00Z", + }, + } as any, + entityNumber: 1, + isPR: false, + }); + const { octokits } = createEventsOctokits([]); + + const timestamp = await resolveTriggerTimestamp(context, octokits); + + expect(timestamp).toBe("2024-01-15T08:00:00Z"); + }); + + it("should use created_at for issues opened event without an API call", async () => { + const { octokits, paginate } = createEventsOctokits([]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueOpenedContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T10:30:00Z"); + expect(paginate).not.toHaveBeenCalled(); + }); + + it("should use created_at for pull_request opened event without an API call", async () => { + const { octokits, paginate } = createEventsOctokits([]); + + const timestamp = await resolveTriggerTimestamp( + mockPullRequestOpenedContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T14:00:00Z"); + expect(paginate).not.toHaveBeenCalled(); + }); + + it("should use the existing comment timestamp for issue_comment events", async () => { + const { octokits, paginate } = createEventsOctokits([]); + + const timestamp = await resolveTriggerTimestamp( + mockIssueCommentContext, + octokits, + ); + + expect(timestamp).toBe("2024-01-15T12:30:00Z"); + expect(paginate).not.toHaveBeenCalled(); + }); +}); + describe("extractOriginalTitle", () => { it("should extract title from IssueCommentEvent on PR", () => { const title = extractOriginalTitle(mockPullRequestCommentContext); @@ -659,6 +949,90 @@ describe("fetchGitHubData integration with time filtering", () => { expect(result.comments[0]?.body).toBe("Comment before trigger"); }); + it("should filter comments using the resolved issues labeled event time", async () => { + const mockOctokits = { + graphql: jest.fn().mockResolvedValue({ + repository: { + issue: { + number: 1234, + title: "Test Issue", + body: "Issue body", + author: { login: "author" }, + comments: { + nodes: [ + { + id: "1", + databaseId: "1", + body: "Comment before label", + author: { login: "user1" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T10:00:00Z", + }, + { + id: "2", + databaseId: "2", + body: "Comment created after label", + author: { login: "user2" }, + createdAt: "2024-01-15T12:00:00Z", + updatedAt: "2024-01-15T12:00:00Z", + }, + { + id: "3", + databaseId: "3", + body: "Comment edited after label", + author: { login: "user3" }, + createdAt: "2024-01-15T10:00:00Z", + updatedAt: "2024-01-15T12:00:00Z", + lastEditedAt: "2024-01-15T12:00:00Z", + }, + { + id: "4", + databaseId: "4", + body: "Latest comment before label", + author: { login: "user4" }, + createdAt: "2024-01-15T11:30:00Z", + updatedAt: "2024-01-15T11:30:00Z", + }, + ], + }, + }, + }, + user: { login: "trigger-user" }, + }), + rest: { + paginate: jest.fn().mockResolvedValue([ + { + event: "labeled", + label: { name: "claude-task" }, + created_at: "2024-01-15T11:45:00Z", + }, + ]), + issues: { listEvents: jest.fn() }, + }, + }; + + // The issues (labeled) webhook has no trigger comment; the boundary is + // the labeled event's own timestamp from the issue event history. + const triggerTime = await resolveTriggerTimestamp( + mockIssueLabeledContext, + mockOctokits as any, + ); + expect(triggerTime).toBe("2024-01-15T11:45:00Z"); + + const result = await fetchGitHubData({ + octokits: mockOctokits as any, + repository: "test-owner/test-repo", + prNumber: "1234", + isPR: false, + triggerUsername: "trigger-user", + triggerTime, + }); + + // Comments created before the label are kept (including the most + // recent one); comments created or edited after it are excluded. + expect(result.comments.map((c) => c.id)).toEqual(["1", "4"]); + }); + it("should filter PR reviews based on trigger time", async () => { const mockOctokits = { graphql: jest.fn().mockResolvedValue({ @@ -841,7 +1215,10 @@ describe("fetchGitHubData integration with time filtering", () => { { id: "2", databaseId: "2", - author: { login: "scanner[bot]" }, + // GraphQL returns the bare login for App actors plus + // __typename: "Bot". It does NOT append a "[bot]" suffix the + // way REST does, so this mirrors a real payload. + author: { __typename: "Bot", login: "scanner" }, body: "Pre-trigger bot review", state: "COMMENTED", submittedAt: "2024-01-15T11:00:00Z", @@ -1401,6 +1778,49 @@ describe("fetchGitHubData integration with time filtering", () => { // Webhook says no body at trigger time — attacker-added GraphQL body must not be used expect(result.contextData.body).toBe(""); }); + + it("should not crash when GraphQL returns null files for a very large PR", async () => { + // GitHub declines to compute the diff for very large PRs: `files` comes + // back as null (with no errors entry) and `changedFiles` is misreported + // as 0. The fetch must degrade gracefully instead of throwing. + const mockOctokits = { + graphql: jest.fn().mockResolvedValue({ + repository: { + pullRequest: { + number: 7912, + title: "Very large PR", + body: "PR body", + author: { login: "author" }, + createdAt: "2024-01-15T10:00:00Z", + state: "OPEN", + labels: { nodes: [] }, + comments: { nodes: [] }, + files: null, + reviews: { nodes: [] }, + }, + }, + user: { login: "trigger-user" }, + }), + rest: { + pulls: { + listFiles: jest.fn().mockResolvedValue({ data: [] }), + }, + }, + }; + + const result = await fetchGitHubData({ + octokits: mockOctokits as any, + repository: "test-owner/test-repo", + prNumber: "7912", + isPR: true, + triggerUsername: "trigger-user", + triggerTime: "2024-01-15T12:00:00Z", + }); + + // No file list is available, so the PR is processed without file-level context. + expect(result.changedFiles).toEqual([]); + expect(result.changedFilesWithSHA).toEqual([]); + }); }); describe("filterCommentsByActor", () => { diff --git a/test/data-formatter.test.ts b/test/data-formatter.test.ts index f72539d..872791c 100644 --- a/test/data-formatter.test.ts +++ b/test/data-formatter.test.ts @@ -108,6 +108,43 @@ Changed Files: 2 files`, ); }); + test("renders an unknown file count when GraphQL returns null files (very large PR)", () => { + // GitHub declines to compute the diff for very large PRs and returns + // `files: null`. `changedFiles` is misreported as 0 in that case, so the + // count must render as unavailable rather than "0 files". + const prData: GitHubPullRequest = { + title: "Very large PR", + body: "PR body", + author: { login: "test-user" }, + baseRefName: "main", + headRefName: "feature/test", + headRefOid: "abc123", + isCrossRepository: false, + headRepository: { owner: { login: "testowner" }, name: "testrepo" }, + createdAt: "2023-01-01T00:00:00Z", + additions: 50, + deletions: 30, + state: "OPEN", + labels: { + nodes: [], + }, + commits: { + totalCount: 3, + nodes: [], + }, + files: null, + comments: { + nodes: [], + }, + reviews: { + nodes: [], + }, + }; + + const result = formatContext(prData, true); + expect(result).toContain("Changed Files: unknown (file list unavailable)"); + }); + test("formats Issue context correctly", () => { const issueData: GitHubIssue = { title: "Test Issue", @@ -471,6 +508,112 @@ describe("formatReviewComments", () => { ); }); + test("includes the diff hunk as context when present", () => { + const reviewData = { + nodes: [ + { + id: "review1", + databaseId: "300001", + author: { login: "reviewer1" }, + body: "", + state: "COMMENTED", + submittedAt: "2023-01-01T00:00:00Z", + comments: { + nodes: [ + { + id: "comment1", + databaseId: "200001", + body: "This can overflow", + author: { login: "reviewer1" }, + createdAt: "2023-01-01T00:00:00Z", + path: "src/index.ts", + line: 42, + diffHunk: "@@ -40,3 +40,3 @@\n-const a = 1;\n+const a = 2;", + }, + ], + }, + }, + ], + }; + + const result = formatReviewComments(reviewData); + + expect(result).toContain("[Comment on src/index.ts:42]: This can overflow"); + expect(result).toContain("Diff context:"); + expect(result).toContain("@@ -40,3 +40,3 @@"); + expect(result).toContain("+const a = 2;"); + }); + + test("omits the diff context when the comment has no diff hunk", () => { + const reviewData = { + nodes: [ + { + id: "review1", + databaseId: "300001", + author: { login: "reviewer1" }, + body: "", + state: "COMMENTED", + submittedAt: "2023-01-01T00:00:00Z", + comments: { + nodes: [ + { + id: "comment1", + databaseId: "200001", + body: "No hunk here", + author: { login: "reviewer1" }, + createdAt: "2023-01-01T00:00:00Z", + path: "src/index.ts", + line: 42, + }, + ], + }, + }, + ], + }; + + const result = formatReviewComments(reviewData); + + expect(result).toContain("[Comment on src/index.ts:42]: No hunk here"); + expect(result).not.toContain("Diff context:"); + }); + + // GitHub returns line: null and diffHunk: "" for outdated comments whose + // line no longer exists in the diff (observed on step-security/claude-code-action#1025). + test("omits the diff context for an outdated comment with an empty diff hunk", () => { + const reviewData = { + nodes: [ + { + id: "review1", + databaseId: "300001", + author: { login: "reviewer1" }, + body: "", + state: "COMMENTED", + submittedAt: "2023-01-01T00:00:00Z", + comments: { + nodes: [ + { + id: "comment1", + databaseId: "200001", + body: "Outdated comment", + author: { login: "reviewer1" }, + createdAt: "2023-01-01T00:00:00Z", + path: "src/index.ts", + line: null, + diffHunk: "", + }, + ], + }, + }, + ], + }; + + const result = formatReviewComments(reviewData); + + expect(result).toContain("[Comment on src/index.ts:?]: Outdated comment"); + expect(result).not.toContain("Diff context:"); + expect(result).not.toContain("```diff"); + }); + test("formats review with only body (no comments) correctly", () => { const reviewData = { nodes: [ diff --git a/test/delete-files-prompt-schema.test.ts b/test/delete-files-prompt-schema.test.ts new file mode 100644 index 0000000..91b8a49 --- /dev/null +++ b/test/delete-files-prompt-schema.test.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun + +import { describe, expect, test, beforeAll } from "bun:test"; +import { generatePrompt } from "../src/create-prompt"; +import type { PreparedContext } from "../src/create-prompt"; +import { + commitFilesPayloadSchema, + deleteFilesPayloadSchema, +} from "../src/mcp/github-file-ops-schemas"; + +beforeAll(() => { + process.env.GITHUB_ACTION_PATH = "/test/action/path"; +}); + +const mockGitHubData = { + contextData: { + title: "Test PR", + body: "This is a test PR", + author: { login: "testuser" }, + state: "OPEN", + labels: { nodes: [] }, + createdAt: "2023-01-01T00:00:00Z", + additions: 15, + deletions: 5, + baseRefName: "main", + headRefName: "feature-branch", + headRefOid: "abc123", + isCrossRepository: false, + headRepository: { owner: { login: "testowner" }, name: "testrepo" }, + commits: { totalCount: 0, nodes: [] }, + files: { nodes: [] }, + comments: { nodes: [] }, + reviews: { nodes: [] }, + }, + comments: [], + changedFiles: [], + changedFilesWithSHA: [], + reviewData: null, + imageUrlMap: new Map(), +}; + +const signingContext: PreparedContext = { + repository: "owner/repo", + claudeCommentId: "12345", + triggerPhrase: "@claude", + eventData: { + eventName: "issue_comment", + commentId: "67890", + isPR: true, + prNumber: "123", + commentBody: "@claude delete the old file", + }, +}; + +function extractToolExample( + prompt: string, + tool: string, +): Record { + const match = prompt.match( + new RegExp( + `${tool.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(\\{[^}]+\\})`, + ), + ); + if (!match) { + throw new Error(`No JSON example for ${tool} in prompt`); + } + return JSON.parse(match[1] as string); +} + +describe("delete_files prompt vs live MCP schema (#1665)", () => { + test("the payload the old prompt taught is rejected by the tool schema", () => { + const taughtByOldPrompt = { + files: ["path/to/old.js"], + message: "chore: remove deprecated file", + }; + const result = deleteFilesPayloadSchema.safeParse(taughtByOldPrompt); + expect(result.success).toBe(false); + if (!result.success) { + const fields = result.error.issues.map((issue) => issue.path.join(".")); + expect(fields).toContain("paths"); + } + }); + + test("the payload the new prompt teaches is accepted by the tool schema", () => { + const taughtByNewPrompt = { + paths: ["path/to/old.js"], + message: "chore: remove deprecated file", + }; + const result = deleteFilesPayloadSchema.safeParse(taughtByNewPrompt); + expect(result.success).toBe(true); + }); + + test("generated tag-mode prompt example parses against the live schema", async () => { + const prompt = await generatePrompt( + signingContext, + mockGitHubData, + true, + "tag", + ); + const example = extractToolExample( + prompt, + "mcp__github_file_ops__delete_files", + ); + expect(example).toHaveProperty("paths"); + expect(example).not.toHaveProperty("files"); + const result = deleteFilesPayloadSchema.safeParse(example); + expect(result.success).toBe(true); + }); + + test("rejects paths when the value is a string instead of an array", () => { + const result = deleteFilesPayloadSchema.safeParse({ + paths: "path/to/old.js", + message: "chore: remove deprecated file", + }); + expect(result.success).toBe(false); + }); + + test("rejects a payload that has paths but omits message", () => { + const result = deleteFilesPayloadSchema.safeParse({ + paths: ["path/to/old.js"], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.map((issue) => issue.path.join(".")), + ).toContain("message"); + } + }); + + test("accepts a payload that still includes the old files key beside paths", () => { + const result = deleteFilesPayloadSchema.safeParse({ + files: ["path/to/old.js"], + paths: ["path/to/old.js"], + message: "chore: remove deprecated file", + }); + expect(result.success).toBe(true); + }); + + test("does not change commit_files — that sibling tool still requires files", async () => { + const prompt = await generatePrompt( + signingContext, + mockGitHubData, + true, + "tag", + ); + const example = extractToolExample( + prompt, + "mcp__github_file_ops__commit_files", + ); + expect(example).toHaveProperty("files"); + expect(example).not.toHaveProperty("paths"); + expect(commitFilesPayloadSchema.safeParse(example).success).toBe(true); + expect(deleteFilesPayloadSchema.safeParse(example).success).toBe(false); + }); + + test("generated delete_files example keys are exactly paths and message", async () => { + const prompt = await generatePrompt( + signingContext, + mockGitHubData, + true, + "tag", + ); + const example = extractToolExample( + prompt, + "mcp__github_file_ops__delete_files", + ); + expect(Object.keys(example).sort()).toEqual(["message", "paths"]); + }); +}); diff --git a/test/fetch-depth.test.ts b/test/fetch-depth.test.ts new file mode 100644 index 0000000..9830c96 --- /dev/null +++ b/test/fetch-depth.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { setupBranch } from "../src/github/operations/branch"; +import { fetchDepthArgs } from "../src/github/operations/fetch-depth"; +import { createMockContext } from "./mockContext"; + +const octokits = { + rest: { + repos: { get: async () => ({ data: { default_branch: "main" } }) }, + git: { getRef: async () => ({ data: { object: { sha: "abc1234" } } }) }, + }, +} as any; + +const githubData = { + contextData: { title: "Add feature", labels: { nodes: [] } }, +} as any; + +describe("setupBranch fetch depth", () => { + let originalCwd: string; + let tempDir = ""; + let repoDir: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = mkdtempSync(join("/tmp", "fetch-depth-")); + repoDir = join(tempDir, "repo"); + const remoteDir = join(tempDir, "origin.git"); + + // Pin the remote's HEAD to main: with the default init.defaultBranch of + // master it would dangle, and `git clone --depth=1` (which implies + // --single-branch) then produces an empty, non-shallow clone. + execFileSync( + "git", + ["init", "--bare", "--initial-branch=main", remoteDir], + { + stdio: "pipe", + }, + ); + execFileSync("git", ["init", repoDir], { stdio: "pipe" }); + git(["checkout", "-b", "main"]); + git(["config", "user.email", "test@example.com"]); + git(["config", "user.name", "Test User"]); + + for (const message of ["first", "second", "third"]) { + writeFileSync(join(repoDir, `${message}.txt`), `${message}\n`); + git(["add", "."]); + git(["commit", "-m", message]); + } + + git(["remote", "add", "origin", remoteDir]); + git(["push", "-u", "origin", "main"]); + + process.chdir(repoDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + for (const useCommitSigning of [false, true]) { + test(`keeps the full history of a complete checkout with use_commit_signing: ${useCommitSigning}`, async () => { + const context = createMockContext({ + isPR: false, + entityNumber: 7, + inputs: { useCommitSigning, branchPrefix: "claude/" }, + }); + + await setupBranch(octokits, githubData, context); + + expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe( + "false", + ); + expect(git(["rev-list", "--count", "HEAD"]).trim()).toBe("3"); + }); + } + + test("still limits the depth on an already shallow checkout", () => { + const shallowDir = join(tempDir, "shallow"); + execFileSync( + "git", + [ + "clone", + "--depth=1", + `file://${join(tempDir, "origin.git")}`, + shallowDir, + ], + { stdio: "pipe" }, + ); + + process.chdir(shallowDir); + expect( + execFileSync("git", ["rev-parse", "--is-shallow-repository"], { + cwd: shallowDir, + encoding: "utf8", + }).trim(), + ).toBe("true"); + expect(fetchDepthArgs(20)).toEqual(["--depth=20"]); + }); + + test("drops the depth limit on a complete checkout", () => { + expect(fetchDepthArgs(20)).toEqual([]); + }); + + function git(args: string[]): string { + return execFileSync("git", args, { + cwd: repoDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } +}); diff --git a/test/fixtures/graphql-endpoint-probe.ts b/test/fixtures/graphql-endpoint-probe.ts new file mode 100644 index 0000000..8697206 --- /dev/null +++ b/test/fixtures/graphql-endpoint-probe.ts @@ -0,0 +1,41 @@ +// Wire-level probe for the GitHub API client's endpoint routing. +// +// `src/github/api/config.ts` reads GITHUB_API_URL / GITHUB_GRAPHQL_URL at module +// load time, so each endpoint configuration has to be exercised in its own fresh +// process (the companion test spawns this file once per case with the relevant +// env vars set). We stub global fetch to capture the FINAL request URL and +// Authorization header — asserting constructor options is not enough because +// @octokit/graphql rewrites/append the path (".../api/v3" -> ".../api/graphql", +// otherwise it appends "/graphql") after the client is constructed. +import { createOctokit } from "../../src/github/api/client"; + +type Captured = { url: string; auth: string | null }; +const captured: Captured[] = []; + +globalThis.fetch = (async (input: any, init?: any) => { + const url: string = + typeof input === "string" ? input : (input?.url ?? String(input)); + const headers = new Headers(init?.headers ?? input?.headers); + captured.push({ url, auth: headers.get("authorization") }); + return new Response(JSON.stringify({ data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +}) as typeof fetch; + +const octokits = createOctokit("test-token"); + +await octokits.graphql(`query { viewer { login } }`); +const graphql = captured[captured.length - 1]!; + +await octokits.rest.request("GET /meta"); +const rest = captured[captured.length - 1]!; + +process.stdout.write( + JSON.stringify({ + graphqlUrl: graphql.url, + graphqlAuth: graphql.auth, + restUrl: rest.url, + restAuth: rest.auth, + }), +); diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index 7b59bbe..23020d7 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -111,6 +111,42 @@ describe("formatResultContent", () => { const result = formatResultContent(JSON.stringify(structuredContent)); expect(result).toBe("**→** Hello world\n\n"); }); + + test("keeps every text block, not just the first", () => { + const structuredContent = [ + { type: "text", text: "first line" }, + { type: "text", text: "second line" }, + { type: "text", text: "third line" }, + ]; + const result = formatResultContent(JSON.stringify(structuredContent)); + + expect(result).toContain("first line"); + expect(result).toContain("second line"); + expect(result).toContain("third line"); + }); + + test("keeps every text block when given an array directly", () => { + const result = formatResultContent([ + { type: "text", text: "alpha" }, + { type: "text", text: "beta" }, + ]); + + expect(result).toContain("alpha"); + expect(result).toContain("beta"); + }); + + test("skips non-text blocks while keeping the text ones", () => { + const structuredContent = [ + { type: "text", text: "visible" }, + { type: "image", source: { data: "ignored-binary" } }, + { type: "text", text: "also visible" }, + ]; + const result = formatResultContent(JSON.stringify(structuredContent)); + + expect(result).toContain("visible"); + expect(result).toContain("also visible"); + expect(result).not.toContain("ignored-binary"); + }); }); describe("formatToolWithResult", () => { @@ -467,6 +503,15 @@ describe("formatResultContent non-string input", () => { expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); }); + + test("handles a text content block whose text field is not a string", () => { + expect(() => + formatResultContent('[{"type":"text","text":{"foo":"bar"}}]'), + ).not.toThrow(); + expect(formatResultContent('[{"type":"text","text":123}]')).toContain( + "123", + ); + }); }); describe("system_other handling", () => { @@ -515,3 +560,106 @@ describe("system_other handling", () => { expect(result).toContain("## 🚀 System Initialization"); }); }); + +describe("credential redaction", () => { + test("redacts credentials embedded in tool results", () => { + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "Bash", + input: { command: "cat .env" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "toolu_1", + content: + "GITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE", + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(result).toContain("[REDACTED_AWS_KEY_ID]"); + expect(result).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"); + expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE"); + }); + + test("redacts credentials embedded in multi-line tool inputs", () => { + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_2", + name: "Write", + input: { + file_path: ".env", + content: + "AWS_ACCESS_KEY_ID=x\nGITHUB_TOKEN=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\n", + }, + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(result).not.toContain("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"); + }); + + test("redacts credentials wrapped in ANSI color codes", () => { + const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "toolu_3", + name: "Bash", + input: { command: "node print-config.js" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "toolu_3", + content: `apiKey: \x1b[32m${key}\x1b[39m\nregion: us-east-1`, + }, + ], + }, + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(result).not.toContain(key); + }); +}); diff --git a/test/git-config.test.ts b/test/git-config.test.ts new file mode 100644 index 0000000..e697698 --- /dev/null +++ b/test/git-config.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { mkdtempSync, rmSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + configureGitAuth, + replaceCheckoutCredentials, +} from "../src/github/operations/git-config"; +import { GITHUB_SERVER_URL } from "../src/github/api/config"; +import { createMockAutomationContext } from "./mockContext"; + +// Derive host-specific expectations from GITHUB_SERVER_URL so the suite passes +// on GHES runners (where Actions exports that variable) as well as github.com. +const SERVER = new URL(GITHUB_SERVER_URL); +const NOREPLY_DOMAIN = + SERVER.hostname === "github.com" + ? "users.noreply.github.com" + : `users.noreply.${SERVER.hostname}`; +const EXTRAHEADER_KEY = `http.${GITHUB_SERVER_URL}/.extraheader`; + +// git exports these into hooks (e.g. a pre-commit hook running the test +// suite); if inherited they would point every git command below at the +// enclosing repository instead of the temp repo. +const GIT_ENV_OVERRIDES = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_COMMON_DIR", + "GIT_PREFIX", +] as const; + +// Pass an explicit env copy: unlike bun's `$`, execFileSync does not pick up +// deletions from process.env, so the GIT_* overrides removed in beforeEach +// would otherwise still reach the child process. +function runGit(args: string[], cwd?: string): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: "pipe", + env: { ...process.env }, + }).trim(); +} + +function gitConfigGetAll(key: string): string { + try { + return runGit(["config", "--local", "--get-all", key]); + } catch { + return ""; + } +} + +function remoteUrl(): string { + return runGit(["remote", "get-url", "origin"]); +} + +describe("git-config", () => { + let originalCwd: string; + let tempDir: string; + let repoDir: string; + let originalActionPath: string | undefined; + let originalNonWriteUsers: string | undefined; + let originalGhToken: string | undefined; + let originalGitEnv: Record; + let consoleLogSpy: any; + + beforeEach(() => { + originalCwd = process.cwd(); + originalActionPath = process.env.GITHUB_ACTION_PATH; + originalNonWriteUsers = process.env.ALLOWED_NON_WRITE_USERS; + originalGhToken = process.env.GH_TOKEN; + delete process.env.ALLOWED_NON_WRITE_USERS; + originalGitEnv = {}; + for (const name of GIT_ENV_OVERRIDES) { + originalGitEnv[name] = process.env[name]; + delete process.env[name]; + } + + tempDir = mkdtempSync(join(tmpdir(), "git-config-test-")); + repoDir = join(tempDir, "repo"); + runGit(["init", repoDir]); + process.env.GITHUB_ACTION_PATH = tempDir; + process.chdir(repoDir); + + git(["remote", "add", "origin", `https://${SERVER.host}/test/repo.git`]); + // Mimic the credential actions/checkout persists in the local config + git([ + "config", + "--local", + "--add", + EXTRAHEADER_KEY, + "AUTHORIZATION: basic one", + ]); + git([ + "config", + "--local", + "--add", + EXTRAHEADER_KEY, + "AUTHORIZATION: basic two", + ]); + git(["config", "--local", "user.name", "pre-existing"]); + + consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + consoleLogSpy?.mockRestore(); + restoreEnv("GITHUB_ACTION_PATH", originalActionPath); + restoreEnv("ALLOWED_NON_WRITE_USERS", originalNonWriteUsers); + restoreEnv("GH_TOKEN", originalGhToken); + for (const name of GIT_ENV_OVERRIDES) { + restoreEnv(name, originalGitEnv[name]); + } + }); + + describe("replaceCheckoutCredentials", () => { + test("removes the checkout extraheader and sets a token remote URL", async () => { + expect(gitConfigGetAll(EXTRAHEADER_KEY)).toContain("AUTHORIZATION"); + + await replaceCheckoutCredentials( + "test-token", + createMockAutomationContext(), + ); + + expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe(""); + expect(remoteUrl()).toBe( + `https://x-access-token:test-token@${SERVER.host}/test-owner/test-repo.git`, + ); + // Only the credential is touched — the git identity is left alone + expect(gitConfigGetAll("user.name")).toBe("pre-existing"); + }); + + test("uses a credential helper when non-write users are allowed", async () => { + process.env.ALLOWED_NON_WRITE_USERS = "someone"; + + await replaceCheckoutCredentials( + "helper-token", + createMockAutomationContext(), + ); + + expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe(""); + expect(remoteUrl()).toBe( + `https://${SERVER.host}/test-owner/test-repo.git`, + ); + const helperPath = join(tempDir, ".git-credential-gh-token"); + expect(gitConfigGetAll("credential.helper")).toBe(helperPath); + expect(statSync(helperPath).mode & 0o777).toBe(0o700); + expect(process.env.GH_TOKEN).toBe("helper-token"); + }); + + test("succeeds when there is no checkout extraheader to remove", async () => { + git(["config", "--local", "--unset-all", EXTRAHEADER_KEY]); + + await expect( + replaceCheckoutCredentials("test-token", createMockAutomationContext()), + ).resolves.toBeUndefined(); + + expect(remoteUrl()).toContain("x-access-token:test-token@"); + }); + }); + + describe("configureGitAuth", () => { + test("configures the git user and replaces the checkout credential", async () => { + await configureGitAuth("test-token", createMockAutomationContext(), { + login: "claude[bot]", + id: 42, + }); + + expect(gitConfigGetAll("user.name")).toBe("claude[bot]"); + expect(gitConfigGetAll("user.email")).toBe( + `42+claude[bot]@${NOREPLY_DOMAIN}`, + ); + expect(gitConfigGetAll(EXTRAHEADER_KEY)).toBe(""); + expect(remoteUrl()).toBe( + `https://x-access-token:test-token@${SERVER.host}/test-owner/test-repo.git`, + ); + }); + }); + + function git(args: string[]): void { + runGit(args, repoDir); + } +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/test/github-actions-pagination.test.ts b/test/github-actions-pagination.test.ts new file mode 100644 index 0000000..4ea7764 --- /dev/null +++ b/test/github-actions-pagination.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import type { Octokit } from "@octokit/rest"; +import { + listWorkflowJobs, + listWorkflowRuns, +} from "../src/mcp/github-actions-pagination"; + +function createPaginatedClient(pages: T[][]) { + const request = async () => ({ data: pages[0] }); + const client = { + actions: { + listWorkflowRunsForRepo: request, + listJobsForWorkflowRun: request, + }, + paginate: async () => pages.flat(), + } as unknown as Octokit; + + return client; +} + +describe("GitHub Actions pagination", () => { + test("returns workflow runs from every page", async () => { + const firstPage = [{ id: 1 }, { id: 2 }]; + const secondPage = [{ id: 3 }]; + const client = createPaginatedClient([firstPage, secondPage]); + + const runs = await listWorkflowRuns(client, { + owner: "owner", + repo: "repo", + head_sha: "sha", + }); + + expect(runs.map((run) => run.id)).toEqual([1, 2, 3]); + }); + + test("returns workflow jobs from every page", async () => { + const firstPage = [{ id: 1 }, { id: 2 }]; + const secondPage = [{ id: 3 }]; + const client = createPaginatedClient([firstPage, secondPage]); + + const jobs = await listWorkflowJobs(client, { + owner: "owner", + repo: "repo", + run_id: 123, + }); + + expect(jobs.map((job) => job.id)).toEqual([1, 2, 3]); + }); +}); diff --git a/test/github-actions-server.test.ts b/test/github-actions-server.test.ts new file mode 100644 index 0000000..953db10 --- /dev/null +++ b/test/github-actions-server.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { readFile, rm } from "fs/promises"; +import os from "os"; +import path from "path"; +import { downloadJobLog } from "../src/mcp/github-actions-server"; +import type { Octokit } from "@octokit/rest"; + +describe("downloadJobLog", () => { + const tmpDirs: string[] = []; + + const makeRunnerTemp = () => { + const dir = path.join( + os.tmpdir(), + `download-job-log-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + tmpDirs.push(dir); + return dir; + }; + + afterEach(async () => { + while (tmpDirs.length) { + const dir = tmpDirs.pop()!; + await rm(dir, { recursive: true, force: true }); + } + }); + + const createStallingClient = (): { + client: Octokit; + getSignal: () => AbortSignal | undefined; + } => { + let signal: AbortSignal | undefined; + const client = { + actions: { + downloadJobLogsForWorkflowRun: (params: { + request?: { signal?: AbortSignal }; + }) => { + signal = params.request?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + reject(new Error("This operation was aborted")); + }); + // Otherwise never settles, simulating a stalled fetch. + }); + }, + }, + } as unknown as Octokit; + return { client, getSignal: () => signal }; + }; + + test("rejects with a timeout instead of hanging when the download stalls", async () => { + const { client, getSignal } = createStallingClient(); + const runnerTemp = makeRunnerTemp(); + + await expect( + downloadJobLog( + client, + { owner: "owner", repo: "repo", job_id: 123 }, + runnerTemp, + 5, + ), + ).rejects.toThrow(); + + expect(getSignal()?.aborted).toBe(true); + }); + + test("writes the log to disk and clears the timeout when the download succeeds", async () => { + const runnerTemp = makeRunnerTemp(); + const client = { + actions: { + downloadJobLogsForWorkflowRun: async (params: { + request?: { signal?: AbortSignal }; + }) => { + expect(params.request?.signal?.aborted).toBe(false); + return { data: "log line 1\nlog line 2\n" }; + }, + }, + } as unknown as Octokit; + + const result = await downloadJobLog( + client, + { owner: "owner", repo: "repo", job_id: 456 }, + runnerTemp, + 30_000, + ); + + expect(result.path).toBe(`${runnerTemp}/github-ci-logs/job-456.log`); + expect(result.size_bytes).toBe( + Buffer.byteLength("log line 1\nlog line 2\n", "utf-8"), + ); + + const written = await readFile(result.path, "utf-8"); + expect(written).toBe("log line 1\nlog line 2\n"); + }); +}); diff --git a/test/github-context.test.ts b/test/github-context.test.ts index 40870f8..caf12d9 100644 --- a/test/github-context.test.ts +++ b/test/github-context.test.ts @@ -33,6 +33,7 @@ import { isIssuesAssignedEvent, isEntityContext, isAutomationContext, + isWorkflowRunEvent, } from "../src/github/context"; import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants"; import { createMockContext, createMockAutomationContext } from "./mockContext"; @@ -517,4 +518,14 @@ describe("type guards", () => { ).toBe(true); expect(isAutomationContext(issuesContext)).toBe(false); }); + + test("isWorkflowRunEvent accepts only workflow_run", () => { + expect( + isWorkflowRunEvent( + createMockAutomationContext({ eventName: "workflow_run" }), + ), + ).toBe(true); + expect(isWorkflowRunEvent(workflowDispatchContext)).toBe(false); + expect(isWorkflowRunEvent(issuesContext)).toBe(false); + }); }); diff --git a/test/github-graphql-url.test.ts b/test/github-graphql-url.test.ts new file mode 100644 index 0000000..37e2dfb --- /dev/null +++ b/test/github-graphql-url.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +// The GitHub client reads GITHUB_API_URL / GITHUB_GRAPHQL_URL when +// `src/github/api/config.ts` is first imported, so we cannot flip env vars +// between cases inside a single process. Instead each case runs the real +// `createOctokit` factory in a fresh Bun process (test/fixtures/graphql-endpoint-probe.ts) +// with a stubbed fetch that reports the FINAL wire URL and Authorization header. +// +// This is the level that matters: @octokit/graphql derives the GraphQL endpoint +// from its baseUrl AFTER construction (rewriting a REST ".../api/v3" base to +// ".../api/graphql", and otherwise appending "/graphql"), so a constructor-option +// assertion would not catch a regression. + +const PROBE = join(import.meta.dir, "fixtures", "graphql-endpoint-probe.ts"); + +type ProbeResult = { + graphqlUrl: string; + graphqlAuth: string | null; + restUrl: string; + restAuth: string | null; +}; + +function probe(env: Record): ProbeResult { + const result = Bun.spawnSync({ + cmd: ["bun", "run", PROBE], + env: { + ...process.env, + // Start from a clean slate so the host's own env cannot leak in. + GITHUB_API_URL: "", + GITHUB_GRAPHQL_URL: "", + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }); + + if (result.exitCode !== 0) { + throw new Error( + `probe failed (exit ${result.exitCode}): ${result.stderr.toString()}`, + ); + } + + return JSON.parse(result.stdout.toString().trim()) as ProbeResult; +} + +describe("GitHub API client endpoint routing", () => { + test("both env vars unset: REST and GraphQL use github.com", () => { + const r = probe({}); + expect(r.restUrl).toBe("https://api.github.com/meta"); + expect(r.graphqlUrl).toBe("https://api.github.com/graphql"); + }); + + test("GITHUB_API_URL alone (GHES): GraphQL still resolves to /api/graphql", () => { + // Regression guard: @octokit/graphql rewrites a ".../api/v3" REST base to + // ".../api/graphql", so GraphQL must keep working when only GITHUB_API_URL + // is provided (e.g. under `act` or partial configs). + const r = probe({ GITHUB_API_URL: "https://ghe.example.test/api/v3" }); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL alone: GraphQL honors it exactly, REST stays public", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + expect(r.restUrl).toBe("https://api.github.com/meta"); + }); + + test("both set to standard GHES values: REST and GraphQL route independently", () => { + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL wins over a GITHUB_API_URL-derived endpoint", () => { + // Distinguishing case: without honoring GITHUB_GRAPHQL_URL, GraphQL would be + // derived from GITHUB_API_URL and hit the wrong host. + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql", + }); + expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql"); + expect(r.restUrl).toBe("https://ghe.example.test/api/v3/meta"); + }); + + test("trailing slash on GITHUB_GRAPHQL_URL is normalized", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql/", + }); + expect(r.graphqlUrl).toBe("https://ghe.example.test/api/graphql"); + }); + + test("GITHUB_GRAPHQL_URL without a /graphql suffix is preserved before the client appends one", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://gql.example.test/custom", + }); + expect(r.graphqlUrl).toBe("https://gql.example.test/custom/graphql"); + }); + + test("a base already ending in /graphql is not doubled", () => { + const r = probe({ + GITHUB_GRAPHQL_URL: "https://gql.example.test/api/graphql", + }); + expect(r.graphqlUrl).not.toContain("/graphql/graphql"); + expect(r.graphqlUrl).toBe("https://gql.example.test/api/graphql"); + }); + + test("the token authorization header is preserved on both clients", () => { + const r = probe({ + GITHUB_API_URL: "https://ghe.example.test/api/v3", + GITHUB_GRAPHQL_URL: "https://ghe.example.test/api/graphql", + }); + expect(r.graphqlAuth).toBe("token test-token"); + expect(r.restAuth).toBe("token test-token"); + }); +}); diff --git a/test/image-downloader.test.ts b/test/image-downloader.test.ts index 50f9c20..12d4dea 100644 --- a/test/image-downloader.test.ts +++ b/test/image-downloader.test.ts @@ -13,6 +13,17 @@ import { downloadCommentImages } from "../src/github/utils/image-downloader"; import type { CommentWithImages } from "../src/github/utils/image-downloader"; import type { Octokits } from "../src/github/api/client"; +// Asset URLs and their signed download URLs share the asset's GUID. +const GUID_1 = "f871c23e-a84d-4f1f-b9a0-86626c63f161"; +const GUID_2 = "0b0c9d33-4e6a-4f4e-8a1a-2f9e5c6d7e8f"; +const GUID_3 = "a1b2c3d4-e5f6-4789-abcd-ef0123456789"; + +const assetUrl = (guid: string, suffix = "") => + `https://github.com/user-attachments/assets/${guid}${suffix}`; + +const signedUrlFor = (guid: string, ext: string, token = "token") => + `https://private-user-images.githubusercontent.com/12345/98765432-${guid}${ext}?jwt=${token}`; + describe("downloadCommentImages", () => { let consoleLogSpy: any; let consoleWarnSpy: any; @@ -97,10 +108,8 @@ describe("downloadCommentImages", () => { test("should detect and download images from issue comments", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/test-image.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/test.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // Mock octokit response // @ts-expect-error Mock implementation doesn't match full type signature @@ -139,7 +148,9 @@ describe("downloadCommentImages", () => { mediaType: { format: "full+json" }, }); - expect(fetchSpy).toHaveBeenCalledWith(signedUrl); + expect(fetchSpy).toHaveBeenCalledWith(signedUrl, { + signal: expect.any(AbortSignal), + }); expect(fsWriteFileSpy).toHaveBeenCalledWith( "/tmp/github-images/image-1704067200000-0.png", Buffer.from(mockArrayBuffer), @@ -164,10 +175,8 @@ describe("downloadCommentImages", () => { // the URL-based guess used to default to ".png" while the bytes are JPEG — // producing a mislabeled file that the Anthropic API rejected with a 400. const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/f871c23e-a84d-4f1f-b9a0-86626c63f161"; - const signedUrl = - "https://private-user-images.githubusercontent.com/screenshot?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".jpg"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({ @@ -209,10 +218,8 @@ describe("downloadCommentImages", () => { test("should handle review comments", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/review-image.jpg"; - const signedUrl = - "https://private-user-images.githubusercontent.com/review.jpg?jwt=token"; + const imageUrl = assetUrl(GUID_1, ".jpg"); + const signedUrl = signedUrlFor(GUID_1, ".jpg"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.getReviewComment = jest.fn().mockResolvedValue({ @@ -255,10 +262,8 @@ describe("downloadCommentImages", () => { test("should handle review bodies", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/review-body.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/body.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.getReview = jest.fn().mockResolvedValue({ @@ -303,10 +308,8 @@ describe("downloadCommentImages", () => { test("should handle issue bodies", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/issue-body.gif"; - const signedUrl = - "https://private-user-images.githubusercontent.com/issue.gif?jwt=token"; + const imageUrl = assetUrl(GUID_1, ".gif"); + const signedUrl = signedUrlFor(GUID_1, ".gif"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.get = jest.fn().mockResolvedValue({ @@ -352,9 +355,8 @@ describe("downloadCommentImages", () => { test("should handle PR bodies", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/pr-body.webp"; - const signedUrl = - "https://private-user-images.githubusercontent.com/pr.webp?jwt=token"; + const imageUrl = assetUrl(GUID_1, ".webp"); + const signedUrl = signedUrlFor(GUID_1, ".webp"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.pulls.get = jest.fn().mockResolvedValue({ @@ -400,12 +402,10 @@ describe("downloadCommentImages", () => { test("should handle multiple images in a single comment", async () => { const mockOctokit = createMockOctokit(); - const imageUrl1 = "https://github.com/user-attachments/assets/image1.png"; - const imageUrl2 = "https://github.com/user-attachments/assets/image2.jpg"; - const signedUrl1 = - "https://private-user-images.githubusercontent.com/1.png?jwt=token1"; - const signedUrl2 = - "https://private-user-images.githubusercontent.com/2.jpg?jwt=token2"; + const imageUrl1 = assetUrl(GUID_1); + const imageUrl2 = assetUrl(GUID_2, ".jpg"); + const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1"); + const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -447,11 +447,221 @@ describe("downloadCommentImages", () => { ); }); + test("should pair images by asset identifier even when the HTML order differs", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl1 = assetUrl(GUID_1); + const imageUrl2 = assetUrl(GUID_2); + const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1"); + const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2"); + + // The rendered HTML lists the second asset first. + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "999", + body: `Two images: ![img1](${imageUrl1}) and ![img2](${imageUrl2})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith(1, signedUrl1, { + signal: expect.any(AbortSignal), + }); + expect(fetchSpy).toHaveBeenNthCalledWith(2, signedUrl2, { + signal: expect.any(AbortSignal), + }); + expect(result.get(imageUrl1)).toBe( + "/tmp/github-images/image-1704067200000-0.png", + ); + expect(result.get(imageUrl2)).toBe( + "/tmp/github-images/image-1704067200000-1.png", + ); + }); + + test("should match asset identifiers case-insensitively", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = assetUrl(GUID_1.toUpperCase()); + const signedUrl = signedUrlFor(GUID_1, ".png"); + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1002", + body: `Uppercase: ![test](${imageUrl})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).toHaveBeenCalledWith(signedUrl, { + signal: expect.any(AbortSignal), + }); + expect(result.get(imageUrl)).toBe( + "/tmp/github-images/image-1704067200000-0.png", + ); + }); + + test("should skip an image whose signed URL refers to a different asset", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = assetUrl(GUID_1); + // The rendered HTML only contains a signed URL for a different asset. + const signedUrl = signedUrlFor(GUID_2, ".png"); + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1003", + body: `Original image: ![test](${imageUrl})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.size).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `No matching signed URL found for ${imageUrl}, skipping`, + ); + }); + + test("should not pair a signed URL that only names the asset in a leading path segment", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = assetUrl(GUID_1); + // The path segment mentions the requested asset, but the URL resolves to a + // different asset's filename once ".." is applied. + const signedUrl = `https://private-user-images.githubusercontent.com/${GUID_1}/../12345/98765432-${GUID_2}.png?jwt=token`; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: `${signedUrl}`, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1005", + body: `Original image: ![test](${imageUrl})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.size).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `No matching signed URL found for ${imageUrl}, skipping`, + ); + }); + + test("should skip an image URL without an asset identifier", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = + "https://github.com/user-attachments/assets/test-image.png"; + const signedUrl = signedUrlFor(GUID_1, ".png"); + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + } as Response); + + const comments: CommentWithImages[] = [ + { + type: "issue_comment", + id: "1004", + body: `No identifier: ![test](${imageUrl})`, + }, + ]; + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + comments, + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result.size).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `No matching signed URL found for ${imageUrl}, skipping`, + ); + }); + test("should skip already downloaded images", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/duplicate.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/dup.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -494,7 +704,7 @@ describe("downloadCommentImages", () => { test("should handle missing HTML body", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/missing.png"; + const imageUrl = assetUrl(GUID_1); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -526,9 +736,8 @@ describe("downloadCommentImages", () => { test("should handle fetch errors", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/error.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/error.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -565,9 +774,98 @@ describe("downloadCommentImages", () => { ); }); + test("should skip an image when the fetch times out", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); + let signal: AbortSignal | null | undefined; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch"); + fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => { + signal = init?.signal; + return new Promise(() => {}); + }); + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + [ + { + type: "issue_comment", + id: "445", + body: `Stalled image: ![stalled](${imageUrl})`, + }, + ], + { timeoutMs: 5 }, + ); + + expect(result.size).toBe(0); + expect(signal?.aborted).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to download"), + expect.objectContaining({ + message: "Image download timed out after 5ms", + }), + ); + }); + + test("should time out while reading a response body", async () => { + const mockOctokit = createMockOctokit(); + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); + let signal: AbortSignal | null | undefined; + + // @ts-expect-error Mock implementation doesn't match full type signature + mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ + data: { + body_html: ``, + }, + }); + + fetchSpy = spyOn(global, "fetch"); + fetchSpy.mockImplementation((_input: unknown, init?: RequestInit) => { + signal = init?.signal; + return Promise.resolve({ + ok: true, + arrayBuffer: () => new Promise(() => {}), + } as Response); + }); + + const result = await downloadCommentImages( + mockOctokit, + "owner", + "repo", + [ + { + type: "issue_comment", + id: "446", + body: `Stalled body: ![stalled](${imageUrl})`, + }, + ], + { timeoutMs: 5 }, + ); + + expect(result.size).toBe(0); + expect(signal?.aborted).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to download"), + expect.objectContaining({ + message: "Image download timed out after 5ms", + }), + ); + }); + test("should handle API errors gracefully", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/api-error.png"; + const imageUrl = assetUrl(GUID_1); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest @@ -599,42 +897,21 @@ describe("downloadCommentImages", () => { test("should extract correct file extensions", async () => { const mockOctokit = createMockOctokit(); const extensions = [ - { - url: "https://github.com/user-attachments/assets/test.png", - ext: ".png", - }, - { - url: "https://github.com/user-attachments/assets/test.jpg", - ext: ".jpg", - }, - { - url: "https://github.com/user-attachments/assets/test.jpeg", - ext: ".jpeg", - }, - { - url: "https://github.com/user-attachments/assets/test.gif", - ext: ".gif", - }, - { - url: "https://github.com/user-attachments/assets/test.webp", - ext: ".webp", - }, - { - url: "https://github.com/user-attachments/assets/test.svg", - ext: ".svg", - }, - { - // default - url: "https://github.com/user-attachments/assets/no-extension", - ext: ".png", - }, + { url: assetUrl(GUID_1, ".png"), ext: ".png" }, + { url: assetUrl(GUID_1, ".jpg"), ext: ".jpg" }, + { url: assetUrl(GUID_1, ".jpeg"), ext: ".jpeg" }, + { url: assetUrl(GUID_1, ".gif"), ext: ".gif" }, + { url: assetUrl(GUID_1, ".webp"), ext: ".webp" }, + { url: assetUrl(GUID_1, ".svg"), ext: ".svg" }, + // default + { url: assetUrl(GUID_1), ext: ".png" }, ]; let callIndex = 0; // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ data: { - body_html: ``, + body_html: ``, }, }); @@ -669,12 +946,11 @@ describe("downloadCommentImages", () => { } }); - test("should handle mismatched signed URL count", async () => { + test("should handle a signed URL missing for one of several images", async () => { const mockOctokit = createMockOctokit(); - const imageUrl1 = "https://github.com/user-attachments/assets/img1.png"; - const imageUrl2 = "https://github.com/user-attachments/assets/img2.png"; - const signedUrl1 = - "https://private-user-images.githubusercontent.com/1.png?jwt=token"; + const imageUrl1 = assetUrl(GUID_1); + const imageUrl2 = assetUrl(GUID_2); + const signedUrl1 = signedUrlFor(GUID_1, ".png"); // Only one signed URL for two images // @ts-expect-error Mock implementation doesn't match full type signature @@ -710,14 +986,15 @@ describe("downloadCommentImages", () => { "/tmp/github-images/image-1704067200000-0.png", ); expect(result.get(imageUrl2)).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `No matching signed URL found for ${imageUrl2}, skipping`, + ); }); test("should detect and download images from HTML img tags", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/html-image.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/html.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // Mock octokit response // @ts-expect-error Mock implementation doesn't match full type signature @@ -756,7 +1033,9 @@ describe("downloadCommentImages", () => { mediaType: { format: "full+json" }, }); - expect(fetchSpy).toHaveBeenCalledWith(signedUrl); + expect(fetchSpy).toHaveBeenCalledWith(signedUrl, { + signal: expect.any(AbortSignal), + }); expect(fsWriteFileSpy).toHaveBeenCalledWith( "/tmp/github-images/image-1704067200000-0.png", Buffer.from(mockArrayBuffer), @@ -777,14 +1056,10 @@ describe("downloadCommentImages", () => { test("should handle HTML img tags with different quote styles", async () => { const mockOctokit = createMockOctokit(); - const imageUrl1 = - "https://github.com/user-attachments/assets/single-quote.jpg"; - const imageUrl2 = - "https://github.com/user-attachments/assets/double-quote.png"; - const signedUrl1 = - "https://private-user-images.githubusercontent.com/single.jpg?jwt=token1"; - const signedUrl2 = - "https://private-user-images.githubusercontent.com/double.png?jwt=token2"; + const imageUrl1 = assetUrl(GUID_1, ".jpg"); + const imageUrl2 = assetUrl(GUID_2, ".png"); + const signedUrl1 = signedUrlFor(GUID_1, ".jpg", "token1"); + const signedUrl2 = signedUrlFor(GUID_2, ".png", "token2"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -828,18 +1103,17 @@ describe("downloadCommentImages", () => { test("should handle mixed Markdown and HTML images", async () => { const mockOctokit = createMockOctokit(); - const markdownUrl = - "https://github.com/user-attachments/assets/markdown.png"; - const htmlUrl = "https://github.com/user-attachments/assets/html.jpg"; - const signedUrl1 = - "https://private-user-images.githubusercontent.com/md.png?jwt=token1"; - const signedUrl2 = - "https://private-user-images.githubusercontent.com/html.jpg?jwt=token2"; + const markdownUrl = assetUrl(GUID_1); + const htmlUrl = assetUrl(GUID_2, ".jpg"); + const signedUrl1 = signedUrlFor(GUID_1, ".png", "token1"); + const signedUrl2 = signedUrlFor(GUID_2, ".jpg", "token2"); + // The rendered HTML has the images in document order (HTML tag first), + // which is the reverse of the order in which the URLs are extracted. // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ data: { - body_html: ``, + body_html: ``, }, }); @@ -852,7 +1126,7 @@ describe("downloadCommentImages", () => { { type: "issue_comment", id: "999", - body: `Markdown: ![test](${markdownUrl}) and HTML: test`, + body: `HTML: test and Markdown: ![test](${markdownUrl})`, }, ]; @@ -878,9 +1152,8 @@ describe("downloadCommentImages", () => { test("should deduplicate identical URLs from Markdown and HTML", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = "https://github.com/user-attachments/assets/duplicate.png"; - const signedUrl = - "https://private-user-images.githubusercontent.com/dup.png?jwt=token"; + const imageUrl = assetUrl(GUID_1); + const signedUrl = signedUrlFor(GUID_1, ".png"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ @@ -921,10 +1194,8 @@ describe("downloadCommentImages", () => { test("should handle HTML img tags with additional attributes", async () => { const mockOctokit = createMockOctokit(); - const imageUrl = - "https://github.com/user-attachments/assets/complex-tag.webp"; - const signedUrl = - "https://private-user-images.githubusercontent.com/complex.webp?jwt=token"; + const imageUrl = assetUrl(GUID_3, ".webp"); + const signedUrl = signedUrlFor(GUID_3, ".webp"); // @ts-expect-error Mock implementation doesn't match full type signature mockOctokit.rest.issues.getComment = jest.fn().mockResolvedValue({ diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 87c7513..94c2b57 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -214,6 +214,46 @@ describe("prepareMcpConfig", () => { ); }); + test("should pin bun config flags before run for every bun server", async () => { + process.env.GITHUB_ACTION_PATH = "/test/action/path"; + process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token"; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github_inline_comment__create_inline_comment"], + mode: "tag", + context: { + ...mockPRContext, + inputs: { ...mockPRContext.inputs, useCommitSigning: true }, + }, + }); + + const parsed = JSON.parse(result); + const servers: Record = { + github_comment: "src/mcp/github-comment-server.ts", + github_file_ops: "src/mcp/github-file-ops-server.ts", + github_inline_comment: "src/mcp/github-inline-comment-server.ts", + github_ci: "src/mcp/github-actions-server.ts", + }; + + for (const [name, script] of Object.entries(servers)) { + expect(parsed.mcpServers[name]).toBeDefined(); + expect(parsed.mcpServers[name].command).toBe("bun"); + expect(parsed.mcpServers[name].args).toEqual([ + "--no-env-file", + "--config=/test/action/path/bunfig.toml", + "run", + `/test/action/path/${script}`, + ]); + } + + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + test("should use current working directory when GITHUB_WORKSPACE is not set", async () => { delete process.env.GITHUB_WORKSPACE; @@ -314,4 +354,102 @@ describe("prepareMcpConfig", () => { const parsed = JSON.parse(result); expect(parsed.mcpServers.github_ci).not.toBeDefined(); }); + + test("should include github MCP server when mcp__github shorthand is used", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github"], + mode: "agent", + context: mockContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github).toBeDefined(); + expect(parsed.mcpServers.github.command).toBe("docker"); + expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe( + "test-token", + ); + }); + + test("should include inline comment server when mcp__github_inline_comment shorthand is used", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github_inline_comment"], + mode: "agent", + context: mockPRContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_inline_comment).toBeDefined(); + expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe( + "test-token", + ); + expect(parsed.mcpServers.github_inline_comment.env.PR_NUMBER).toBe("456"); + }); + + test("should include comment server in agent mode when mcp__github_comment shorthand is used", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github_comment"], + mode: "agent", + context: mockContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_comment).toBeDefined(); + expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe( + "test-token", + ); + }); + + test("should include CI server in agent mode when mcp__github_ci shorthand is used", async () => { + process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token"; + + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["mcp__github_ci"], + mode: "agent", + context: mockPRContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github_ci).toBeDefined(); + expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token"); + expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456"); + + delete process.env.DEFAULT_WORKFLOW_TOKEN; + }); + + test("should not include github MCP server when unrelated tool is specified", async () => { + const result = await prepareMcpConfig({ + githubToken: "test-token", + owner: "test-owner", + repo: "test-repo", + branch: "test-branch", + baseBranch: "main", + allowedTools: ["Bash", "Read", "Grep"], + mode: "agent", + context: mockContext, + }); + + const parsed = JSON.parse(result); + expect(parsed.mcpServers.github).not.toBeDefined(); + expect(parsed.mcpServers.github_inline_comment).not.toBeDefined(); + }); }); diff --git a/test/mockContext.ts b/test/mockContext.ts index 324104e..93b7ac0 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -140,7 +140,7 @@ export const mockIssueOpenedContext: ParsedGitHubContext = { body: "## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this", assignee: null, created_at: "2024-01-15T10:30:00Z", - updated_at: "2024-01-15T10:30:00Z", + updated_at: "2024-01-15T10:35:00Z", html_url: "https://github.com/test-owner/test-repo/issues/42", user: { login: "john-doe", @@ -191,6 +191,8 @@ export const mockIssueAssignedContext: ParsedGitHubContext = { avatar_url: "https://avatars.githubusercontent.com/u/11111", html_url: "https://github.com/claude-bot", }, + created_at: "2024-01-15T09:00:00Z", + updated_at: "2024-01-15T11:00:00Z", }, repository: { name: "test-repo", @@ -225,6 +227,8 @@ export const mockIssueLabeledContext: ParsedGitHubContext = { html_url: "https://github.com/alice-wonder", }, assignee: null, + created_at: "2024-01-15T09:30:00Z", + updated_at: "2024-01-15T11:30:00Z", }, label: { id: 987654321, @@ -355,6 +359,8 @@ export const mockPullRequestOpenedContext: ParsedGitHubContext = { avatar_url: "https://avatars.githubusercontent.com/u/55555", html_url: "https://github.com/feature-developer", }, + created_at: "2024-01-15T14:00:00Z", + updated_at: "2024-01-15T14:05:00Z", }, repository: { name: "test-repo", diff --git a/test/modes/agent.test.ts b/test/modes/agent.test.ts index 1404b0d..8f0892d 100644 --- a/test/modes/agent.test.ts +++ b/test/modes/agent.test.ts @@ -16,28 +16,35 @@ describe("Agent Mode", () => { let exportVariableSpy: any; let setOutputSpy: any; let configureGitAuthSpy: any; + let replaceCheckoutCredentialsSpy: any; beforeEach(() => { exportVariableSpy = spyOn(core, "exportVariable").mockImplementation( () => {}, ); setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); - // Mock configureGitAuth to prevent actual git commands from running + // Mock git configuration to prevent actual git commands from running configureGitAuthSpy = spyOn( gitConfig, "configureGitAuth", ).mockImplementation(async () => { // Do nothing - prevent actual git config modifications }); + replaceCheckoutCredentialsSpy = spyOn( + gitConfig, + "replaceCheckoutCredentials", + ).mockImplementation(async () => {}); }); afterEach(() => { exportVariableSpy?.mockClear(); setOutputSpy?.mockClear(); configureGitAuthSpy?.mockClear(); + replaceCheckoutCredentialsSpy?.mockClear(); exportVariableSpy?.mockRestore(); setOutputSpy?.mockRestore(); configureGitAuthSpy?.mockRestore(); + replaceCheckoutCredentialsSpy?.mockRestore(); }); test("prepareAgentMode is exported as a function", () => { @@ -257,4 +264,59 @@ describe("Agent Mode", () => { // Should be empty or just whitespace when no MCP servers are included expect(result.claudeArgs).not.toContain("--mcp-config"); }); + + describe("git credential configuration", () => { + const mockOctokit = { + rest: { + users: { + getByUsername: mock(() => + Promise.resolve({ + data: { login: "test-user", id: 12345, type: "User" }, + }), + ), + }, + }, + } as any; + + test("uses full git auth on the non-signing path", async () => { + const context = createMockAutomationContext({ + eventName: "workflow_dispatch", + }); + + await prepareAgentMode({ + context, + octokit: mockOctokit, + githubToken: "test-token", + }); + + expect(configureGitAuthSpy).toHaveBeenCalledTimes(1); + expect(configureGitAuthSpy).toHaveBeenCalledWith("test-token", context, { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), + }); + // configureGitAuth performs the credential replacement itself; the mock + // stands in for it here, so the standalone helper is not invoked. + expect(replaceCheckoutCredentialsSpy).not.toHaveBeenCalled(); + }); + + test("still replaces the checkout credential when API commit signing is enabled", async () => { + const context = createMockAutomationContext({ + eventName: "workflow_dispatch", + inputs: { useCommitSigning: true }, + }); + + await prepareAgentMode({ + context, + octokit: mockOctokit, + githubToken: "test-token", + }); + + expect(configureGitAuthSpy).not.toHaveBeenCalled(); + expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledTimes(1); + expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledWith( + "test-token", + context, + ); + }); + }); }); diff --git a/test/modes/detector.test.ts b/test/modes/detector.test.ts index 3b58f9e..6c24974 100644 --- a/test/modes/detector.test.ts +++ b/test/modes/detector.test.ts @@ -76,6 +76,20 @@ describe("detectMode with enhanced routing", () => { expect(detectMode(context)).toBe("agent"); }); + it("should use tag mode when track_progress is true for pull_request.labeled", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "labeled", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + it("should throw error when track_progress is used with unsupported PR action", () => { const context: GitHubContext = { ...baseContext, diff --git a/test/modes/tag.test.ts b/test/modes/tag.test.ts index d68d7fc..beac188 100644 --- a/test/modes/tag.test.ts +++ b/test/modes/tag.test.ts @@ -1,8 +1,99 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { prepareTagMode } from "../../src/modes/tag"; +import { mockIssueCommentContext } from "../mockContext"; +import * as actor from "../../src/github/validation/actor"; +import * as createInitial from "../../src/github/operations/comments/create-initial"; +import * as fetcher from "../../src/github/data/fetcher"; +import * as branch from "../../src/github/operations/branch"; +import * as createPrompt from "../../src/create-prompt"; +import * as mcp from "../../src/mcp/install-mcp-server"; +import * as gitConfig from "../../src/github/operations/git-config"; describe("Tag Mode", () => { test("prepareTagMode is exported as a function", () => { expect(typeof prepareTagMode).toBe("function"); }); + + describe("git credential configuration", () => { + let spies: Array<{ mockRestore: () => void }>; + let configureGitAuthSpy: any; + let replaceCheckoutCredentialsSpy: any; + + beforeEach(() => { + configureGitAuthSpy = spyOn( + gitConfig, + "configureGitAuth", + ).mockImplementation(async () => {}); + replaceCheckoutCredentialsSpy = spyOn( + gitConfig, + "replaceCheckoutCredentials", + ).mockImplementation(async () => {}); + spies = [ + configureGitAuthSpy, + replaceCheckoutCredentialsSpy, + spyOn(actor, "checkHumanActor").mockImplementation(async () => {}), + spyOn(createInitial, "createInitialComment").mockImplementation( + async () => ({ id: 42 }) as any, + ), + spyOn(fetcher, "fetchGitHubData").mockImplementation( + async () => ({}) as any, + ), + spyOn(branch, "setupBranch").mockImplementation( + async () => + ({ + baseBranch: "main", + claudeBranch: "claude/test", + currentBranch: "claude/test", + }) as any, + ), + spyOn(createPrompt, "createPrompt").mockImplementation(async () => {}), + spyOn(mcp, "prepareMcpConfig").mockImplementation(async () => "{}"), + ]; + }); + + afterEach(() => { + for (const spy of spies) { + spy.mockRestore(); + } + }); + + test("uses full git auth on the non-signing path", async () => { + const context = { ...mockIssueCommentContext }; + + await prepareTagMode({ + context, + octokit: {} as any, + githubToken: "test-token", + }); + + expect(configureGitAuthSpy).toHaveBeenCalledTimes(1); + expect(configureGitAuthSpy).toHaveBeenCalledWith("test-token", context, { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), + }); + // configureGitAuth performs the credential replacement itself; the mock + // stands in for it here, so the standalone helper is not invoked. + expect(replaceCheckoutCredentialsSpy).not.toHaveBeenCalled(); + }); + + test("still replaces the checkout credential when API commit signing is enabled", async () => { + const context = { + ...mockIssueCommentContext, + inputs: { ...mockIssueCommentContext.inputs, useCommitSigning: true }, + }; + + await prepareTagMode({ + context, + octokit: {} as any, + githubToken: "test-token", + }); + + expect(configureGitAuthSpy).not.toHaveBeenCalled(); + expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledTimes(1); + expect(replaceCheckoutCredentialsSpy).toHaveBeenCalledWith( + "test-token", + context, + ); + }); + }); }); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index f89a771..95a1ef4 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -3,6 +3,7 @@ import * as core from "@actions/core"; import { checkWritePermissions } from "../src/github/validation/permissions"; import type { ParsedGitHubContext } from "../src/github/context"; import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants"; +import { createMockAutomationContext } from "./mockContext"; describe("checkWritePermissions", () => { let coreInfoSpy: any; @@ -455,4 +456,159 @@ describe("checkWritePermissions", () => { expect(result).toBe(true); }); }); + + describe("workflow_run contexts", () => { + const createWorkflowRunContext = ( + actor: string, + runActor: string = actor, + ) => + createMockAutomationContext({ + eventName: "workflow_run", + eventAction: "completed", + actor, + payload: { + action: "completed", + workflow_run: { + id: 123, + event: "pull_request", + actor: { login: runActor }, + head_repository: { full_name: "fork-owner/test-repo" }, + }, + } as any, + }); + + const createMockOctokitWithLevels = (levels: Record) => + ({ + repos: { + getCollaboratorPermissionLevel: async (params: { + username: string; + }) => ({ + data: { permission: levels[params.username] ?? "none" }, + }), + }, + }) as any; + + test("should return false when the run actor lacks write access", async () => { + const mockOctokit = createMockOctokit("read"); + const context = createWorkflowRunContext("fork-contributor"); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + expect(coreWarningSpy).toHaveBeenCalledWith( + "Actor has insufficient permissions: read", + ); + }); + + test("should return true when the run actor has write access", async () => { + const mockOctokit = createMockOctokit("write"); + const context = createWorkflowRunContext("maintainer"); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should return true when the run actor has admin access", async () => { + const mockOctokit = createMockOctokit("admin"); + const context = createWorkflowRunContext("maintainer"); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should also check the payload run actor when it differs from the workflow actor", async () => { + const mockOctokit = createMockOctokitWithLevels({ + maintainer: "write", + "fork-contributor": "read", + }); + const context = createWorkflowRunContext( + "maintainer", + "fork-contributor", + ); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(false); + expect(coreInfoSpy).toHaveBeenCalledWith( + "workflow_run was started by fork-contributor; checking permissions for that actor as well", + ); + }); + + test("should return true when both the workflow actor and run actor have write access", async () => { + const mockOctokit = createMockOctokitWithLevels({ + maintainer: "write", + "other-maintainer": "admin", + }); + const context = createWorkflowRunContext( + "maintainer", + "other-maintainer", + ); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + + test("should allow a run actor listed in allowed_non_write_users when github_token is provided", async () => { + const mockOctokit = createMockOctokit("read"); + const context = createWorkflowRunContext("fork-contributor"); + + const result = await checkWritePermissions( + mockOctokit, + context, + "fork-contributor,other-user", + true, + ); + + expect(result).toBe(true); + expect(coreWarningSpy).toHaveBeenCalledWith( + "⚠️ SECURITY WARNING: Bypassing write permission check for fork-contributor due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.", + ); + }); + + test("should NOT bypass for a run actor in allowed_non_write_users when github_token is not provided", async () => { + const mockOctokit = createMockOctokit("read"); + const context = createWorkflowRunContext("fork-contributor"); + + const result = await checkWritePermissions( + mockOctokit, + context, + "fork-contributor", + false, + ); + + expect(result).toBe(false); + expect(coreWarningSpy).toHaveBeenCalledWith( + "Actor has insufficient permissions: read", + ); + }); + + test("should require the payload run actor to also be in allowed_non_write_users", async () => { + const mockOctokit = createMockOctokit("read"); + const context = createWorkflowRunContext( + "maintainer", + "fork-contributor", + ); + + const result = await checkWritePermissions( + mockOctokit, + context, + "maintainer", + true, + ); + + expect(result).toBe(false); + }); + + test("should return true for [bot] run actors", async () => { + const mockOctokit = createMockOctokit("none"); + const context = createWorkflowRunContext("dependabot[bot]"); + + const result = await checkWritePermissions(mockOctokit, context); + + expect(result).toBe(true); + }); + }); }); diff --git a/test/public-comment-redaction.test.ts b/test/public-comment-redaction.test.ts new file mode 100644 index 0000000..4ab4708 --- /dev/null +++ b/test/public-comment-redaction.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { redactSecrets, sanitizeContent } from "../src/github/utils/sanitizer"; + +describe("Public Comment Output Sanitization & Redaction", () => { + it("redacts all credential types from public comment output", () => { + const rawComment = [ + "Here is the summary of the work done:", + "- GitHub Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + "- Anthropic Key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + "- AWS Access Key: AKIAIOSFODNN7EXAMPLE", + "- Slack Bot Token: xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + "- JWT Bearer: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + "", + "Invisible\u200Bzero-width chars", + "![Image Alt Injection](https://example.com/pic.png)", + ].join("\n"); + + const sanitizedOutput = redactSecrets(sanitizeContent(rawComment)); + + // Ensure all secret types are redacted + expect(sanitizedOutput).not.toContain( + "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + ); + expect(sanitizedOutput).not.toContain( + "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + ); + expect(sanitizedOutput).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(sanitizedOutput).not.toContain( + "xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + ); + expect(sanitizedOutput).not.toContain( + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", + ); + + expect(sanitizedOutput).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(sanitizedOutput).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(sanitizedOutput).toContain("[REDACTED_AWS_KEY_ID]"); + expect(sanitizedOutput).toContain("[REDACTED_SLACK_TOKEN]"); + expect(sanitizedOutput).toContain("[REDACTED_JWT]"); + + // Ensure prompt injection / invisible chars / hidden tags are also sanitized + expect(sanitizedOutput).not.toContain( + "", + ); + expect(sanitizedOutput).not.toContain("\u200B"); + expect(sanitizedOutput).not.toContain("Image Alt Injection"); + expect(sanitizedOutput).toContain("![](https://example.com/pic.png)"); + }); + + it("ensures public comments have the same secret redaction coverage as logs/errors", () => { + const errorDetails = + "Error: failed to connect with sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE"; + const commentBody = + "Report: encountered sk-ant-abcdefghijklmnopqrstuvwxyz123456 and AKIAIOSFODNN7EXAMPLE"; + + const redactedError = redactSecrets(errorDetails); + const redactedComment = redactSecrets(sanitizeContent(commentBody)); + + expect(redactedError).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redactedError).toContain("[REDACTED_AWS_KEY_ID]"); + expect(redactedComment).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redactedComment).toContain("[REDACTED_AWS_KEY_ID]"); + }); +}); diff --git a/test/restore-config.test.ts b/test/restore-config.test.ts index 43dbf75..093978e 100644 --- a/test/restore-config.test.ts +++ b/test/restore-config.test.ts @@ -5,6 +5,7 @@ import { lstatSync, mkdtempSync, mkdirSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -147,7 +148,7 @@ describe("restoreConfigFromBase", () => { ); }); - test("snapshots symlinked sensitive paths even when the PR head target is missing", () => { + test("records dangling links as placeholders, including top-level ones", () => { setupSymlinkedMainBranch(); git(["checkout", "pr"]); @@ -157,14 +158,229 @@ describe("restoreConfigFromBase", () => { restoreConfigFromBase("main"); - expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isSymbolicLink()).toBe( - true, + expectPlaceholder(".claude-pr/CLAUDE.md"); + expectPlaceholder(".claude-pr/.claude/CLAUDE.md"); + expectNoLinksInSnapshot(); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + + test("snapshots links to tracked in-tree files as dereferenced content", () => { + setupSymlinkedMainBranch(); + + git(["checkout", "pr"]); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile(".claude-pr/CLAUDE.md").isFile()).toBe(true); + expect(lstatRepoFile(".claude-pr/.claude/CLAUDE.md").isFile()).toBe(true); + expect(readRepoFile(".claude-pr/CLAUDE.md")).toBe( + "shared agent instructions\n", + ); + expect(readRepoFile(".claude-pr/.claude/CLAUDE.md")).toBe( + "shared agent instructions\n", + ); + expectNoLinksInSnapshot(); + }); + + test("records CLAUDE.md links to targets outside the working tree as placeholders", () => { + const outsideFile = writeOutsideFile("notes.md", "outside notes\n"); + + rmSync(join(repoDir, "CLAUDE.md"), { force: true }); + symlinkRepoFile("CLAUDE.md", outsideFile); + git(["add", "-A"]); + git(["commit", "-m", "pr links CLAUDE.md outside the repo"]); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/CLAUDE.md"); + expect(readRepoFile(".claude-pr/CLAUDE.md")).not.toBe("outside notes\n"); + expect(snapshotRegularFileContents()).not.toContain("outside notes\n"); + expectNoLinksInSnapshot(); + expect(readRepoFile("CLAUDE.md")).toBe("base claude instructions\n"); + }); + + test("records nested links to targets outside the working tree as placeholders", () => { + const outsideFile = writeOutsideFile( + "secret.txt", + "outside file content\n", + ); + writeOutsideFile("dir/inner.txt", "outside dir content\n"); + const outsideDir = join(tempDir, "outside", "dir"); + + symlinkRepoFile(".claude/linked-file.md", outsideFile); + symlinkRepoFile(".claude/linked-dir", outsideDir); + git(["add", "-A"]); + git(["commit", "-m", "pr adds nested links outside the repo"]); + + restoreConfigFromBase("main"); + + expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe( + `${JSON.stringify({ source: "pr" })}\n`, ); + expectPlaceholder(".claude-pr/.claude/linked-file.md"); + expectPlaceholder(".claude-pr/.claude/linked-dir"); + const contents = snapshotRegularFileContents(); + expect(contents).not.toContain("outside file content\n"); + expect(contents).not.toContain("outside dir content\n"); + expectNoLinksInSnapshot(); expect(readRepoFile(".claude/settings.json")).toBe( `${JSON.stringify({ source: "base" })}\n`, ); }); + test("records links into git metadata as placeholders", () => { + symlinkRepoFile(".claude/git-config", "../.git/config"); + git(["add", "-A"]); + git(["commit", "-m", "pr links into git metadata"]); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/git-config"); + expect(snapshotRegularFileContents()).not.toContain( + readRepoFile(".git/config"), + ); + expectNoLinksInSnapshot(); + }); + + test("records relative links that only resolve from inside the snapshot as placeholders", () => { + // Both targets dangle at their source location but would resolve to the + // repository's .git/config if re-created one directory deeper. + symlinkRepoFile(".claude/x", "../../.git/config"); + rmSync(join(repoDir, "CLAUDE.md"), { force: true }); + symlinkRepoFile("CLAUDE.md", "../.git/config"); + git(["add", "-A"]); + git(["commit", "-m", "pr adds relative links"]); + + restoreConfigFromBase("main"); + + const gitConfig = readRepoFile(".git/config"); + for (const path of [".claude-pr/.claude/x", ".claude-pr/CLAUDE.md"]) { + expectPlaceholder(path); + expect(readRepoFile(path)).not.toBe(gitConfig); + } + expect(snapshotRegularFileContents()).not.toContain(gitConfig); + expectNoLinksInSnapshot(); + }); + + test("records links into nested git metadata inside the working tree as placeholders", () => { + writeRepoFile("other/.git/config", "nested checkout config\n"); + symlinkRepoFile(".claude/x", "../other/.git/config"); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/x"); + expect(snapshotRegularFileContents()).not.toContain( + "nested checkout config\n", + ); + expectNoLinksInSnapshot(); + }); + + test("records links to untracked in-tree files as placeholders", () => { + writeRepoFile(".env", "untracked env contents\n"); + symlinkRepoFile(".claude/env", "../.env"); + git(["add", ".claude/env"]); + git(["commit", "-m", "pr links to an untracked file"]); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/env"); + expect(snapshotRegularFileContents()).not.toContain( + "untracked env contents\n", + ); + expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe( + `${JSON.stringify({ source: "pr" })}\n`, + ); + expectNoLinksInSnapshot(); + }); + + test("records links to tracked files modified after checkout as placeholders", () => { + writeRepoFile(".env", "PLACEHOLDER=1\n"); + symlinkRepoFile(".claude/env", "../.env"); + git(["add", ".env", ".claude/env"]); + git(["commit", "-m", "pr links to a tracked file"]); + writeRepoFile(".env", "written after checkout\n"); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/env"); + expect(snapshotRegularFileContents()).not.toContain( + "written after checkout\n", + ); + expectNoLinksInSnapshot(); + }); + + test("snapshots a sensitive path that links to a tracked in-tree directory", () => { + rmSync(join(repoDir, ".claude"), { recursive: true, force: true }); + writeRepoFile( + "config/claude/settings.json", + `${JSON.stringify({ source: "linked-dir" })}\n`, + ); + writeRepoFile("config/claude/agents/reviewer.md", "reviewer agent\n"); + writeRepoFile("docs/agents/writer.md", "writer agent\n"); + symlinkRepoFile("config/claude/more-agents", "../../docs/agents"); + symlinkRepoFile(".claude", "config/claude"); + git(["add", "-A"]); + git(["commit", "-m", "pr links .claude to a tracked directory"]); + writeRepoFile("config/claude/local.txt", "untracked file\n"); + writeRepoFile("config/claude/cache/entry.txt", "untracked dir entry\n"); + + restoreConfigFromBase("main"); + + expect(lstatRepoFile(".claude-pr/.claude").isDirectory()).toBe(true); + expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe( + `${JSON.stringify({ source: "linked-dir" })}\n`, + ); + expect(readRepoFile(".claude-pr/.claude/agents/reviewer.md")).toBe( + "reviewer agent\n", + ); + expect(readRepoFile(".claude-pr/.claude/more-agents/writer.md")).toBe( + "writer agent\n", + ); + expectPlaceholder(".claude-pr/.claude/local.txt"); + expectPlaceholder(".claude-pr/.claude/cache"); + const contents = snapshotRegularFileContents(); + expect(contents).not.toContain("untracked file\n"); + expect(contents).not.toContain("untracked dir entry\n"); + expectNoLinksInSnapshot(); + expect(lstatRepoFile(".claude").isDirectory()).toBe(true); + expect(readRepoFile(".claude/settings.json")).toBe( + `${JSON.stringify({ source: "base" })}\n`, + ); + }); + + test("records links to untracked in-tree directories as a single placeholder", () => { + writeRepoFile("build/out/a.js", "generated a\n"); + writeRepoFile("build/out/b.js", "generated b\n"); + symlinkRepoFile(".claude/build", "../build"); + git(["add", ".claude/build"]); + git(["commit", "-m", "pr links to an untracked directory"]); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/build"); + const contents = snapshotRegularFileContents(); + expect(contents).not.toContain("generated a\n"); + expect(contents).not.toContain("generated b\n"); + expectNoLinksInSnapshot(); + }); + + test("records links back into a parent directory as placeholders", () => { + symlinkRepoFile(".claude/parent-dir", ".."); + git(["add", "-A"]); + git(["commit", "-m", "pr adds a link back to the repo root"]); + + restoreConfigFromBase("main"); + + expectPlaceholder(".claude-pr/.claude/parent-dir"); + expect(existsRepoFile(".claude-pr/.claude/parent-dir/src")).toBe(false); + expect(readRepoFile(".claude-pr/.claude/settings.json")).toBe( + `${JSON.stringify({ source: "pr" })}\n`, + ); + expectNoLinksInSnapshot(); + }); + test("does not modify an existing .gitignore", () => { writeRepoFile(".gitignore", "node_modules\n"); git(["add", ".gitignore"]); @@ -178,6 +394,37 @@ describe("restoreConfigFromBase", () => { expect(countClaudePrExcludeEntries()).toBe(1); }); + test("leaves a full checkout unshallow so base..HEAD stays scoped to the PR", () => { + // The damage only shows up once base has moved on since the PR branched: + // the merge base is then an older commit that a depth-limited fetch of base + // truncates away, and every base..HEAD comparison silently changes meaning. + git(["checkout", "main"]); + writeRepoFile("src/other.ts", "export const advanced = true;\n"); + git(["add", "src/other.ts"]); + git(["commit", "-m", "base advance"]); + git(["push", "origin", "main"]); + git(["checkout", "pr"]); + + expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false"); + const mergeBaseBefore = git(["merge-base", "origin/main", "HEAD"]).trim(); + + restoreConfigFromBase("main"); + + expect(git(["rev-parse", "--is-shallow-repository"]).trim()).toBe("false"); + expect(git(["merge-base", "origin/main", "HEAD"]).trim()).toBe( + mergeBaseBefore, + ); + // These are the two commands the prompt tells Claude to run to scope its + // work to the PR: the log range must not pick up already-merged commits, + // and the three-dot diff must still resolve a merge base at all. + expect(git(["log", "--format=%s", "origin/main..HEAD"]).trim()).toBe( + "pr config", + ); + expect( + git(["diff", "--name-only", "origin/main...HEAD"]).trim().split("\n"), + ).toEqual([".claude/settings.json", "CLAUDE.md"]); + }); + function git(args: string[]): string { return execFileSync("git", args, { cwd: repoDir, @@ -196,6 +443,55 @@ describe("restoreConfigFromBase", () => { return readFileSync(join(repoDir, path), "utf8"); } + function writeOutsideFile(path: string, contents: string): string { + const fullPath = join(tempDir, "outside", path); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); + return fullPath; + } + + // Contents of every regular file recorded in the snapshot, without following + // links, so tests can assert what actually got copied into the repository. + function snapshotRegularFileContents(): string[] { + const contents: string[] = []; + const visit = (dir: string) => { + for (const entry of readdirSync(dir)) { + const entryPath = join(dir, entry); + const stats = lstatSync(entryPath); + if (stats.isDirectory()) { + visit(entryPath); + } else if (stats.isFile()) { + contents.push(readFileSync(entryPath, "utf8")); + } + } + }; + visit(join(repoDir, ".claude-pr")); + return contents; + } + + // The snapshot must never contain links: every entry is a regular file or a + // real directory. + function expectNoLinksInSnapshot(): void { + const visit = (dir: string) => { + for (const entry of readdirSync(dir)) { + const entryPath = join(dir, entry); + const stats = lstatSync(entryPath); + expect(stats.isSymbolicLink()).toBe(false); + if (stats.isDirectory()) { + visit(entryPath); + } + } + }; + visit(join(repoDir, ".claude-pr")); + } + + function expectPlaceholder(path: string): void { + const stats = lstatRepoFile(path); + expect(stats.isSymbolicLink()).toBe(false); + expect(stats.isFile()).toBe(true); + expect(readRepoFile(path)).toStartWith("Snapshot placeholder: "); + } + function existsRepoFile(path: string): boolean { return existsSync(join(repoDir, path)); } diff --git a/test/sanitizer.test.ts b/test/sanitizer.test.ts index 2cb7e30..7b935e5 100644 --- a/test/sanitizer.test.ts +++ b/test/sanitizer.test.ts @@ -8,6 +8,7 @@ import { sanitizeContent, stripHtmlComments, redactGitHubTokens, + redactSecrets, } from "../src/github/utils/sanitizer"; describe("stripInvisibleCharacters", () => { @@ -368,7 +369,122 @@ export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN] }); }); +describe("redactSecrets", () => { + it("should still redact GitHub tokens", () => { + expect( + redactSecrets("Token: ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"), + ).toBe("Token: [REDACTED_GITHUB_TOKEN]"); + }); + + it("should redact Anthropic API keys (sk-ant-)", () => { + const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + expect(redactSecrets(`ANTHROPIC_API_KEY=${key}`)).toBe( + "ANTHROPIC_API_KEY=[REDACTED_ANTHROPIC_KEY]", + ); + }); + + it("should not redact sk- strings that are not sk-ant-", () => { + const content = + "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 and sk-ant-short"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact AWS access key ids", () => { + expect(redactSecrets("aws_access_key_id = AKIAIOSFODNN7EXAMPLE")).toBe( + "aws_access_key_id = [REDACTED_AWS_KEY_ID]", + ); + expect(redactSecrets("temp creds ASIAIOSFODNN7EXAMPLE end")).toBe( + "temp creds [REDACTED_AWS_KEY_ID] end", + ); + }); + + it("should not redact AWS-like strings that do not fit the format", () => { + const content = + "AKIAtest AKIA123 AKIAIOSFODNN7EXAMPLEXYZ akiaiosfodnn7example"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact Slack tokens", () => { + expect(redactSecrets("token=xoxb-1234567890-abcdefghijkl")).toBe( + "token=[REDACTED_SLACK_TOKEN]", + ); + expect(redactSecrets("xoxp-1234567890-1234567890-abc")).toBe( + "[REDACTED_SLACK_TOKEN]", + ); + }); + + it("should not redact xox strings that do not fit the format", () => { + const content = "xoxo-1234567890abc xoxz-1234567890abc xoxb-short"; + expect(redactSecrets(content)).toBe(content); + }); + + it("should redact JWT-shaped strings", () => { + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + expect(redactSecrets(`Authorization: Bearer ${jwt}`)).toBe( + "Authorization: Bearer [REDACTED_JWT]", + ); + }); + + it("should redact tokens that follow a JSON escape sequence", () => { + const serialized = JSON.stringify({ + content: + "line one\nGITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\tsk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWx", + }); + const redacted = redactSecrets(serialized); + expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(redacted).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(redacted).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW"); + }); + + it("should redact tokens preceded by ANSI color codes", () => { + const ghp = "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"; + const anthropic = + "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh"; + expect(redactSecrets(`\x1b[31m${ghp}\x1b[0m`)).toBe( + "\x1b[31m[REDACTED_GITHUB_TOKEN]\x1b[0m", + ); + expect(redactSecrets(`key=\x1b[32m${anthropic}\x1b[39m`)).toBe( + "key=\x1b[32m[REDACTED_ANTHROPIC_KEY]\x1b[39m", + ); + expect(redactSecrets(`\x1b[1mAKIAIOSFODNN7EXAMPLE\x1b[0m`)).toBe( + "\x1b[1m[REDACTED_AWS_KEY_ID]\x1b[0m", + ); + }); + + it("should redact tokens that follow other JSON escapes", () => { + const ghp = "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"; + const serialized = JSON.stringify({ + colored: `\x1b[31m${ghp}`, + formfeed: `\f${ghp}`, + quoted: `"AKIAIOSFODNN7EXAMPLE"`, + }); + const redacted = redactSecrets(serialized); + expect(redacted).not.toContain(ghp); + expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(redacted).toContain("[REDACTED_GITHUB_TOKEN]"); + expect(redacted).toContain("[REDACTED_AWS_KEY_ID]"); + }); + + it("should not redact base64 blobs that are not JWTs", () => { + // Long base64 without dots, and two-segment strings, are left alone + const content = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9eyJzdWIiOiIxMjM0NTY3ODkw " + + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0 " + + "aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSBqd3Q="; + expect(redactSecrets(content)).toBe(content); + }); +}); + describe("sanitizeContent with token redaction", () => { + it("should only redact GitHub tokens from inbound content", () => { + const content = + "docs example key AKIAIOSFODNN7EXAMPLE and token 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"; + expect(sanitizeContent(content)).toBe( + "docs example key AKIAIOSFODNN7EXAMPLE and token [REDACTED_GITHUB_TOKEN]", + ); + }); + it("should redact tokens as part of full sanitization", () => { const content = ` @@ -402,3 +518,28 @@ describe("stripHtmlComments (legacy)", () => { ); }); }); + +describe("outbound comment sanitization and redaction", () => { + it("should sanitize content and redact all credential types for public comments", () => { + const rawComment = + "Done! Configured AWS AKIAIOSFODNN7EXAMPLE, Anthropic sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890, Slack xoxb-1234567890-abcdefghijkl-mnopqrstuvwx, and GitHub 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 "; + const sanitizedAndRedacted = redactSecrets(sanitizeContent(rawComment)); + + expect(sanitizedAndRedacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(sanitizedAndRedacted).not.toContain( + "sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890", + ); + expect(sanitizedAndRedacted).not.toContain( + "xoxb-1234567890-abcdefghijkl-mnopqrstuvwx", + ); + expect(sanitizedAndRedacted).not.toContain( + "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", + ); + expect(sanitizedAndRedacted).not.toContain("secret note"); + + expect(sanitizedAndRedacted).toContain("[REDACTED_AWS_KEY_ID]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_ANTHROPIC_KEY]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_SLACK_TOKEN]"); + expect(sanitizedAndRedacted).toContain("[REDACTED_GITHUB_TOKEN]"); + }); +}); diff --git a/test/setup-branch-validation.test.ts b/test/setup-branch-validation.test.ts new file mode 100644 index 0000000..e3d8248 --- /dev/null +++ b/test/setup-branch-validation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { setupBranch } from "../src/github/operations/branch"; +import { createMockContext } from "./mockContext"; + +const octokits = { + rest: { + repos: { get: async () => ({ data: { default_branch: "main" } }) }, + git: { getRef: async () => ({ data: { object: { sha: "abc1234" } } }) }, + }, +} as any; + +const githubData = { + contextData: { title: "Add feature", labels: { nodes: [] } }, +} as any; + +// ':' is rejected by validateBranchName. The signing path used to skip that +// check and only fail on the file ops server's first commit (a 422). +const INVALID_TEMPLATE = "{{prefix}}release:{{entityNumber}}"; + +const loggedErrors: string[] = []; + +describe("setupBranch generated branch name validation", () => { + let originalCwd: string; + let tempDir: string; + let exitCode: number | undefined; + let originalExit: typeof process.exit; + let originalError: typeof console.error; + + beforeEach(() => { + originalCwd = process.cwd(); + // Not a git repo, so the remote existence probe fails and setupBranch + // continues with the generated name. + tempDir = mkdtempSync(join("/tmp", "setup-branch-")); + process.chdir(tempDir); + + exitCode = undefined; + loggedErrors.length = 0; + originalExit = process.exit; + originalError = console.error; + console.error = (...args: unknown[]) => { + loggedErrors.push(args.map(String).join(" ")); + }; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error("process.exit called"); + }) as typeof process.exit; + }); + + afterEach(() => { + process.exit = originalExit; + console.error = originalError; + process.chdir(originalCwd); + rmSync(tempDir, { recursive: true, force: true }); + }); + + for (const useCommitSigning of [true, false]) { + test(`rejects an invalid generated branch name with use_commit_signing: ${useCommitSigning}`, async () => { + const context = createMockContext({ + isPR: false, + entityNumber: 42, + inputs: { + useCommitSigning, + branchPrefix: "claude/", + branchNameTemplate: INVALID_TEMPLATE, + }, + }); + + await expect(setupBranch(octokits, githubData, context)).rejects.toThrow( + "process.exit called", + ); + expect(exitCode).toBe(1); + // Must fail on the name itself, not on a later git or API call. + expect(loggedErrors.join("\n")).toContain( + 'Invalid branch name: "claude/release:42"', + ); + }); + } +}); diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index 2751643..611b6fc 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -134,6 +134,20 @@ describe("checkContainsTrigger", () => { expect(checkContainsTrigger(context)).toBe(false); }); + it("should return true when the labeled name differs only in case from the trigger", () => { + const context = { + ...mockIssueLabeledContext, + payload: { + ...mockIssueLabeledContext.payload, + label: { + ...(mockIssueLabeledContext.payload as any).label, + name: "Claude-Task", + }, + }, + } as ParsedGitHubContext; + expect(checkContainsTrigger(context)).toBe(true); + }); + it("should return false for non-labeled events", () => { const context = { ...mockIssueLabeledContext, diff --git a/test/update-git-reference.test.ts b/test/update-git-reference.test.ts new file mode 100644 index 0000000..4f57cba --- /dev/null +++ b/test/update-git-reference.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "bun:test"; +import { GITHUB_API_URL } from "../src/github/api/config"; +import { updateGitReference } from "../src/mcp/update-git-reference"; + +const reference = { + owner: "owner", + repo: "repo", + branch: "feature", + sha: "abc123", + githubToken: "token", +}; + +function response(status: number) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => "response body", + }; +} + +describe("updateGitReference", () => { + it("should patch the branch reference", async () => { + await updateGitReference({ + ...reference, + fetchFn: async (url, init) => { + expect(url).toBe( + `${GITHUB_API_URL}/repos/owner/repo/git/refs/heads/feature`, + ); + expect(init.method).toBe("PATCH"); + expect(init.headers).toMatchObject({ Authorization: "Bearer token" }); + expect(JSON.parse(String(init.body))).toEqual({ + sha: "abc123", + force: false, + }); + return response(200); + }, + }); + }); + + it("should not retry deterministic client errors", async () => { + for (const status of [400, 404, 409, 422]) { + let attempts = 0; + + await expect( + updateGitReference({ + ...reference, + fetchFn: async () => { + attempts++; + return response(status); + }, + retryOptions: { initialDelayMs: 1 }, + }), + ).rejects.toThrow(`Failed to update reference: ${status}`); + + expect(attempts).toBe(1); + } + }); + + it("should retry transient HTTP errors", async () => { + for (const status of [403, 429, 500]) { + let attempts = 0; + + await updateGitReference({ + ...reference, + fetchFn: async () => response(attempts++ === 0 ? status : 200), + retryOptions: { initialDelayMs: 1 }, + }); + + expect(attempts).toBe(2); + } + }); + + it("should retry network errors", async () => { + let attempts = 0; + + await updateGitReference({ + ...reference, + fetchFn: async () => { + attempts++; + if (attempts === 1) { + throw new Error("network error"); + } + return response(200); + }, + retryOptions: { initialDelayMs: 1 }, + }); + + expect(attempts).toBe(2); + }); +}); diff --git a/test/validate-branch-name.test.ts b/test/validate-branch-name.test.ts index 6ee26a0..32eca05 100644 --- a/test/validate-branch-name.test.ts +++ b/test/validate-branch-name.test.ts @@ -29,6 +29,15 @@ describe("validateBranchName", () => { expect(() => validateBranchName("release.1.2.3")).not.toThrow(); }); + it("should accept branch names containing parentheses", () => { + expect(() => + validateBranchName("feat(example)-valid-branch"), + ).not.toThrow(); + expect(() => + validateBranchName("fix(parser)-handle-empty-input"), + ).not.toThrow(); + }); + it("should accept typical branch name formats", () => { expect(() => validateBranchName("claude/issue-123-20250101-1234"), From a157433de91a32158fbd69d3141d55a954e63b0e Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 17:29:16 +0530 Subject: [PATCH 2/7] ci: ci issues fixed --- .github/egress-firewall.yaml | 4 ++-- .github/workflows/claude-review-local.yml | 4 ++-- test/comments-common.test.ts | 6 +++++- tsconfig.json | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/egress-firewall.yaml b/.github/egress-firewall.yaml index c2fc588..821cca6 100644 --- a/.github/egress-firewall.yaml +++ b/.github/egress-firewall.yaml @@ -7,8 +7,8 @@ allow: # short-lived API token, and fetching the Claude GitHub App's token in claude.yml and # claude-review.yml - api.anthropic.com - # GitHub API: calls made by the Claude Code action, by gh in scripts/gh.sh and - # scripts/edit-issue-labels.sh, and by the /review-pr command + # GitHub: checkout (git clone) and GitHub API calls made by the Claude Code action + - github.com - api.github.com # Claude Code install script: fetched by the Claude Code action, and by # test-custom-executables.yml ("Install Claude Code manually") diff --git a/.github/workflows/claude-review-local.yml b/.github/workflows/claude-review-local.yml index 10a187a..076f3fa 100644 --- a/.github/workflows/claude-review-local.yml +++ b/.github/workflows/claude-review-local.yml @@ -8,11 +8,10 @@ permissions: {} jobs: review: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-firewall permissions: contents: read pull-requests: write - id-token: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -32,3 +31,4 @@ jobs: prompt: "/review-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}" claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment" + --permission-mode auto diff --git a/test/comments-common.test.ts b/test/comments-common.test.ts index 6406341..a426e3c 100644 --- a/test/comments-common.test.ts +++ b/test/comments-common.test.ts @@ -10,7 +10,11 @@ import { GITHUB_SERVER_URL } from "../src/github/api/config"; describe("comments/common", () => { describe("createJobRunLink", () => { test("builds a markdown link to the workflow run", () => { - const result = createJobRunLink("step-security", "claude-code-action", "42"); + const result = createJobRunLink( + "step-security", + "claude-code-action", + "42", + ); expect(result).toBe( `[View job run](${GITHUB_SERVER_URL}/step-security/claude-code-action/actions/runs/42)`, ); diff --git a/tsconfig.json b/tsconfig.json index 52796b5..83d74c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", From 3ff45e71556fb2ed9e5f8798b80fa2b24d414f81 Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 17:55:07 +0530 Subject: [PATCH 3/7] fix: added more endpoints to egress-firewall.yml --- .github/egress-firewall.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/egress-firewall.yaml b/.github/egress-firewall.yaml index 821cca6..4aab245 100644 --- a/.github/egress-firewall.yaml +++ b/.github/egress-firewall.yaml @@ -10,6 +10,12 @@ allow: # GitHub: checkout (git clone) and GitHub API calls made by the Claude Code action - github.com - api.github.com + # StepSecurity harden-runner: telemetry/audit correlation API and DNS-over-HTTPS + # resolvers that harden-runner installs. Without these, harden-runner's DoH setup + # blocks all DNS lookups (including github.com) on the firewall runner. + - agent.api.stepsecurity.io + - cloudflare-dns.com + - dns.google # Claude Code install script: fetched by the Claude Code action, and by # test-custom-executables.yml ("Install Claude Code manually") - claude.ai From f3f1e078129bd5d483b8f75fb7e254f72683b499 Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 17:59:48 +0530 Subject: [PATCH 4/7] ci: fix ci by removing egress control as we already have harden runner --- .github/egress-firewall.yaml | 35 ----------- .github/scripts/check_workflow_hardening.py | 61 +------------------ .github/workflows/claude-review-local.yml | 2 +- .github/workflows/test-base-action.yml | 4 +- .github/workflows/test-custom-executables.yml | 2 +- .github/workflows/test-mcp-servers.yml | 4 +- .github/workflows/test-settings.yml | 8 +-- .github/workflows/test-structured-output.yml | 10 +-- CLAUDE.md | 9 ++- 9 files changed, 20 insertions(+), 115 deletions(-) delete mode 100644 .github/egress-firewall.yaml diff --git a/.github/egress-firewall.yaml b/.github/egress-firewall.yaml deleted file mode 100644 index 4aab245..0000000 --- a/.github/egress-firewall.yaml +++ /dev/null @@ -1,35 +0,0 @@ -# Hosts that jobs on GitHub's egress-firewall runner (runs-on: ubuntu-24.04-firewall) -# may reach. All other hosts are blocked, apart from any that GitHub's firewall allows -# by default. Add a host only when a workflow step needs it, name it in full (no '*'), and say what uses it. -mode: enforce -allow: - # Claude API: model requests, trading the workflow's GitHub identity token for a - # short-lived API token, and fetching the Claude GitHub App's token in claude.yml and - # claude-review.yml - - api.anthropic.com - # GitHub: checkout (git clone) and GitHub API calls made by the Claude Code action - - github.com - - api.github.com - # StepSecurity harden-runner: telemetry/audit correlation API and DNS-over-HTTPS - # resolvers that harden-runner installs. Without these, harden-runner's DoH setup - # blocks all DNS lookups (including github.com) on the firewall runner. - - agent.api.stepsecurity.io - - cloudflare-dns.com - - dns.google - # Claude Code install script: fetched by the Claude Code action, and by - # test-custom-executables.yml ("Install Claude Code manually") - - claude.ai - # Claude Code binary, downloaded by the install script - - downloads.claude.ai - # Bun and Node.js release downloads (oven-sh/setup-bun and actions/setup-node) - - release-assets.githubusercontent.com - # npm packages (bun install in action.yml, base-action/action.yml and - # test-mcp-servers.yml) - - registry.npmjs.org - # Bun install script (test-custom-executables.yml, "Install Bun manually") - - bun.sh - # apt packages bubblewrap and socat, which the Claude Code action installs when a - # workflow admits users without write access (issue-triage.yml) - - azure.archive.ubuntu.com - - archive.ubuntu.com - - security.ubuntu.com diff --git a/.github/scripts/check_workflow_hardening.py b/.github/scripts/check_workflow_hardening.py index 4ab0fd0..cc9048b 100755 --- a/.github/scripts/check_workflow_hardening.py +++ b/.github/scripts/check_workflow_hardening.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail if a workflow job that calls Claude, or .github/egress-firewall.yaml, breaks a rule in CLAUDE.md, +"""Fail if a workflow job that calls Claude breaks a rule in CLAUDE.md, "Security hardening for GitHub Actions". Run from the repository root. A job calls Claude when it runs the Claude Code action, or when it or a local action it uses mentions ANTHROPIC_FEDERATION_RULE_ID. """ @@ -11,9 +11,7 @@ import subprocess import sys -FIREWALL_RUNNER = "ubuntu-24.04-firewall" WORKFLOW_DIR = pathlib.Path(".github/workflows") -POLICY_PATH = pathlib.Path(".github/egress-firewall.yaml") SIGN_IN_MARKER = "anthropic_federation_rule_id" CLAUDE_ACTIONS = ("step-security/claude-code-action", "step-security/claude-code-base-action") HELP = 'See CLAUDE.md, "Security hardening for GitHub Actions".' @@ -27,7 +25,6 @@ ) # Key: ":". Value: why that job is exempt from the table's rule. -EXEMPT_FROM_FIREWALL_RUNNER: dict[str, str] = {} EXEMPT_FROM_AUTO_MODE: dict[str, str] = {} @@ -232,25 +229,6 @@ def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> lis key = f"{file_name}:{job_id}" where = f".github/workflows/{file_name}: job '{job_id}'" errors = [] - runs_on = job.get("runs-on") - if isinstance(runs_on, list) and len(runs_on) == 1: - runs_on = runs_on[0] - if key in EXEMPT_FROM_FIREWALL_RUNNER: - print( - f"The egress-firewall runner is not required for job '{job_id}' in {file_name}. " - f"Reason: {EXEMPT_FROM_FIREWALL_RUNNER[key]}." - ) - elif runs_on != FIREWALL_RUNNER: - if "runs-on" not in job: - has = "no 'runs-on'" - elif isinstance(job["runs-on"], str): - has = f"'runs-on: {job['runs-on']}'" - else: - has = "a 'runs-on' list or group" - errors.append( - f"{where} calls Claude, so it must have 'runs-on: {FIREWALL_RUNNER}'. " - f"It has {has}. {HELP}" - ) exempt = key in EXEMPT_FROM_AUTO_MODE if exempt: print( @@ -274,41 +252,6 @@ def check_job(file_name: str, job_id: str, job: dict, workflow_env: dict) -> lis return errors -def check_policy() -> list[str]: - if not POLICY_PATH.is_file(): - return [ - f"{POLICY_PATH} is missing. Jobs on the egress-firewall runner need it " - f"to limit outbound network access. {HELP}" - ] - policy = load_yaml(POLICY_PATH) - if not isinstance(policy, dict): - return [ - f"{POLICY_PATH} is empty or is not a set of 'name: value' lines. It needs 'mode: enforce' " - f"and an 'allow:' list of hosts. {HELP}" - ] - errors = [] - if "mode" not in policy: - errors.append(f"{POLICY_PATH}: 'mode' is missing. Add 'mode: enforce'. {HELP}") - elif policy["mode"] != "enforce": - errors.append( - f"{POLICY_PATH}: 'mode' is '{policy['mode']}'. It must be 'enforce'. {HELP}" - ) - allow = policy.get("allow") - if not isinstance(allow, list) or not allow: - errors.append( - f"{POLICY_PATH}: the 'allow' list is missing or empty. List under 'allow:' " - f"each host the jobs need. {HELP}" - ) - else: - for host in allow: - if "*" in str(host): - errors.append( - f"{POLICY_PATH}: the 'allow' entry '{host}' contains '*'. " - f"Name each host in full. {HELP}" - ) - return errors - - def main() -> int: if not WORKFLOW_DIR.is_dir(): stop(f"{WORKFLOW_DIR} not found. Run this check from the repository root.") @@ -322,8 +265,6 @@ def main() -> int: continue checked += 1 errors.extend(check_job(path.name, job_id, job, workflow.get("env") or {})) - if checked: - errors.extend(check_policy()) for error in errors: print(f"::error::{error}") if errors: diff --git a/.github/workflows/claude-review-local.yml b/.github/workflows/claude-review-local.yml index 076f3fa..4238244 100644 --- a/.github/workflows/claude-review-local.yml +++ b/.github/workflows/claude-review-local.yml @@ -8,7 +8,7 @@ permissions: {} jobs: review: - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest permissions: contents: read pull-requests: write diff --git a/.github/workflows/test-base-action.yml b/.github/workflows/test-base-action.yml index e0d5f3e..7310ee5 100644 --- a/.github/workflows/test-base-action.yml +++ b/.github/workflows/test-base-action.yml @@ -22,7 +22,7 @@ jobs: test-inline-prompt: # Skip on fork PRs since they can't access secrets for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -78,7 +78,7 @@ jobs: test-prompt-file: # Skip on fork PRs since they can't access secrets for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/.github/workflows/test-custom-executables.yml b/.github/workflows/test-custom-executables.yml index 87dde40..1343896 100644 --- a/.github/workflows/test-custom-executables.yml +++ b/.github/workflows/test-custom-executables.yml @@ -17,7 +17,7 @@ jobs: test-custom-executables: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/.github/workflows/test-mcp-servers.yml b/.github/workflows/test-mcp-servers.yml index fa034da..57f8c18 100644 --- a/.github/workflows/test-mcp-servers.yml +++ b/.github/workflows/test-mcp-servers.yml @@ -17,7 +17,7 @@ jobs: test-mcp-integration: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -102,7 +102,7 @@ jobs: test-mcp-config-flag: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/.github/workflows/test-settings.yml b/.github/workflows/test-settings.yml index d7abd32..ee96cce 100644 --- a/.github/workflows/test-settings.yml +++ b/.github/workflows/test-settings.yml @@ -17,7 +17,7 @@ jobs: test-settings-inline-allow: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -74,7 +74,7 @@ jobs: test-settings-inline-deny: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -114,7 +114,7 @@ jobs: test-settings-file-allow: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -176,7 +176,7 @@ jobs: test-settings-file-deny: # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/.github/workflows/test-structured-output.yml b/.github/workflows/test-structured-output.yml index 14d9612..6978984 100644 --- a/.github/workflows/test-structured-output.yml +++ b/.github/workflows/test-structured-output.yml @@ -15,7 +15,7 @@ jobs: name: Test Basic Type Conversions # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -82,7 +82,7 @@ jobs: name: Test Arrays and Objects # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -141,7 +141,7 @@ jobs: name: Test Edge Cases # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -208,7 +208,7 @@ jobs: name: Test Output Name Sanitization # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 @@ -256,7 +256,7 @@ jobs: name: Test Execution File Format # Skip on fork PRs since they can't mint the OIDC token used for Claude API auth if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-24.04-firewall + runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 diff --git a/CLAUDE.md b/CLAUDE.md index 95256ed..a4ec45f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,14 +47,13 @@ Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare ## Security hardening for GitHub Actions -Workflow jobs in this repository that call Claude run with three protections. Keep them when you add or edit a workflow. +Workflow jobs in this repository that call Claude run with two protections. Keep them when you add or edit a workflow. -1. **Egress-firewall runner.** The job has `runs-on: ubuntu-24.04-firewall`, a GitHub-hosted runner that filters the job's outbound network traffic. Do not move a job that calls Claude to another runner. -2. **Network allow list.** `.github/egress-firewall.yaml` lists the hosts those jobs may reach, besides any that GitHub's firewall allows by default. Keep `mode: enforce`, which is what makes the firewall block the rest. Follow that file's header when you add a host. -3. **Auto permission mode.** Every step that runs the Claude Code action (`uses: step-security/claude-code-action`, or this repository's own `./` and `./base-action`) passes `--permission-mode auto` in `claude_args`. A tool call that needs permission and that the allowed tools do not cover then runs only if Claude Code's safety review passes it. Allow only the tools the job needs, and keep any `--disallowedTools` list a step has. Use `claude-opus-4-6` or a newer model: on an older one Claude Code falls back to its default permission mode. +1. **Harden-runner (audit mode).** Every job uses `step-security/harden-runner` with `egress-policy: audit` to log all outbound network calls. This provides visibility without blocking. +2. **Auto permission mode.** Every step that runs the Claude Code action (`uses: step-security/claude-code-action`, or this repository's own `./` and `./base-action`) passes `--permission-mode auto` in `claude_args`. A tool call that needs permission and that the allowed tools do not cover then runs only if Claude Code's safety review passes it. Allow only the tools the job needs, and keep any `--disallowedTools` list a step has. Use `claude-opus-4-6` or a newer model: on an older one Claude Code falls back to its default permission mode. `claude.yml` answers `@claude` mentions. The Claude Code action sets `--permission-mode acceptEdits` for those, and the `--permission-mode auto` in the workflow's `claude_args`, which comes after it, replaces it. -`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 1 or 3, or when the allow list is missing, empty, not `mode: enforce`, or names a host with `*`. It cannot see a job that calls Claude another way, so check new workflows by hand too. If a job cannot meet protection 1 or 3, add it with the reason to the matching exemption table in `.github/scripts/check_workflow_hardening.py`. A job in `EXEMPT_FROM_AUTO_MODE` must set no permission mode at all. Do not skip or weaken the check. +`.github/workflows/workflow-hardening.yml` fails when a job that runs the Claude Code action or mentions `ANTHROPIC_FEDERATION_RULE_ID` breaks protection 2. It cannot see a job that calls Claude another way, so check new workflows by hand too. If a job cannot meet protection 2, add it with the reason to `EXEMPT_FROM_AUTO_MODE` in `.github/scripts/check_workflow_hardening.py`. A job listed there must set no permission mode at all. Do not skip or weaken the check. Keep each workflow's `permissions:` block minimal, and never print tokens or environment variables in workflow logs. From df0c3b0cc57ba7bb090fa439640e5ca998d34ab0 Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 18:12:02 +0530 Subject: [PATCH 5/7] fix: change bun version to a fix version and downgrade packages for cooldown checks --- .github/workflows/ci.yml | 2 +- bun.lock | 20 ++++++++++---------- package.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4197a03..4bacdbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - uses: step-security/setup-bun@f6f5dadeac34f70c7828f731569e8d6e8330b8fb #v2.1.3 with: - bun-version: latest + bun-version: 1.2.12 - name: Install dependencies run: bun install diff --git a/bun.lock b/bun.lock index c5fb913..58b3804 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.286", + "@anthropic-ai/claude-agent-sdk": "0.3.283", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", @@ -38,23 +38,23 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.286", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.286", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.286", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.286" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-InL/UNmRGSwBM/81PME0J0TZDsDBBlweWqRZgq2XSViSIg2hBi8nIL8j9Hm6MHRH85wgDJQE5n6Vo/r9hIO0NQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.283", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.283", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.283" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KB+mqU5JLbH2sztlSQeCOu71bK6padYAha3uacBzxFSOVfuRTywYzvsC9P+qV6gXmPXcu98FaPqQv6vBF9j8hA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.286", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gkxWcJ+Z23UxwghI1V3dL09PkELIZmB2vPelR8XsdfhS+yP1KvoW7FThvRLojcxXb3fj0ddYawjQSQYIkXFbxw=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.283", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UQkROekjufppyB/qrsrU81sM0fcYNWJBEITrGp7NLbOocJZBUR+uVMIx/UHVpe6j81trXPIeRWyfJWgZI17Kxg=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.286", "", { "os": "darwin", "cpu": "x64" }, "sha512-eMdni7sy1ud2IISI4QSsfVBCxotzSe62zCGdXISejL9MxDIwcRgEGZpO5OV+j7t8mNGMTe6Opl1t/3Z/1RUJaQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.283", "", { "os": "darwin", "cpu": "x64" }, "sha512-WkwVppmX0cg1DnXTT9od82pmqM4OK9H3O6MIXoX89SWYty0OpMxsB37263C1tiqk4WkWCs2jwxRXhsoO62ArGQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h+WWGek9beZ6i1qbIjjwYEFbs46D6qumjSBc6cLsEvnufcoi7mc0iBwbFSh9yRb3upxoi4ZTJdl53T3gLTdUQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.283", "", { "os": "linux", "cpu": "arm64" }, "sha512-47IEX/XWw4DUIzPPC85qiASO90rpz7E+2gWr4AKOz5ZAa7ZqEX0PYqPIcpHEM7WpEREfmXCMZgef2bwim24u8A=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.286", "", { "os": "linux", "cpu": "arm64" }, "sha512-fG8Cqx53jkFyEL86ETA0tdLH3p06yFQKHcoxnMpiU+VmJ5g6uGFhsUZbJ6gFvuT3EgyMcNI2kZwhCCDF/DTnpQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.283", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRlnlh5fsRMoTtjDSGxZL6TathGRgSCMpJk2kdy9Wtz8l6qDjgkLuQilzTF12CwFIn+yf1Fqfrk+Njw+0EsL3A=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-kNczbWhWJ1G8sPRZd4Nsx/Ozr/kx16lT2NGp/EEmTV4Hn732xfLkUOlg+tFcS78i6lOPBknjDB/cz/J5Ha4wDQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.283", "", { "os": "linux", "cpu": "x64" }, "sha512-cE5AebMvTlq7t7Oc0FmP5LzMOEtJ3F8XRGxkc3kTGr4aNcQgXplyYNH2yu1teW1yInuMXHzlCeFRdrmxpe2sRg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.286", "", { "os": "linux", "cpu": "x64" }, "sha512-WeO/wG2uPh95BhOQi1IsIECdW5gJgC1Q9xtGw4RjV04it1fBMAbUe7aVwP4DQgH8PHnmduA7dRnLtbnZnrGsjg=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.283", "", { "os": "linux", "cpu": "x64" }, "sha512-T0T8mR7MSe7bVI96tmeK7DgUy2xhG8CZD1i0nCC2C05sSmkDDn/OYgNqpGPxJbo8Ic0Psxd7TOmngQYqPKWtpA=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.286", "", { "os": "win32", "cpu": "arm64" }, "sha512-N4p7Gw5Qg3q9+Y5sEht1cIHHZkmBAUfWXJnqfmDD7N8twqF3XNVK3w65hDLqli4i0ttuZu79bGmRGkgNGm9Imw=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.283", "", { "os": "win32", "cpu": "arm64" }, "sha512-hZjyeIgZpALMvYq2QfyPzl4AZzOVRUoU8NOB82Z3cVXLcEvQJXJN03Hp3XNsuBq5YMUoSUJAffMannhM7UqVmg=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.286", "", { "os": "win32", "cpu": "x64" }, "sha512-pg35GRPBKyviod0i8Z3EVMzDnTiiiucuWUbyH1bVIFFN0UWCQQ+PRUJ15qrPKjL6+vlFxOX2ei9FfsWYjGYZwA=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.283", "", { "os": "win32", "cpu": "x64" }, "sha512-h5eZxFxk6f1LPSuUOoBH9fslhL9ncywdZN5Qw9XmWuijHYTfevXuoza5nm6N98O+hmSykJ8NjC3/muhONDvzBg=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], diff --git a/package.json b/package.json index 8135d51..3eafd07 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.286", + "@anthropic-ai/claude-agent-sdk": "^0.3.283", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", From 5c66f6aa9fddc2b391f47654a1863fbdbca4c937 Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 18:18:08 +0530 Subject: [PATCH 6/7] fix: bun.lock updated --- base-action/bun.lock | 63 ++++++++++++++++++++++------------------ base-action/package.json | 2 +- bun.lock | 2 +- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/base-action/bun.lock b/base-action/bun.lock index 67ce3a6..f004c93 100644 --- a/base-action/bun.lock +++ b/base-action/bun.lock @@ -1,12 +1,13 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@step-security/claude-code-base-action", "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.220", - "axios": "^1.18.0", + "@anthropic-ai/claude-agent-sdk": "0.3.283", + "axios": "^1.16.1", "shell-quote": "^1.8.4", }, "devDependencies": { @@ -27,31 +28,31 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.220", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.283", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.283", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.283", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.283", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.283" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-KB+mqU5JLbH2sztlSQeCOu71bK6padYAha3uacBzxFSOVfuRTywYzvsC9P+qV6gXmPXcu98FaPqQv6vBF9j8hA=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.220", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.283", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UQkROekjufppyB/qrsrU81sM0fcYNWJBEITrGp7NLbOocJZBUR+uVMIx/UHVpe6j81trXPIeRWyfJWgZI17Kxg=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.220", "", { "os": "darwin", "cpu": "x64" }, "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.283", "", { "os": "darwin", "cpu": "x64" }, "sha512-WkwVppmX0cg1DnXTT9od82pmqM4OK9H3O6MIXoX89SWYty0OpMxsB37263C1tiqk4WkWCs2jwxRXhsoO62ArGQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.283", "", { "os": "linux", "cpu": "arm64" }, "sha512-47IEX/XWw4DUIzPPC85qiASO90rpz7E+2gWr4AKOz5ZAa7ZqEX0PYqPIcpHEM7WpEREfmXCMZgef2bwim24u8A=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.220", "", { "os": "linux", "cpu": "arm64" }, "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.283", "", { "os": "linux", "cpu": "arm64" }, "sha512-BRlnlh5fsRMoTtjDSGxZL6TathGRgSCMpJk2kdy9Wtz8l6qDjgkLuQilzTF12CwFIn+yf1Fqfrk+Njw+0EsL3A=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.283", "", { "os": "linux", "cpu": "x64" }, "sha512-cE5AebMvTlq7t7Oc0FmP5LzMOEtJ3F8XRGxkc3kTGr4aNcQgXplyYNH2yu1teW1yInuMXHzlCeFRdrmxpe2sRg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.220", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.283", "", { "os": "linux", "cpu": "x64" }, "sha512-T0T8mR7MSe7bVI96tmeK7DgUy2xhG8CZD1i0nCC2C05sSmkDDn/OYgNqpGPxJbo8Ic0Psxd7TOmngQYqPKWtpA=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.220", "", { "os": "win32", "cpu": "arm64" }, "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.283", "", { "os": "win32", "cpu": "arm64" }, "sha512-hZjyeIgZpALMvYq2QfyPzl4AZzOVRUoU8NOB82Z3cVXLcEvQJXJN03Hp3XNsuBq5YMUoSUJAffMannhM7UqVmg=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.220", "", { "os": "win32", "cpu": "x64" }, "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.283", "", { "os": "win32", "cpu": "x64" }, "sha512-h5eZxFxk6f1LPSuUOoBH9fslhL9ncywdZN5Qw9XmWuijHYTfevXuoza5nm6N98O+hmSykJ8NjC3/muhONDvzBg=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.104.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.131.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw=="], "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/node-server": ["@hono/node-server@2.1.3", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="], "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], @@ -123,17 +124,17 @@ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -159,17 +160,17 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.13.0", "", {}, "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ=="], + "hono": ["hono@4.13.12", "", {}, "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.4.0", "", {}, "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ=="], + "ip-address": ["ip-address@10.7.2", "", {}, "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -177,7 +178,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -187,7 +188,7 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -197,7 +198,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -217,13 +218,13 @@ "prettier": ["prettier@3.5.3", "", { "bin": "bin/prettier.cjs" }, ""], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -253,7 +254,7 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -279,17 +280,21 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], diff --git a/base-action/package.json b/base-action/package.json index 8148f0f..83be935 100644 --- a/base-action/package.json +++ b/base-action/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@actions/core": "^2.0.3", - "@anthropic-ai/claude-agent-sdk": "^0.3.286", + "@anthropic-ai/claude-agent-sdk": "0.3.283", "axios": "^1.16.1", "shell-quote": "^1.8.4" }, diff --git a/bun.lock b/bun.lock index 58b3804..98c38aa 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@actions/core": "^2.0.3", "@actions/github": "^8.0.1", - "@anthropic-ai/claude-agent-sdk": "0.3.283", + "@anthropic-ai/claude-agent-sdk": "^0.3.283", "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", From 46016ecfa1c6d2d57269a4c3d3b10857ca00914a Mon Sep 17 00:00:00 2001 From: amanstep Date: Thu, 1 Oct 2026 18:21:15 +0530 Subject: [PATCH 7/7] ci: add necessary permissions --- .github/workflows/claude-review-local.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/claude-review-local.yml b/.github/workflows/claude-review-local.yml index 4238244..9597aea 100644 --- a/.github/workflows/claude-review-local.yml +++ b/.github/workflows/claude-review-local.yml @@ -12,6 +12,7 @@ jobs: permissions: contents: read pull-requests: write + id-token: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2