name: Lint forbidden tenant-env keys

# RFC#523 Layer 3 (task #146): scan workspace_secrets-writer Go code
# under workspace-server/ for new code that hardcodes a forbidden
# operator-scope env var NAME (GITEA_TOKEN, CP_ADMIN_API_TOKEN,
# RAILWAY_TOKEN, INFISICAL_OPERATOR_TOKEN, MOLECULE_OPERATOR_*, …).
#
# Catches the class "a new writer accidentally widens the propagation
# set" — e.g. a future env-mutator plugin that sets envVars["GITEA_TOKEN"]
# directly. Today the L1 runtime guard would abort the provision, but
# this lint surfaces the offending code at PR review time instead of
# at first provision attempt.
#
# Companion layers:
#   - L1: workspace-server/internal/handlers/workspace_provision_forbidden_env.go
#         (fail-closed abort at provision time)
#   - L2: workspace/entrypoint.sh top-of-file env-grep + exit 1
#
# Open-source-template-friendly: the deny pattern is generic. A fork
# can copy this workflow and replace OPERATOR_KEY_PATTERN with its
# own operator-scope key names.
#
# Path-filter discipline:
#   This workflow runs on every PR (no paths: filter — see
#   feedback_path_filtered_workflow_cant_be_required). The scan itself
#   targets workspace_secrets-writer paths via grep -r; it's fast
#   (sub-second) so unconditional run is fine.

on:
  pull_request:
    types: [opened, synchronize, reopened]
  push:
    branches: [main, staging]

env:
  GITHUB_SERVER_URL: https://git.moleculesai.app

