name: Security review

# Runs Claude against the PR diff, looking for security holes attackers
# could exploit in root-platform-cli — the Workbench CLI (`rp`), a published
# npm package that handles Root API keys, writes files into user
# workspaces, and executes product-module code locally. The main threat
# surfaces: credential leakage (API keys in logs/telemetry/files),
# malicious product-module content reaching eval/exec or path traversal,
# and npm supply-chain risk.
#
# Produces a traffic-light verdict (green/yellow/red). Red blocks the merge
# button via branch protection. Yellow is advisory.
#
# Ported from root-platform's security-review.yml, retuned for the CLI.
# Uses Claude Max / Pro via OAuth token (CLAUDE_CODE_OAUTH_TOKEN secret).

on:
  pull_request:
    types: [opened, ready_for_review]
  issue_comment:
    types: [created]

permissions:
  contents: read
  pull-requests: write
  issues: write
  statuses: write

concurrency:
  group: security-review-${{ github.event.pull_request.number || github.event.issue.number }}
  cancel-in-progress: true

jobs:
  security-review:
    # Auto-run once on PR open or draft->ready transition. Re-run on demand
    # when someone comments `/review` (all 4) or `/review security` (just this one).
    # `startsWith(body, '/review')` tolerates trailing whitespace + the
    # checkbox-listener body suffix.
    if: |
      (github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
      (github.event_name == 'issue_comment' && github.event.issue.pull_request != null &&
       startsWith(github.event.comment.body, '/review') &&
       !startsWith(github.event.comment.body, '/review coverage') &&
       !startsWith(github.event.comment.body, '/review breaking') &&
       !startsWith(github.event.comment.body, '/review quality') &&
       !startsWith(github.event.comment.body, '/review-help'))
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - name: Resolve PR context
        id: ctx
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # PR number + head SHA come from different event payloads.
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            PR=${{ github.event.pull_request.number }}
            SHA=${{ github.event.pull_request.head.sha }}
          else
            PR=${{ github.event.issue.number }}
            SHA=$(gh api repos/${{ github.repository }}/pulls/$PR --jq .head.sha)
          fi
          echo "pr_number=$PR" >> "$GITHUB_OUTPUT"
          echo "head_sha=$SHA" >> "$GITHUB_OUTPUT"
          echo "Resolved PR=$PR SHA=$SHA"

      - name: React to trigger comment
        if: github.event_name == 'issue_comment'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api -X POST \
            repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
            -f content=eyes >/dev/null || true

      - name: Checkout PR head
        uses: actions/checkout@v6
        with:
          ref: ${{ steps.ctx.outputs.head_sha }}
          fetch-depth: 0

      - name: Capture PR diff + relevant rules
        id: pr
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ steps.ctx.outputs.pr_number }}
        run: |
          gh pr diff "$PR_NUMBER" > /tmp/pr.diff
          diff_size=$(wc -c < /tmp/pr.diff)
          echo "diff_size=$diff_size" >> "$GITHUB_OUTPUT"
          echo "diff_lines=$(wc -l < /tmp/pr.diff)" >> "$GITHUB_OUTPUT"
          mkdir -p /tmp/ctx
          {
            for f in \
              CLAUDE.md \
              .cursor/rules/cli-messaging.mdc; do
              if [ -f "$f" ]; then
                echo "================================================================"
                echo "FILE: $f"
                echo "================================================================"
                cat "$f"
                echo
              fi
            done
          } > /tmp/ctx/rules.md
          echo '--- Diff preview (first 100 lines) ---'
          head -100 /tmp/pr.diff || true

      - name: Run Claude security review
        id: review
        uses: anthropics/claude-code-base-action@v0.0.63
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          model: 'claude-opus-4-7'
          allowed_tools: 'Read,Grep,Bash'
          max_turns: '35'
          timeout_minutes: '12'
          prompt: |
            You are conducting an in-depth security review of a pull
            request to root-platform-cli — the Root Platform Workbench CLI
            (`rp`), a published npm package that developers run locally
            and in CI. It authenticates with Root API keys, reads/writes
            files in user workspaces, shells out to build tools, and
            handles product-module code and documents. Your job: find ANY
            way an attacker could exploit the code changes to leak
            credentials, execute untrusted content, escape the workspace
            directory, or compromise the machine running the CLI.

            Be paranoid but precise — false positives waste reviewer time,
            false negatives ship to every developer machine and CI runner
            that upgrades the package.

            REPOSITORY: ${{ github.repository }}
            PR: #${{ github.event.pull_request.number }} — "${{ github.event.pull_request.title }}"
            DIFF SIZE: ${{ steps.pr.outputs.diff_size }} bytes, ${{ steps.pr.outputs.diff_lines }} lines

            ============================================================
            !!  CRITICAL: EMIT THE VERDICT JSON OR THE RUN IS WASTED
            ============================================================
            Your run has a HARD 35-turn ceiling. If you do not emit the
            ::review_json:: block before that ceiling, the workflow falls
            back to a placeholder verdict and your compute is discarded.
            A partial / yellow verdict is ALWAYS better than no verdict.
            Plan your tool calls accordingly.

            ============================================================
            OUTPUT FORMAT — READ THIS FIRST AND DO NOT FORGET IT
            ============================================================
            Your FINAL assistant message MUST contain exactly one JSON
            object wrapped in the markers below, on lines by themselves
            with NO surrounding code fences (no ``` and no ~~~), and NO
            commentary after the closing marker.

            ::review_json::
            {
              "verdict": "red" | "yellow" | "green",
              "summary": "<one-sentence overall assessment>",
              "findings": [
                {
                  "severity": "critical" | "high" | "medium" | "low" | "info",
                  "category": "credential-leak" | "command-injection" | "path-traversal" | "untrusted-code-execution" | "secrets-in-code" | "insecure-transport" | "untrusted-data" | "insecure-defaults" | "information-disclosure" | "dependency-risk" | "supply-chain",
                  "location": "<file path:line, or 'general' if cross-cutting>",
                  "description": "<what's wrong and why it matters>",
                  "fix": "<concrete suggestion>"
                }
              ]
            }
            ::end::

            Empty `findings: []` is fine. A yellow verdict with partial
            confidence is FAR better than no verdict at all.

            ============================================================
            TURN BUDGET — HARD CAP
            ============================================================
            Hard limit: 12 tool-calling turns before you MUST emit.
              - Turns 1-2: Read /tmp/pr.diff + /tmp/ctx/rules.md.
              - Turns 3-10: Read FULL files for the highest-risk changes.
                For anything touching the API helper: check where the API
                key flows (headers only — never logs, telemetry, error
                messages, or written files). For file writes: trace the
                path construction back to user/remote input. For child
                processes / exec: trace argument construction.
              - Turn 11: STOP exploring. Turn 12: EMIT.

            ============================================================

            You have Read, Grep, and Bash (git, rg, cat, head, tail) tools.
            The PR diff is in /tmp/pr.diff. Project conventions are in
            /tmp/ctx/rules.md.

            AUDIT CHECKLIST — scan the diff AND broader context for each:

              1. **credential-leak** — the Root API key (from auth config)
                 or any token appearing in console output, error messages,
                 thrown error bodies, usage telemetry (log-usage must keep
                 scrubbing `<API Key>`), written files (generated
                 workspaces, skill files, jsconfig), or URLs/query strings.
                 Any new logging near RootAPIHelper is suspect.

              2. **command-injection** — child_process exec/spawn with
                 string-concatenated input from config files, module
                 definitions, or API responses. Product-module content is
                 REMOTE data — a compromised org's module must not be able
                 to run commands on a developer machine.

              3. **path-traversal** — file writes where any path segment
                 comes from remote/module data (keys, file names in
                 definitions, supplementary-terms file names) without
                 sanitisation — `../` escape from the workspace directory
                 clobbers arbitrary files.

              4. **untrusted-code-execution** — eval / new Function /
                 require of files derived from remote content outside the
                 documented product-module execution paths; templating that
                 interpolates remote strings into executed code.

              5. **secrets-in-code** — API keys, tokens, credentials
                 checked in as literals (fixtures, examples, tests). Look
                 for high-entropy strings, JWT shapes, `sk_`/`pk_`
                 prefixes.

              6. **insecure-transport** — http:// endpoints for anything
                 non-localhost, disabled TLS verification, API keys sent
                 to hosts other than the configured Root host.

              7. **untrusted-data** — JSON.parse of remote/module content
                 followed by unguarded property access on hot paths, regex
                 DoS (catastrophic backtracking) on user/remote strings,
                 prototype-pollution-prone deep merges of remote objects.

              8. **insecure-defaults** — new config defaults that weaken
                 posture (e.g. defaulting to a non-Root host, silently
                 accepting self-signed certs), `Math.random` used for
                 anything security-sensitive.

              9. **information-disclosure** — full API response bodies or
                 stack traces dumped on error paths that may include other
                 orgs' data; internal hosts leaked into generated files.

              10. **dependency-risk** — newly-added deps with known CVEs,
                  package-lock.json drift (package.json changed but lock
                  didn't, or vice versa), deps with very few maintainers.

              11. **supply-chain** — new `postinstall` / `preinstall`
                  scripts in package.json, typo-squatted package names,
                  changes to the npm publish workflow or bin entry points.
                  This package ships to every Workbench user — treat
                  publish-pipeline changes as critical surface.

            SCOPING: only flag issues introduced or enabled by this PR.
            Pre-existing security debt unrelated to the diff is noise.
            Trivial changes (typo fixes, comment-only diffs) → green,
            empty findings.

            VERDICT — choose ONE based on the WORST severity in findings:
              - "red"    — at least one CRITICAL or HIGH severity issue.
                           Blocks merge.
              - "yellow" — only MEDIUM, LOW, or INFO. Advisory; doesn't
                           block.
              - "green"  — no security concerns. Clean pass.

            REMINDER — the OUTPUT FORMAT spec at the top is the load-bearing
            output of this whole workflow. By turn 11, stop exploring; by
            turn 12, emit. Markers on their own lines, no code fences, no
            commentary after `::end::`.

      - name: Parse verdict, post comment, set status
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ steps.ctx.outputs.pr_number }}
          HEAD_SHA: ${{ steps.ctx.outputs.head_sha }}
          REPO: ${{ github.repository }}
          RUN_ID: ${{ github.run_id }}
        run: |
          python3 .github/scripts/post-claude-review.py \
            --context "Security review" \
            --review-kind "security" \
            --execution-file "${{ steps.review.outputs.execution_file }}" \
            --workflow-file ".github/workflows/security-review.yml" \
            --findings-heading "Findings"

      - name: Delete trigger comment (issue_comment only)
        # When the workflow was fired by a /review comment, delete that
        # comment so the timeline doesn't fill up with trigger noise.
        # The verdict sticky comment is the persistent artifact.
        if: always() && github.event_name == 'issue_comment'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api -X DELETE \
            "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}" \
            >/dev/null 2>&1 || true