jobs:
  scan:
    name: Scan workspace_secrets writers for forbidden env keys
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 1

      - name: Scan for forbidden operator-scope env key NAMES in writer paths
        run: |
          set -euo pipefail

          # Forbidden EXACT-MATCH env var names. Kept in lockstep with
          # workspace-server/internal/handlers/workspace_provision_forbidden_env.go
          # forbiddenTenantEnvKeys. The Go-side test
          # TestIsForbiddenTenantEnvKey_ExactMatches is the source of
          # truth — if Go-side adds a key, also add it here (and
          # vice-versa). Drift between the two is the failure mode this
          # entire 3-layer guardrail is designed to catch.
          FORBIDDEN_KEYS=(
            "GITEA_TOKEN" "GITEA_PAT"
            "GITHUB_TOKEN" "GITHUB_PAT" "GH_TOKEN"
            "GITLAB_TOKEN" "GL_TOKEN"
            "BITBUCKET_TOKEN"
            "CP_ADMIN_API_TOKEN" "CP_ADMIN_TOKEN"
            "INFISICAL_OPERATOR_TOKEN" "INFISICAL_BOOTSTRAP_TOKEN"
            "RAILWAY_TOKEN" "RAILWAY_PERSONAL_API_TOKEN"
            "HETZNER_TOKEN" "HETZNER_API_TOKEN"
          )

          # Forbidden PREFIX patterns — operator-scope families.
          FORBIDDEN_PREFIXES=(
            "MOLECULE_OPERATOR_"
          )

          # Writer paths: Go source under workspace-server/ that
          # writes to the env-vars map or to workspace_secrets DB rows.
          # Tests, the forbidden-env source itself, and the silent-
          # strip denylist are exempt (they LIST the keys by design).
          SCAN_ROOT="workspace-server/internal"
          # Exempt paths fall in two classes:
          #   1. The deny-set definitions + the silent-strip denylist:
          #      they LIST the forbidden names by design.
          #   2. Pre-RFC#523 persona-merge / config-read paths that
          #      already handle these names correctly (the silent-
          #      strip downstream + the new L1 fail-closed cover the
          #      runtime risk; these reads are unchanged).
          # New code MUST NOT be added to this list without reviewer
          # signoff and a one-line justification in this diff.
          EXEMPT_PATHS=(
            # Class 1 — deny-set definitions
            "workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
            "workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
            "workspace-server/internal/provisioner/provisioner.go"
            "workspace-server/internal/provisioner/provisioner_test.go"
            # Class 2 — pre-existing persona-fallback / org-helper paths
            # that set the GITEA_TOKEN fallback lane (stripped downstream
            # by provisioner.buildContainerEnv per forensic #145). The
            # new L1 fail-closed runs BEFORE these writers, so any
            # operator-scope leak via global/workspace_secrets is
            # already caught. See applyAgentGitHTTPCreds doc-comment.
            "workspace-server/internal/handlers/agent_git_identity.go"
            "workspace-server/internal/handlers/org_helpers.go"
            "workspace-server/internal/handlers/org.go"
            # Class 2 — CP→platform admin auth (NOT a tenant env write;
            # this is the control-plane HTTP auth header source).
            "workspace-server/internal/provisioner/cp_provisioner.go"
          )

          # Build a single grep -F pattern: every forbidden key wrapped
          # in quotes (Go string-literal form, which is how env-map
          # writes appear). e.g. envVars["GITEA_TOKEN"] = ... or
          # `"GITEA_TOKEN":` in a literal-map declaration.
          #
          # We deliberately match the quoted form so a comment that
          # happens to spell the name without quotes (e.g. "see
          # GITEA_TOKEN below") doesn't trip the lint.
          PATTERN=""
          for k in "${FORBIDDEN_KEYS[@]}"; do
            PATTERN="${PATTERN}\"${k}\"\n"
          done
          for p in "${FORBIDDEN_PREFIXES[@]}"; do
            # Prefix match needs a regex; switch to grep -E below for
            # this slice. Kept conceptually here so the deny set lives
            # in one place; scan is run twice (literal + prefix).
            true
          done

          # Build exempt-paths grep filter — `grep -v -f` style.
          EXEMPT_FILTER=$(mktemp)
          trap 'rm -f "$EXEMPT_FILTER"' EXIT
          for p in "${EXEMPT_PATHS[@]}"; do
            echo "$p" >> "$EXEMPT_FILTER"
          done

          # --- Exact-match scan ---
          HITS=""
          for k in "${FORBIDDEN_KEYS[@]}"; do
            # Only .go files; skip _test.go for the writer-path scan
            # since tests legitimately reference the names. The
            # writer-path lint targets PRODUCTION code only.
            found=$(grep -rn --include='*.go' --exclude='*_test.go' "\"${k}\"" "$SCAN_ROOT" 2>/dev/null \
                    | grep -v -F -f "$EXEMPT_FILTER" || true)
            if [ -n "$found" ]; then
              HITS="${HITS}${found}\n"
            fi
          done

          # --- Prefix scan ---
          for prefix in "${FORBIDDEN_PREFIXES[@]}"; do
            found=$(grep -rnE --include='*.go' --exclude='*_test.go' "\"${prefix}[A-Z0-9_]+\"" "$SCAN_ROOT" 2>/dev/null \
                    | grep -v -F -f "$EXEMPT_FILTER" || true)
            if [ -n "$found" ]; then
              HITS="${HITS}${found}\n"
            fi
          done

          if [ -n "$HITS" ]; then
            echo "::error::RFC#523 Layer 3: forbidden operator-scope env var name(s) hardcoded in tenant-workspace writer paths:"
            printf "$HITS"
            echo ""
            echo "These env-var NAMES are on the operator-scope deny list (see"
            echo "workspace-server/internal/handlers/workspace_provision_forbidden_env.go)."
            echo "If your code legitimately needs to inject one of these for a"
            echo "non-tenant code path, add the file to EXEMPT_PATHS in this"
            echo "workflow with a one-line justification — reviewer signoff required."
            exit 1
          fi

          echo "OK No forbidden operator-scope env key names hardcoded in writer paths."
