diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 2a170fa8a..bfac36c97 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -90,12 +90,46 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Initialize CodeQL + id: init-codeql + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + # Feature-enablement GET /code-scanning/codeql-action/features returns + # HTTP 503 "No server is currently available" during GitHub API outages. + # One 20s retry was not enough when sibling CodeQL jobs recovered later. + - name: Wait before CodeQL init retry + if: steps.init-codeql.outcome == 'failure' + run: sleep 30 + + - name: Retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry + if: steps.init-codeql.outcome == 'failure' + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Wait before CodeQL init second retry + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' + run: sleep 60 + + - name: Second retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry-2 + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis + if: >- + steps.init-codeql.outcome == 'success' || + steps.init-codeql-retry.outcome == 'success' || + steps.init-codeql-retry-2.outcome == 'success' uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{ matrix.language }}" @@ -197,12 +231,46 @@ jobs: ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} - name: Initialize CodeQL + id: init-codeql + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + # Feature-enablement GET /code-scanning/codeql-action/features returns + # HTTP 503 "No server is currently available" during GitHub API outages. + # One 20s retry was not enough when sibling CodeQL jobs recovered later. + - name: Wait before CodeQL init retry + if: steps.init-codeql.outcome == 'failure' + run: sleep 30 + + - name: Retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry + if: steps.init-codeql.outcome == 'failure' + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Wait before CodeQL init second retry + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' + run: sleep 60 + + - name: Second retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry-2 + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis + if: >- + steps.init-codeql.outcome == 'success' || + steps.init-codeql-retry.outcome == 'success' || + steps.init-codeql-retry-2.outcome == 'success' uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{ matrix.language }}-merge" diff --git a/AGENTS.md b/AGENTS.md index 16f0981c0..b68fc6ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +Cursor Cloud Agents cannot complete Figma MCP OAuth against `https://mcp.figma.com/mcp` (allowlisted-client catalog; Cloud unsupported). Desktop/CLI remain the MCP path. Cloud Agents that must read Figma files store `FIGMA_ACCESS_TOKEN`, run `python3 scripts/ci/figma_rest_auth.py`, then `python3 scripts/ci/figma_rest_file.py `. See [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..48f44e38c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,33 @@ sequenceDiagram - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +## Figma Cloud Agent REST fallback + +```mermaid +flowchart TD + Need["Cloud Agent needs Figma"] + Mcp{"Figma MCP OAuth available?"} + Desktop["Desktop / CLI: Settings → Tools and MCP → Figma → Connect"] + Token{"FIGMA_ACCESS_TOKEN set?"} + Whoami["python3 scripts/ci/figma_rest_auth.py"] + File["python3 scripts/ci/figma_rest_file.py file-key-or-url"] + Mint["Mint a Figma PAT with file_content:read and store the secret"] + + Need --> Mcp + Mcp -->|"yes, Desktop/CLI"| Desktop + Mcp -->|"no, Cloud Agent"| Token + Token -->|"no"| Mint + Mint --> Whoami + Token -->|"yes"| Whoami + Whoami --> File +``` + +Cloud Agents never complete Figma MCP OAuth. Whoami alone is not file +read. The file helper allowlists the key and node ids, opens a +pinned `HTTPSConnection("api.figma.com")`, and prints a token-free JSON +outline. See +[`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). + ## Quality gates `scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. @@ -106,5 +133,7 @@ tests pin workflow structure and governance prose so drift fails closed. contract. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. +- [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md) + — Cloud Agent Figma MCP boundary and REST file-read fallback. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..8e8795ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added Cloud Agent Figma REST helpers `scripts/ci/figma_rest_auth.py` and `scripts/ci/figma_rest_file.py` that verify `FIGMA_ACCESS_TOKEN` against pinned `GET /v1/me` and then read an allowlisted `GET /v1/files/{key}` (optional `/nodes` or `/images`) so whoami is not treated as file read. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. - Added a permanent exact-head contract workflow for the hourly review-repair scheduler, immutable reusable-workflow source, NVIDIA NIM model boundary, credential isolation, and fail-closed unattended-agent permissions. @@ -26,6 +27,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Retried CodeQL `init` up to three times (30s then 60s) after a GitHub feature-enablement API 503 (`No server is currently available`) so a brief outage does not fail the required compatibility analysis. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. @@ -39,6 +41,7 @@ Semantic Versioning where the repository publishes a release. ### Security +- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids, and cap whoami/file bodies so `file://`, `Host` retargeting, and path traversal cannot leave the pinned origin. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. @@ -55,6 +58,7 @@ Semantic Versioning where the repository publishes a release. ### Documentation +- Added an APA 7 doctoring record for the Cloud Agent Figma MCP OAuth boundary, REST whoami and file-read helpers, operator next-action contract, and Figma file-endpoint citations. - Added an APA 7 doctoring record for conflict-control evidence isolation, including the Strix-reported trust-boundary failure, test-first remediation, canonical-path rule, operator contract, rollback, MITRE CWE-22, and current GitHub Actions secure-use guidance. - Added operator and APA 7 doctoring records for the hourly cadence, immutable source identity, NVIDIA NIM provider and secret boundary, high-reasoning Mistral Small 4 writer, model-process credential isolation, modular MSA ownership, product-specific caller activation, verification contract, and rollback. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. diff --git a/CLAUDE.md b/CLAUDE.md index 7127d3c1c..f8158b145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,3 +128,8 @@ repeatable compile command. cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. +- **Figma on Cloud Agents is REST-only.** Do not retry MCP Connect against + `https://mcp.figma.com/mcp`. Store `FIGMA_ACCESS_TOKEN`, run + `python3 scripts/ci/figma_rest_auth.py`, then + `python3 scripts/ci/figma_rest_file.py `. Desktop/CLI keep + the full MCP path. See `docs/doctoring/figma-cloud-agent-mcp-auth.md`. diff --git a/docs/CWL-MASTER-CONTEXT.md b/docs/CWL-MASTER-CONTEXT.md index bd5e6c0c4..5aa0b2cd8 100644 --- a/docs/CWL-MASTER-CONTEXT.md +++ b/docs/CWL-MASTER-CONTEXT.md @@ -129,6 +129,7 @@ GitHub **Project #1** is the shared source of truth. Structure: real **Issues** - **BLOCKER B1**: org GitHub Actions effectively HALTED (~86 queued, ~0 in_progress org-wide) — likely the Actions monthly SPENDING CAP. Blocks ALL PR checks/merges + the Cloudflare DNS run (nameservers). Fix (org-admin): raise the Actions spending limit OR add a self-hosted runner. Nothing merges until then. - **Decisions pending**: (D1) Code Security enablement vs the CodeQL-only code_scanning ruleset (osv/trivy/scorecard SARIF upload) — a private repo needs GHAS seats; reconcile or make those checks non-required. (D2) trivy `limit-severities-for-sarif: true` (gate only CRITICAL/HIGH) — held pending the user's strict-security preference. - **Built this session, PR-open, awaiting merge (B1)**: see Project #1 PRs (contextual-orchestrator cost/routing #46 + naruon#973; pg-llm-batch; keyverse Keycloak; inkspan; SBOM #361; opencode auto-retry #360; Strix neutral #349 + emit #358; appguardrail collector #254; auto-rebase #357; noema #359/naruon#970; PDF-DOM naruon#965/newsdom#300; SDP #11; fast-mlsirm GPGPU #109; scopeweave #284/naruon#971; fuzzing 10 PRs (found+fixed 2 real naruon bugs); Cloudflare DNS/Pages #362; this protocol #363; planning #974). Human step: report the mapasevo21 malware file (github user-attachments) to GitHub Abuse; rotate the xtrmLLMBatchPython-leaked keys; the org-admin runner/decisions above. +- **Figma MCP on Cloud Agents (2026-08-16)**: `https://mcp.figma.com/mcp` is OAuth-only and not supported in Cursor Cloud Agents / Automations. Desktop IDE and CLI remain the MCP path. Cloud Agents that must read Figma files store `FIGMA_ACCESS_TOKEN`, run `python3 scripts/ci/figma_rest_auth.py`, then `python3 scripts/ci/figma_rest_file.py `. See `docs/doctoring/figma-cloud-agent-mcp-auth.md`. --- *Keep this current. Update Project #1 as the live tracker; this file is the narrative brief a fresh agent reads to reconstruct the whole picture.* diff --git a/docs/doctoring/figma-cloud-agent-mcp-auth.md b/docs/doctoring/figma-cloud-agent-mcp-auth.md new file mode 100644 index 000000000..c41bc9fe5 --- /dev/null +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -0,0 +1,145 @@ +# Figma MCP auth on Cursor Cloud Agents + +## Incident + +A Cursor Cloud Agent tasked with Figma work discovers the official Figma MCP +server (`https://mcp.figma.com/mcp`) in an `error` state: live tool discovery +fails and no Figma tools are available. Re-running Connect / OAuth from the +Cloud Agent cannot repair it. Desktop Cursor and the Cursor CLI remain able to +complete the same OAuth flow. + +## Live evidence (2026-08-16) + +Unauthenticated `initialize` against the remote MCP endpoint: + +```http +POST https://mcp.figma.com/mcp +HTTP/2 401 +WWW-Authenticate: Bearer resource_metadata="https://mcp.figma.com/.well-known/oauth-protected-resource",scope="mcp:connect",authorization_uri="https://api.figma.com/.well-known/oauth-authorization-server" +``` + +Body: `Unauthorized`. + +The same environment can reach Figma (`HEAD`/`POST` complete; no egress block). +`GET https://api.figma.com/v1/me` without a token returns +`{"status":403,"err":"Invalid token"}`. No `FIGMA_*` environment variables are +present on the Cloud Agent VM. + +Figma's remote MCP is OAuth 2.1 with PKCE and an allowlisted MCP client +catalog. Cursor Cloud Agents are not a supported client for that catalog. + +## Decision + +Do not treat Figma MCP as available inside Cloud Agents or Cloud Automations. +Cursor staff stated this explicitly: Figma MCP is not supported in Cloud agents; +it is fully supported in the IDE and the CLI (Neilson, 2026). There is no +estimated timeline; support is a joint Cursor/Figma change. + +Use two disjoint auth paths: + +| Surface | Auth | Capability | +|---|---|---| +| Cursor Desktop / CLI | Figma MCP OAuth (`Settings → Tools & MCP → Figma → Connect`) | Full MCP toolset (`get_design_context`, `use_figma`, write-to-canvas, …) | +| Cursor Cloud Agent | Figma personal or plan access token in `FIGMA_ACCESS_TOKEN` | REST only: `python3 scripts/ci/figma_rest_auth.py` then `python3 scripts/ci/figma_rest_file.py ` (`X-Figma-Token` only, pinned `https://api.figma.com/v1/me` and `/v1/files/{key}`) | + +A personal or plan access token does **not** unlock Figma MCP on Cloud Agents. +It only authorizes the REST API. Do not commit the token. Do not put it in +`environment.json`, workflow YAML, or chat output. + +Prefer a **plan access token** for organization Cloud Agent fleets +(admin-managed, expiry up to one year; Figma, 2026a). Use a personal access +token only when the operator is acting on their own account (maximum 90 days). +Both kinds are stored in the same secret name. Whoami and file bodies are +capped (64 KiB / 8 MiB). The opener refuses every header except +`X-Figma-Token` so a `Host` override cannot retarget TLS (CWE-22; MITRE, 2026). + +## Operator procedure + +1. **Desktop / CLI MCP (preferred for design-to-code).** In Cursor Desktop, + Settings → Tools & MCP → Figma → Connect, then Allow access in the Figma + browser window. Confirm with a Figma MCP `whoami` from a desktop agent. +2. **Cloud Agent REST fallback.** In Figma: account menu → Settings → Security + → Personal access tokens → Generate new token. Name it for Cloud Agents. + Grant `file_content:read` (add comment scopes only if needed). Maximum + expiry is 90 days (Figma, 2025). Store the value as the Cursor environment + secret `FIGMA_ACCESS_TOKEN`. +3. **Verify the secret without printing it:** + + ```bash + python3 scripts/ci/figma_rest_auth.py + ``` + + Success prints a handle/id/email line. Missing or rejected tokens exit + non-zero and never echo the secret. The helper opens a pinned + `http.client.HTTPSConnection("api.figma.com")` with + `ssl.create_default_context()` to `GET /v1/me` and refuses any other URL, so + Semgrep `dynamic-urllib-use-detected` does not apply + (`urllib.request.urlopen` is not used). The historical + `httpsconnection-detected` audit (pre-3.4.3 default-verify gap) is + suppressed only at that literal sink. +4. **Read the file the buyer asked for.** Whoami is not file read. After the + secret verifies, run: + + ```bash + python3 scripts/ci/figma_rest_file.py 'https://www.figma.com/design//?node-id=12-34' + ``` + + Or pass the file key and node id directly: + + ```bash + python3 scripts/ci/figma_rest_file.py '' --node-id 12:34 + python3 scripts/ci/figma_rest_file.py '' --node-id 12:34 --images + ``` + + The helper allowlists the file key (10-128 letters or digits) and node + ids before they enter the path, opens the same pinned + `api.figma.com` origin, and prints a token-free JSON outline (pages and + top-level frames at depth 2 by default). `--images` returns HTTPS PNG + URLs for those nodes. `file://`, `http://`, and `api.figma.com` locators + are refused. Use the outline or image URLs as the next design-to-code + input; do not retry MCP Connect. + +## Why MCP Connect cannot be finished here + +Figma only accepts MCP clients listed in its catalog (Figma, 2026b). The +Cloud Agent MCP client is not on that list, so the OAuth authorize endpoint +answers `Forbidden` / `401` before a browser grant can be created. Asking the +user to "click Connect" inside a Cloud Agent or Automation therefore cannot +succeed. The same Connect button works in the desktop IDE because that client +is allowlisted. + +## APA 7th references + +Figma. (2025). *Changelog*. Figma Developer Docs. Retrieved August 16, 2026, +from https://developers.figma.com/docs/rest-api/changelog/ + +Figma. (2026a). *Personal access tokens*. Figma Developer Docs. Retrieved +August 16, 2026, from +https://developers.figma.com/docs/rest-api/personal-access-tokens/ + +Figma. (2026b). *Set up the remote server (recommended)*. Figma Developer Docs. +Retrieved August 16, 2026, from +https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/ + +Figma. (2026c). *Endpoints*. Figma Developer Docs. Retrieved August 16, 2026, +from https://developers.figma.com/docs/rest-api/file-endpoints/ + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. +https://www.rfc-editor.org/rfc/rfc9110 + +Hardt, D., Parecki, A., & Lodderstedt, T. (Eds.). (2025). *The OAuth 2.1 +authorization framework* (Internet-Draft draft-ietf-oauth-v2-1). Internet +Engineering Task Force. Retrieved August 16, 2026, from +https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1 + +MITRE. (2026). *CWE-22: Improper limitation of a pathname to a restricted +directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html + +Neilson, K. (2026, June 10). Reply in *Figma MCP shows "Forbidden" in +Automations / Cloud Agents*. Cursor Forum. Retrieved August 16, 2026, from +https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969 + +Sakimura, N., Bradley, J., & Agarwal, N. (2015). *Proof Key for Code Exchange +by OAuth public clients* (RFC 7636). RFC Editor. +https://doi.org/10.17487/RFC7636 diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py new file mode 100644 index 000000000..74fac7af1 --- /dev/null +++ b/scripts/ci/figma_rest_auth.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Verify Figma REST personal-access-token auth for Cloud Agents. + +Cursor Cloud Agents cannot complete Figma MCP OAuth. The supported Cloud +fallback is a Figma personal access token in ``FIGMA_ACCESS_TOKEN``, sent as +``X-Figma-Token`` to ``https://api.figma.com/v1/me``. This helper never prints +the token. +""" + +from __future__ import annotations + +import http.client +import json +import os +import ssl +import sys +from collections.abc import Callable, Mapping +from typing import Any, TextIO + +TOKEN_ENV_NAME = "FIGMA_ACCESS_TOKEN" +TOKEN_HEADER = "X-Figma-Token" +WHOAMI_URL = "https://api.figma.com/v1/me" +REQUEST_TIMEOUT_SECONDS = 20 +MAX_WHOAMI_BODY_BYTES = 65_536 +EXIT_OK = 0 +EXIT_MISSING_TOKEN = 2 +EXIT_REJECTED = 3 +EXIT_TRANSPORT = 4 +Opener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] +BoundedReader = Callable[[int], bytes] + + +class FigmaAuthError(Exception): + """Raised when Figma REST authentication cannot be completed.""" + + def __init__(self, message: str, exit_code: int) -> None: + """Record a user-visible failure and the process exit code.""" + super().__init__(message) + self.exit_code = exit_code + + +def read_access_token(environ: Mapping[str, str]) -> str: + """Return the trimmed personal access token or raise ``FigmaAuthError``.""" + raw = environ.get(TOKEN_ENV_NAME) + if raw is None: + raise FigmaAuthError( + f"{TOKEN_ENV_NAME} is unset. Cloud Agents cannot complete Figma " + "MCP OAuth; add a Figma personal access token as this secret.", + EXIT_MISSING_TOKEN, + ) + token = raw.strip() + if not token: + raise FigmaAuthError( + f"{TOKEN_ENV_NAME} is empty. Generate a Figma personal access " + "token and store it as this secret; do not commit it.", + EXIT_MISSING_TOKEN, + ) + return token + + +def sanitize_request_headers(headers: Mapping[str, str]) -> dict[str, str]: + """Allow only ``X-Figma-Token`` so a ``Host`` header cannot retarget TLS.""" + sanitized: dict[str, str] = {} + for name, value in headers.items(): + if name.lower() != TOKEN_HEADER.lower(): + raise FigmaAuthError( + f"Figma REST opener refuses header {name!s} other than " + f"{TOKEN_HEADER}.", + EXIT_TRANSPORT, + ) + if not value.strip(): + raise FigmaAuthError( + "Figma REST token header is empty.", + EXIT_TRANSPORT, + ) + sanitized[TOKEN_HEADER] = value + return sanitized + + +def read_bounded_body(read: BoundedReader, limit: int) -> bytes: + """Read at most ``limit`` bytes or raise ``FigmaAuthError``.""" + if limit < 1: + raise FigmaAuthError( + "Figma REST body limit must be a positive byte count.", + EXIT_TRANSPORT, + ) + payload = read(limit + 1) + if len(payload) > limit: + raise FigmaAuthError( + f"Figma REST response exceeded {limit} bytes.", + EXIT_TRANSPORT, + ) + return payload + + +def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET the fixed Figma whoami origin and return ``(status, body)``. + + Host and path are string literals at the TLS sink. Caller ``url`` is + accepted only when it equals ``WHOAMI_URL``, so ``file://`` and other + schemes never reach the network helper. This path does not call + ``urllib.request.urlopen``. ``ssl.create_default_context()`` makes + certificate and hostname verification explicit; CI Python is 3.12+, + which already verifies by default. The scoped Semgrep suppression is + only for the historical ``httpsconnection-detected`` audit (pre-3.4.3 + default-verify gap), not for a dynamic host or scheme. + """ + if url != WHOAMI_URL: + raise FigmaAuthError( + "Figma REST opener refuses URLs other than the fixed HTTPS " + "/v1/me endpoint.", + EXIT_TRANSPORT, + ) + request_headers = sanitize_request_headers(headers) + connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected + "api.figma.com", + timeout=REQUEST_TIMEOUT_SECONDS, + context=ssl.create_default_context(), + ) + try: + connection.request( + "GET", + "/v1/me", + headers=request_headers, + ) + response = connection.getresponse() + return int(response.status), read_bounded_body( + response.read, + MAX_WHOAMI_BODY_BYTES, + ) + except OSError as exc: + raise FigmaAuthError( + f"Figma REST transport failed: {exc}", + EXIT_TRANSPORT, + ) from exc + finally: + connection.close() + + +def parse_whoami_payload(body: bytes) -> dict[str, Any]: + """Parse a Figma ``/v1/me`` JSON object or raise ``FigmaAuthError``.""" + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise FigmaAuthError( + "Figma REST /v1/me returned a non-JSON body.", + EXIT_TRANSPORT, + ) from exc + if not isinstance(payload, dict): + raise FigmaAuthError( + "Figma REST /v1/me returned a JSON value that is not an object.", + EXIT_TRANSPORT, + ) + return payload + + +def identity_field(value: object) -> str | None: + """Return a single-line identity token, including numeric Figma ids.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + value = str(value) + if not isinstance(value, str): + return None + cleaned = " ".join(value.split()) + if not cleaned: + return None + return cleaned + + +def identity_summary(payload: Mapping[str, Any]) -> str: + """Return a token-free identity line from a ``/v1/me`` object.""" + parts: list[str] = [] + handle = identity_field(payload.get("handle")) + account_id = identity_field(payload.get("id")) + email = identity_field(payload.get("email")) + if handle is not None: + parts.append(f"handle={handle}") + if account_id is not None: + parts.append(f"id={account_id}") + if email is not None: + parts.append(f"email={email}") + if not parts: + return "Figma REST authentication succeeded." + return "Figma REST authentication succeeded (" + ", ".join(parts) + ")." + + +def verify_rest_auth( + environ: Mapping[str, str], + opener: Opener = default_opener, +) -> str: + """Authenticate against Figma REST ``/v1/me`` and return an identity line.""" + token = read_access_token(environ) + status, body = opener(WHOAMI_URL, {TOKEN_HEADER: token}) + if status in {401, 403}: + raise FigmaAuthError( + f"Figma REST rejected {TOKEN_ENV_NAME} with HTTP {status}. " + "Regenerate the personal access token and update the secret.", + EXIT_REJECTED, + ) + if status != 200: + raise FigmaAuthError( + f"Figma REST /v1/me returned HTTP {status}.", + EXIT_TRANSPORT, + ) + return identity_summary(parse_whoami_payload(body)) + + +def main( + argv: list[str] | None = None, + environ: Mapping[str, str] | None = None, + opener: Opener = default_opener, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + """Verify ``FIGMA_ACCESS_TOKEN`` and print a token-free identity line.""" + del argv + out = stdout if stdout is not None else sys.stdout + err = stderr if stderr is not None else sys.stderr + env = environ if environ is not None else os.environ + try: + out.write(verify_rest_auth(env, opener) + "\n") + except FigmaAuthError as exc: + err.write(str(exc) + "\n") + return exc.exit_code + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/scripts/ci/figma_rest_file.py b/scripts/ci/figma_rest_file.py new file mode 100644 index 000000000..d47951aa8 --- /dev/null +++ b/scripts/ci/figma_rest_file.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Read a Figma file over REST for Cloud Agents. + +Cursor Cloud Agents cannot complete Figma MCP OAuth. After +``figma_rest_auth.py`` confirms ``FIGMA_ACCESS_TOKEN``, this helper GETs +``/v1/files/{file_key}`` (optional ``/nodes`` or ``/images``) on a pinned +``api.figma.com`` HTTPS connection. File keys and node IDs are allowlisted +before they enter the request path. The token is never printed. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import re +import ssl +import sys +from collections.abc import Callable, Mapping, Sequence +from typing import Any, TextIO +from urllib.parse import parse_qs, unquote, urlparse + +from scripts.ci.figma_rest_auth import ( + EXIT_OK, + EXIT_REJECTED, + EXIT_TRANSPORT, + REQUEST_TIMEOUT_SECONDS, + TOKEN_ENV_NAME, + TOKEN_HEADER, + FigmaAuthError, + identity_field, + read_access_token, + read_bounded_body, + sanitize_request_headers, +) + +EXIT_INVALID_TARGET = 5 +EXIT_NOT_FOUND = 6 +FIGMA_API_HOST = "api.figma.com" +FILE_KEY_PATTERN = re.compile(r"^[A-Za-z0-9]{10,128}$") +NODE_ID_PATTERN = re.compile(r"^\d+:\d+$") +FILE_URL_TYPES = frozenset({"file", "design", "board", "proto", "slides", "deck", "figjam"}) +FILE_URL_HOSTS = frozenset({"www.figma.com", "figma.com"}) +ALLOWED_REQUEST_PATH = re.compile( + r"^/v1/(?:" + r"files/[A-Za-z0-9]{10,128}(?:\?depth=[1-8])?" + r"|files/[A-Za-z0-9]{10,128}/nodes\?ids=\d+:\d+(?:,\d+:\d+)*(?:&depth=[1-8])?" + r"|images/[A-Za-z0-9]{10,128}\?ids=\d+:\d+(?:,\d+:\d+)*&format=png" + r")$" +) +DEFAULT_TREE_DEPTH = 2 +MAX_TREE_DEPTH = 8 +MAX_FILE_BODY_BYTES = 8_388_608 +FileOpener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] + + +def validate_file_key(raw: str) -> str: + """Return an allowlisted Figma file or branch key.""" + key = raw.strip() + if not FILE_KEY_PATTERN.fullmatch(key): + raise FigmaAuthError( + "Figma file key must be 10-128 letters or digits. Paste the key " + "or a https://www.figma.com/design//... URL; do not pass " + "paths, schemes, or query strings as the key.", + EXIT_INVALID_TARGET, + ) + return key + + +def validate_node_id(raw: str) -> str: + """Return a Figma node id in ``page:node`` form.""" + candidate = raw.strip().replace("-", ":", 1) + if not NODE_ID_PATTERN.fullmatch(candidate): + raise FigmaAuthError( + "Figma node id must look like 12:34 (URL form 12-34 is accepted).", + EXIT_INVALID_TARGET, + ) + return candidate + + +def parse_file_locator(raw: str) -> tuple[str, list[str]]: + """Return ``(file_key, node_ids)`` from a key or Figma file URL.""" + text = raw.strip() + if not text: + raise FigmaAuthError( + "Pass a Figma file key or https://www.figma.com/design//... URL.", + EXIT_INVALID_TARGET, + ) + if "://" in text or text.startswith(("figma.com/", "www.figma.com/")): + parsed = urlparse(text if "://" in text else f"https://{text}") + if parsed.scheme != "https": + raise FigmaAuthError( + "Figma file URLs must use https://www.figma.com. " + "file:// and http:// locators are refused.", + EXIT_INVALID_TARGET, + ) + host = (parsed.hostname or "").lower() + if host not in FILE_URL_HOSTS: + raise FigmaAuthError( + "Figma file URLs must be on www.figma.com. " + "api.figma.com paths are not locators.", + EXIT_INVALID_TARGET, + ) + parts = [unquote(part) for part in parsed.path.split("/") if part] + if len(parts) < 2 or parts[0] not in FILE_URL_TYPES: + raise FigmaAuthError( + "Figma file URL must look like " + "https://www.figma.com/design//.", + EXIT_INVALID_TARGET, + ) + node_ids = [ + validate_node_id(value) for value in parse_qs(parsed.query).get("node-id", []) + ] + return validate_file_key(parts[1]), node_ids + return validate_file_key(text), [] + + +def unique_node_ids(values: Sequence[str]) -> list[str]: + """Return allowlisted node ids in first-seen order.""" + seen: set[str] = set() + ordered: list[str] = [] + for raw in values: + node_id = validate_node_id(raw) + if node_id in seen: + continue + seen.add(node_id) + ordered.append(node_id) + return ordered + + +def build_request_path( + file_key: str, + *, + node_ids: Sequence[str] = (), + depth: int = DEFAULT_TREE_DEPTH, + images: bool = False, +) -> str: + """Return a pinned Figma REST path after allowlisting every parameter.""" + key = validate_file_key(file_key) + ids = unique_node_ids(node_ids) + if not isinstance(depth, int) or isinstance(depth, bool) or depth < 1 or depth > MAX_TREE_DEPTH: + raise FigmaAuthError( + f"Figma tree depth must be an integer from 1 to {MAX_TREE_DEPTH}.", + EXIT_INVALID_TARGET, + ) + if images: + if not ids: + raise FigmaAuthError( + "Image export needs at least one --node-id so Figma can render " + "those nodes. Pass the frame id from the Figma URL.", + EXIT_INVALID_TARGET, + ) + return f"/v1/images/{key}?ids={','.join(ids)}&format=png" + if ids: + return f"/v1/files/{key}/nodes?ids={','.join(ids)}&depth={depth}" + return f"/v1/files/{key}?depth={depth}" + + +def default_file_opener(path: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET an allowlisted Figma REST path and return ``(status, body)``. + + Host is the string literal ``api.figma.com``. ``path`` must match + ``ALLOWED_REQUEST_PATH`` so ``file://``, ``..``, and other schemes never + reach the TLS sink. This path does not call ``urllib.request.urlopen``. + ``ssl.create_default_context()`` makes certificate verification explicit. + The scoped Semgrep suppression is only for the historical + ``httpsconnection-detected`` audit, not for a dynamic host or scheme. + """ + if ALLOWED_REQUEST_PATH.fullmatch(path) is None: + raise FigmaAuthError( + "Figma REST file opener refuses paths other than allowlisted " + "/v1/files or /v1/images requests on api.figma.com.", + EXIT_TRANSPORT, + ) + request_headers = sanitize_request_headers(headers) + connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected + FIGMA_API_HOST, + timeout=REQUEST_TIMEOUT_SECONDS, + context=ssl.create_default_context(), + ) + try: + connection.request("GET", path, headers=request_headers) + response = connection.getresponse() + return int(response.status), read_bounded_body(response.read, MAX_FILE_BODY_BYTES) + except OSError as exc: + raise FigmaAuthError( + f"Figma REST transport failed: {exc}", + EXIT_TRANSPORT, + ) from exc + finally: + connection.close() + + +def parse_json_object(body: bytes) -> dict[str, Any]: + """Parse a Figma JSON object or raise ``FigmaAuthError``.""" + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise FigmaAuthError( + "Figma REST file endpoint returned a non-JSON body.", + EXIT_TRANSPORT, + ) from exc + if not isinstance(payload, dict): + raise FigmaAuthError( + "Figma REST file endpoint returned a JSON value that is not an object.", + EXIT_TRANSPORT, + ) + return payload + + +def outline_node(node: object, remaining_depth: int) -> dict[str, Any] | None: + """Return a token-free id/name/type outline of one Figma node.""" + if not isinstance(node, Mapping): + return None + summary: dict[str, Any] = {} + node_id = identity_field(node.get("id")) + name = identity_field(node.get("name")) + node_type = identity_field(node.get("type")) + if node_id is not None: + summary["node_id"] = node_id + if name is not None: + summary["node_name"] = name + if node_type is not None: + summary["node_type"] = node_type + if remaining_depth > 0: + children = node.get("children") + if isinstance(children, list): + outlined = [outline_node(child, remaining_depth - 1) for child in children] + summary["child_nodes"] = [child for child in outlined if child is not None] + return summary or None + + +def https_image_url(value: object) -> str | None: + """Return a https image URL, refusing token-shaped or non-https values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned.startswith("https://"): + return None + if "figd_" in cleaned or TOKEN_ENV_NAME in cleaned: + return None + return cleaned + + +def summarize_file_payload(payload: Mapping[str, Any], outline_depth: int) -> dict[str, Any]: + """Return a compact, token-free file, node, or image summary.""" + summary: dict[str, Any] = {} + file_name = identity_field(payload.get("name")) + last_modified = identity_field(payload.get("lastModified")) + version = identity_field(payload.get("version")) + editor_type = identity_field(payload.get("editorType")) + role = identity_field(payload.get("role")) + if file_name is not None: + summary["file_name"] = file_name + if last_modified is not None: + summary["last_modified"] = last_modified + if version is not None: + summary["file_version"] = version + if editor_type is not None: + summary["editor_type"] = editor_type + if role is not None: + summary["viewer_role"] = role + document = outline_node(payload.get("document"), outline_depth) + if document is not None: + summary["document_outline"] = document + raw_nodes = payload.get("nodes") + if isinstance(raw_nodes, Mapping): + nodes: dict[str, Any] = {} + for raw_id, raw_node in raw_nodes.items(): + candidate = str(raw_id).replace("-", ":", 1) + node_id = validate_node_id(str(raw_id)) if NODE_ID_PATTERN.fullmatch(candidate) else None + if node_id is None: + continue + document_node = raw_node.get("document") if isinstance(raw_node, Mapping) else None + outlined = outline_node(document_node, outline_depth) + if outlined is not None: + nodes[node_id] = outlined + if nodes: + summary["selected_nodes"] = nodes + raw_images = payload.get("images") + if isinstance(raw_images, Mapping): + images = { + str(node_id): image_url + for node_id, raw_url in raw_images.items() + if (image_url := https_image_url(raw_url)) is not None + } + if images: + summary["image_urls"] = images + if not summary: + return {"read_status": "Figma REST file read succeeded with no outline fields."} + return summary + + +def classify_file_status(status: int) -> None: + """Raise ``FigmaAuthError`` for every non-200 Figma file status.""" + if status in {401, 403}: + raise FigmaAuthError( + f"Figma REST rejected {TOKEN_ENV_NAME} with HTTP {status}. " + "Regenerate the personal access token with file_content:read " + "and confirm the token can open that file.", + EXIT_REJECTED, + ) + if status == 404: + raise FigmaAuthError( + "Figma REST returned HTTP 404. Check the file key and that the " + "token's owner can open the file.", + EXIT_NOT_FOUND, + ) + if status == 400: + raise FigmaAuthError( + "Figma REST returned HTTP 400. Check --node-id and --depth.", + EXIT_INVALID_TARGET, + ) + if status != 200: + raise FigmaAuthError( + f"Figma REST file endpoint returned HTTP {status}.", + EXIT_TRANSPORT, + ) + + +def fetch_file_document( + locator: str, + environ: Mapping[str, str], + *, + extra_node_ids: Sequence[str] = (), + depth: int = DEFAULT_TREE_DEPTH, + images: bool = False, + opener: FileOpener = default_file_opener, +) -> dict[str, Any]: + """Authenticate and return a token-free Figma file summary.""" + file_key, url_node_ids = parse_file_locator(locator) + node_ids = unique_node_ids([*url_node_ids, *extra_node_ids]) + path = build_request_path( + file_key, + node_ids=node_ids, + depth=depth, + images=images, + ) + token = read_access_token(environ) + status, body = opener(path, {TOKEN_HEADER: token}) + classify_file_status(status) + return summarize_file_payload(parse_json_object(body), depth) + + +def build_argument_parser() -> argparse.ArgumentParser: + """Return the Cloud Agent CLI for Figma file reads.""" + parser = argparse.ArgumentParser( + prog="figma_rest_file.py", + description=( + "Read a Figma file over REST after FIGMA_ACCESS_TOKEN is set. " + "Prints a token-free JSON outline so a Cloud Agent can continue " + "design-to-code without Figma MCP." + ), + ) + parser.add_argument( + "locator", + help="Figma file key or https://www.figma.com/design//... URL", + ) + parser.add_argument( + "--depth", + type=int, + default=DEFAULT_TREE_DEPTH, + help="Tree depth 1-8 (default 2: pages and top-level frames)", + ) + parser.add_argument( + "--node-id", + action="append", + default=[], + dest="node_ids", + help="Figma node id (12:34 or URL form 12-34). Repeatable.", + ) + parser.add_argument( + "--images", + action="store_true", + help="Render --node-id frames as PNG URLs instead of JSON outline", + ) + return parser + + +def parse_cli_args(argv: Sequence[str]) -> argparse.Namespace: + """Parse CLI arguments, ignoring a leading script path.""" + raw = list(argv) + if raw and raw[0].endswith("figma_rest_file.py"): + raw = raw[1:] + return build_argument_parser().parse_args(raw) + + +def main( + argv: list[str] | None = None, + environ: Mapping[str, str] | None = None, + opener: FileOpener = default_file_opener, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + """Read a Figma file and print a token-free JSON outline.""" + out = stdout if stdout is not None else sys.stdout + err = stderr if stderr is not None else sys.stderr + env = environ if environ is not None else os.environ + try: + args = parse_cli_args(sys.argv if argv is None else argv) + summary = fetch_file_document( + args.locator, + env, + extra_node_ids=args.node_ids, + depth=args.depth, + images=args.images, + opener=opener, + ) + out.write(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + except FigmaAuthError as exc: + err.write(str(exc) + "\n") + return exc.exit_code + except SystemExit as exc: + code = exc.code + if code in {None, 0}: + return EXIT_OK + if isinstance(code, int): + return EXIT_INVALID_TARGET if code == 2 else code + err.write(str(code) + "\n") + return EXIT_INVALID_TARGET + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 813385b23..79af844ff 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -33,6 +33,14 @@ def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: assert "analyze-merge:" in workflow assert "merge_commit_sha != ''" in workflow assert "CodeQL merge preview" in workflow + assert workflow.count("Retry Initialize CodeQL after GitHub API outage") == 2 + assert workflow.count("Second retry Initialize CodeQL after GitHub API outage") == 2 + assert workflow.count("No server is currently available") == 2 + assert workflow.count("id: init-codeql\n") == 2 + assert workflow.count("id: init-codeql-retry\n") == 2 + assert workflow.count("id: init-codeql-retry-2\n") == 2 + assert workflow.count("sleep 30") == 2 + assert workflow.count("sleep 60") == 2 assert "github.event.pull_request.head.sha" in workflow assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py new file mode 100644 index 000000000..d3e04eff3 --- /dev/null +++ b/tests/test_figma_rest_auth.py @@ -0,0 +1,371 @@ +"""Contracts for Cloud Agent Figma REST authentication.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import figma_rest_auth as auth + +ROOT = Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring" / "figma-cloud-agent-mcp-auth.md" +AGENTS = ROOT / "AGENTS.md" +MASTER = ROOT / "docs" / "CWL-MASTER-CONTEXT.md" +TOKEN = "figd_test_token_must_never_appear" +_SEMGREP_HTTPSCONNECTION_RULE = ( + "python.lang.security.audit.httpsconnection-detected.httpsconnection-detected" +) + + +def _whoami_body(**fields: str) -> bytes: + """Return a Figma ``/v1/me`` JSON body.""" + return json.dumps(fields).encode("utf-8") + + +def test_read_access_token_requires_nonempty_secret() -> None: + """Missing or blank tokens fail closed without treating MCP as available.""" + with pytest.raises(auth.FigmaAuthError) as missing: + auth.read_access_token({}) + assert missing.value.exit_code == auth.EXIT_MISSING_TOKEN + assert auth.TOKEN_ENV_NAME in str(missing.value) + + with pytest.raises(auth.FigmaAuthError) as blank: + auth.read_access_token({auth.TOKEN_ENV_NAME: " \n"}) + assert blank.value.exit_code == auth.EXIT_MISSING_TOKEN + assert "empty" in str(blank.value) + + +def test_read_access_token_strips_whitespace() -> None: + """Surrounding whitespace is not part of the stored secret.""" + assert auth.read_access_token({auth.TOKEN_ENV_NAME: f" {TOKEN}\n"}) == TOKEN + + +def test_identity_summary_omits_unknown_fields() -> None: + """Identity lines stay token-free and tolerate a sparse payload.""" + assert auth.identity_summary({}) == "Figma REST authentication succeeded." + assert ( + auth.identity_summary( + {"handle": "seonghobae", "id": "123", "email": "user@example.com"} + ) + == "Figma REST authentication succeeded " + "(handle=seonghobae, id=123, email=user@example.com)." + ) + assert auth.identity_summary({"handle": " ", "id": 17}) == ( + "Figma REST authentication succeeded (id=17)." + ) + assert auth.identity_field(True) is None + assert auth.identity_field({"id": "x"}) is None + assert auth.identity_field(" hello world ") == "hello world" + + +def test_parse_whoami_payload_rejects_non_objects() -> None: + """Non-JSON and non-object bodies are transport failures, not auth success.""" + with pytest.raises(auth.FigmaAuthError) as invalid_json: + auth.parse_whoami_payload(b"not-json") + assert invalid_json.value.exit_code == auth.EXIT_TRANSPORT + + with pytest.raises(auth.FigmaAuthError) as not_object: + auth.parse_whoami_payload(b'["me"]') + assert not_object.value.exit_code == auth.EXIT_TRANSPORT + + with pytest.raises(auth.FigmaAuthError): + auth.parse_whoami_payload(b"\xff") + + +def test_verify_rest_auth_accepts_valid_token() -> None: + """A 200 ``/v1/me`` response is the Cloud Agent auth success signal.""" + seen: dict[str, Any] = {} + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + seen["url"] = url + seen["headers"] = dict(headers) + return 200, _whoami_body(handle="seonghobae", id="abc") + + summary = auth.verify_rest_auth({auth.TOKEN_ENV_NAME: TOKEN}, opener) + + assert seen["url"] == auth.WHOAMI_URL + assert seen["headers"] == {auth.TOKEN_HEADER: TOKEN} + assert "handle=seonghobae" in summary + assert TOKEN not in summary + + +@pytest.mark.parametrize("status", [401, 403]) +def test_verify_rest_auth_rejects_unauthorized_token(status: int) -> None: + """Figma 401/403 mean the secret must be rotated, not that MCP is up.""" + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return status, b'{"status":403,"err":"Invalid token"}' + + with pytest.raises(auth.FigmaAuthError) as rejected: + auth.verify_rest_auth({auth.TOKEN_ENV_NAME: TOKEN}, opener) + assert rejected.value.exit_code == auth.EXIT_REJECTED + assert str(status) in str(rejected.value) + assert TOKEN not in str(rejected.value) + + +def test_verify_rest_auth_treats_unexpected_status_as_transport() -> None: + """Non-auth HTTP failures stay distinct from a missing or rejected token.""" + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return 503, b"unavailable" + + with pytest.raises(auth.FigmaAuthError) as transport: + auth.verify_rest_auth({auth.TOKEN_ENV_NAME: TOKEN}, opener) + assert transport.value.exit_code == auth.EXIT_TRANSPORT + assert "503" in str(transport.value) + + +class _FakeWhoamiResponse: + """Minimal ``HTTPResponse`` stand-in for ``HTTPSConnection.getresponse``.""" + + def __init__(self, status: int, body: bytes) -> None: + """Record the canned status and body.""" + self.status = status + self._body = body + + def read(self, amt: int | None = None) -> bytes: + """Return the canned body, honoring an optional byte limit.""" + if amt is None: + return self._body + return self._body[:amt] + + +class _FakeWhoamiConnection: + """Record the pinned Figma origin used by ``default_opener``.""" + + last: _FakeWhoamiConnection | None = None + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Capture the TLS host, timeout, and explicit SSL context.""" + self.host = host + self.timeout = timeout + self.context = context + self.method = "" + self.path = "" + self.headers: dict[str, str] = {} + self.closed = False + self._status = 200 + self._body = b'{"handle":"ok"}' + type(self).last = self + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Record the fixed GET /v1/me call.""" + self.method = method + self.path = path + self.headers = dict(headers or {}) + + def getresponse(self) -> _FakeWhoamiResponse: + """Return the canned whoami response.""" + return _FakeWhoamiResponse(self._status, self._body) + + def close(self) -> None: + """Mark the connection closed.""" + self.closed = True + + +def test_sanitize_request_headers_allows_only_figma_token() -> None: + """A caller ``Host`` header must not reach ``HTTPSConnection.request``.""" + assert auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN}) == { + auth.TOKEN_HEADER: TOKEN + } + with pytest.raises(auth.FigmaAuthError) as refused: + auth.sanitize_request_headers({"Host": "evil.example", auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert "Host" in str(refused.value) + assert TOKEN not in str(refused.value) + with pytest.raises(auth.FigmaAuthError) as blank: + auth.sanitize_request_headers({auth.TOKEN_HEADER: " "}) + assert blank.value.exit_code == auth.EXIT_TRANSPORT + + +def test_read_bounded_body_rejects_oversize_and_nonpositive_limits() -> None: + """Whoami bodies stay inside the 64 KiB cap.""" + assert auth.read_bounded_body(lambda amt: b"ok"[:amt], 8) == b"ok" + with pytest.raises(auth.FigmaAuthError) as oversize: + auth.read_bounded_body(lambda amt: b"x" * amt, 4) + assert oversize.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as invalid_limit: + auth.read_bounded_body(lambda amt: b"", 0) + assert invalid_limit.value.exit_code == auth.EXIT_TRANSPORT + + +def test_default_opener_refuses_host_override(monkeypatch: pytest.MonkeyPatch) -> None: + """Header sanitization runs before the pinned whoami request.""" + constructed: list[object] = [] + + def forbidden_connection(*args: object, **kwargs: object) -> object: + constructed.append((args, kwargs)) + return _FakeWhoamiConnection(*args, **kwargs) + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", forbidden_connection) + with pytest.raises(auth.FigmaAuthError) as refused: + auth.default_opener(auth.WHOAMI_URL, {"Host": "evil.example"}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert constructed == [] + + +def test_default_opener_rejects_non_whoami_urls() -> None: + """``file://`` and other caller URLs never reach the TLS sink.""" + with pytest.raises(auth.FigmaAuthError) as refused: + auth.default_opener("file:///etc/passwd", {auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert "refuses" in str(refused.value) + assert TOKEN not in str(refused.value) + + +def test_default_opener_returns_http_error_bodies(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-200 Figma statuses stay as ``(status, body)`` for auth classification.""" + + class ForbiddenConnection(_FakeWhoamiConnection): + """Return HTTP 403 from the pinned origin.""" + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Initialize a 403 canned response.""" + super().__init__(host, timeout, context=context) + self._status = 403 + self._body = b"nope" + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", ForbiddenConnection) + status, body = auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert status == 403 + assert body == b"nope" + assert ForbiddenConnection.last is not None + assert ForbiddenConnection.last.closed is True + + +def test_default_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful HTTPS response yields its status and body bytes.""" + monkeypatch.setattr(auth.http.client, "HTTPSConnection", _FakeWhoamiConnection) + status, body = auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert status == 200 + assert body == b'{"handle":"ok"}' + connection = _FakeWhoamiConnection.last + assert connection is not None + assert connection.host == "api.figma.com" + assert connection.timeout == auth.REQUEST_TIMEOUT_SECONDS + assert connection.context is not None + assert connection.method == "GET" + assert connection.path == "/v1/me" + assert connection.headers == {auth.TOKEN_HEADER: TOKEN} + assert connection.closed is True + + +def test_default_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" + + class FailingConnection(_FakeWhoamiConnection): + """Raise a transport error after the host is already pinned.""" + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Fail after recording the request.""" + super().request(method, path, headers) + raise TimeoutError("timed out") + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", FailingConnection) + with pytest.raises(auth.FigmaAuthError) as transport: + auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert transport.value.exit_code == auth.EXIT_TRANSPORT + assert "timed out" in str(transport.value) + assert TOKEN not in str(transport.value) + assert FailingConnection.last is not None + assert FailingConnection.last.closed is True + + +def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None: + """Semgrep ``dynamic-urllib-use-detected`` must not apply to this helper.""" + source = Path(auth.__file__).read_text(encoding="utf-8") + source_lines = source.splitlines() + sink_lines = [line for line in source_lines if "http.client.HTTPSConnection(" in line] + assert "urlopen(" not in source + assert len(sink_lines) == 1 + assert f"# nosemgrep: {_SEMGREP_HTTPSCONNECTION_RULE}" in sink_lines[0] + assert "ssl.create_default_context()" in source + assert '"api.figma.com"' in source + assert '"/v1/me"' in source + + +def test_main_writes_identity_and_error_channels() -> None: + """CLI success and failure stay on stdout/stderr and never echo the token.""" + stdout = io.StringIO() + stderr = io.StringIO() + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return 200, _whoami_body(handle="seonghobae") + + ok = auth.main( + argv=["figma_rest_auth.py"], + environ={auth.TOKEN_ENV_NAME: TOKEN}, + opener=opener, + stdout=stdout, + stderr=stderr, + ) + assert ok == auth.EXIT_OK + assert "handle=seonghobae" in stdout.getvalue() + assert stderr.getvalue() == "" + assert TOKEN not in stdout.getvalue() + + missing_out = io.StringIO() + missing_err = io.StringIO() + missing = auth.main( + argv=[], + environ={}, + opener=opener, + stdout=missing_out, + stderr=missing_err, + ) + assert missing == auth.EXIT_MISSING_TOKEN + assert missing_out.getvalue() == "" + assert auth.TOKEN_ENV_NAME in missing_err.getvalue() + assert TOKEN not in missing_err.getvalue() + + +def test_main_uses_process_streams_when_unspecified( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The default CLI path reads ``os.environ`` and writes process streams.""" + monkeypatch.setenv(auth.TOKEN_ENV_NAME, TOKEN) + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url + assert headers[auth.TOKEN_HEADER] == TOKEN + return 200, _whoami_body(id="xyz") + + assert auth.main(opener=opener) == auth.EXIT_OK + captured = capsys.readouterr() + assert "id=xyz" in captured.out + assert TOKEN not in captured.out + + +def test_doctoring_and_entry_docs_pin_cloud_agent_fallback() -> None: + """Agents must not treat Figma MCP OAuth as available in Cloud Agents.""" + doctoring = DOCTORING.read_text(encoding="utf-8") + agents = AGENTS.read_text(encoding="utf-8") + master = MASTER.read_text(encoding="utf-8") + for text in (doctoring, agents, master): + assert "FIGMA_ACCESS_TOKEN" in text + assert "mcp.figma.com" in text + assert "X-Figma-Token" in doctoring + assert "not supported in Cloud agents" in doctoring + assert "https://api.figma.com/v1/me" in doctoring + assert "scripts/ci/figma_rest_auth.py" in doctoring + assert "scripts/ci/figma_rest_file.py" in doctoring + assert "scripts/ci/figma_rest_file.py" in agents + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master diff --git a/tests/test_figma_rest_file.py b/tests/test_figma_rest_file.py new file mode 100644 index 000000000..fbd93fa8c --- /dev/null +++ b/tests/test_figma_rest_file.py @@ -0,0 +1,519 @@ +"""Contracts for Cloud Agent Figma REST file reads.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import figma_rest_auth as auth +from scripts.ci import figma_rest_file as files + +ROOT = Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring" / "figma-cloud-agent-mcp-auth.md" +AGENTS = ROOT / "AGENTS.md" +MASTER = ROOT / "docs" / "CWL-MASTER-CONTEXT.md" +CHANGELOG = ROOT / "CHANGELOG.md" +ARCHITECTURE = ROOT / "ARCHITECTURE.md" +CLAUDE = ROOT / "CLAUDE.md" +TOKEN = "figd_test_token_must_never_appear" +FILE_KEY = "TestFileKey0123456789" +FILE_URL = f"https://www.figma.com/design/{FILE_KEY}/Checkout?node-id=12-34" +_SEMGREP_HTTPSCONNECTION_RULE = ( + "python.lang.security.audit.httpsconnection-detected.httpsconnection-detected" +) + + +def _file_body(**fields: object) -> bytes: + """Return a Figma file JSON body.""" + return json.dumps(fields).encode("utf-8") + + +def test_validate_file_key_rejects_paths_and_schemes() -> None: + """File keys cannot smuggle slashes, dots, or URL schemes.""" + for raw in ("", "short", "../passwd", "file://x", "https://x", "abc/def", "key with space"): + with pytest.raises(auth.FigmaAuthError) as invalid: + files.validate_file_key(raw) + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + assert TOKEN not in str(invalid.value) + assert files.validate_file_key(f" {FILE_KEY} \n") == FILE_KEY + + +def test_validate_node_id_accepts_url_hyphen_form() -> None: + """Figma share URLs use 12-34; the REST API uses 12:34.""" + assert files.validate_node_id("12-34") == "12:34" + assert files.validate_node_id("12:34") == "12:34" + with pytest.raises(auth.FigmaAuthError) as invalid: + files.validate_node_id("root") + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_parse_file_locator_reads_design_url() -> None: + """A Figma design URL yields the file key and node-id query.""" + key, node_ids = files.parse_file_locator(FILE_URL) + assert key == FILE_KEY + assert node_ids == ["12:34"] + assert files.parse_file_locator(FILE_KEY) == (FILE_KEY, []) + + +@pytest.mark.parametrize( + "locator", + [ + "", + "file:///etc/passwd", + "http://www.figma.com/design/TestFileKey0123456789/x", + "https://api.figma.com/v1/files/TestFileKey0123456789", + "https://www.figma.com/onlykey", + "https://www.figma.com/unknown/TestFileKey0123456789/x", + "www.figma.com/design/nope/x", + ], +) +def test_parse_file_locator_refuses_unsafe_or_incomplete_urls(locator: str) -> None: + """Non-https, non-figma, and incomplete locators fail closed.""" + with pytest.raises(auth.FigmaAuthError) as invalid: + files.parse_file_locator(locator) + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_parse_file_locator_accepts_host_without_scheme() -> None: + """Operators may paste www.figma.com/... without the scheme prefix.""" + key, node_ids = files.parse_file_locator(f"www.figma.com/file/{FILE_KEY}/Home") + assert key == FILE_KEY + assert node_ids == [] + + +def test_unique_node_ids_preserve_first_seen_order() -> None: + """Repeated node ids from a URL plus --node-id stay unique.""" + assert files.unique_node_ids(["12-34", "12:34", "1:2"]) == ["12:34", "1:2"] + + +def test_build_request_path_emits_only_allowlisted_shapes() -> None: + """File, node, and image paths stay inside the opener allowlist.""" + file_path = files.build_request_path(FILE_KEY) + assert file_path == f"/v1/files/{FILE_KEY}?depth=2" + assert files.ALLOWED_REQUEST_PATH.fullmatch(file_path) + nodes_path = files.build_request_path(FILE_KEY, node_ids=["12:34"], depth=1) + assert nodes_path == f"/v1/files/{FILE_KEY}/nodes?ids=12:34&depth=1" + assert files.ALLOWED_REQUEST_PATH.fullmatch(nodes_path) + image_path = files.build_request_path(FILE_KEY, node_ids=["12:34"], images=True) + assert image_path == f"/v1/images/{FILE_KEY}?ids=12:34&format=png" + assert files.ALLOWED_REQUEST_PATH.fullmatch(image_path) + + +def test_build_request_path_rejects_images_without_nodes_and_bad_depth() -> None: + """Image export and depth stay fail-closed for the operator.""" + with pytest.raises(auth.FigmaAuthError) as missing_nodes: + files.build_request_path(FILE_KEY, images=True) + assert missing_nodes.value.exit_code == files.EXIT_INVALID_TARGET + for depth in (0, 9, True): + with pytest.raises(auth.FigmaAuthError) as invalid_depth: + files.build_request_path(FILE_KEY, depth=depth) # type: ignore[arg-type] + assert invalid_depth.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_default_file_opener_refuses_non_allowlisted_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``file://`` and host-relative traversal never construct TLS.""" + constructed: list[object] = [] + + def forbidden_connection(*args: object, **kwargs: object) -> object: + constructed.append((args, kwargs)) + raise AssertionError("file opener must not connect for a refused path") + + monkeypatch.setattr(files.http.client, "HTTPSConnection", forbidden_connection) + for path in ( + "file:///etc/passwd", + "/v1/files/../secrets", + f"/v1/files/{FILE_KEY}?callback=http://evil", + "/v1/me", + ): + with pytest.raises(auth.FigmaAuthError) as refused: + files.default_file_opener(path, {auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert TOKEN not in str(refused.value) + assert constructed == [] + + +class _FakeFileResponse: + """Minimal ``HTTPResponse`` stand-in for ``HTTPSConnection.getresponse``.""" + + def __init__(self, status: int, body: bytes) -> None: + """Record the canned status and body.""" + self.status = status + self._body = body + + def read(self, amt: int | None = None) -> bytes: + """Return the canned body, honoring an optional byte limit.""" + if amt is None: + return self._body + return self._body[:amt] + + +class _FakeFileConnection: + """Record the pinned Figma origin used by ``default_file_opener``.""" + + last: _FakeFileConnection | None = None + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Capture the TLS host, timeout, and explicit SSL context.""" + self.host = host + self.timeout = timeout + self.context = context + self.method = "" + self.path = "" + self.headers: dict[str, str] = {} + self.closed = False + self._status = 200 + self._body = b'{"name":"Checkout"}' + type(self).last = self + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Record the allowlisted GET.""" + self.method = method + self.path = path + self.headers = dict(headers or {}) + + def getresponse(self) -> _FakeFileResponse: + """Return the canned file response.""" + return _FakeFileResponse(self._status, self._body) + + def close(self) -> None: + """Mark the connection closed.""" + self.closed = True + + +def test_default_file_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful HTTPS response yields its status and body bytes.""" + monkeypatch.setattr(files.http.client, "HTTPSConnection", _FakeFileConnection) + path = files.build_request_path(FILE_KEY) + status, body = files.default_file_opener(path, {auth.TOKEN_HEADER: TOKEN}) + assert status == 200 + assert body == b'{"name":"Checkout"}' + connection = _FakeFileConnection.last + assert connection is not None + assert connection.host == "api.figma.com" + assert connection.timeout == auth.REQUEST_TIMEOUT_SECONDS + assert connection.context is not None + assert connection.method == "GET" + assert connection.path == path + assert connection.headers == {auth.TOKEN_HEADER: TOKEN} + assert connection.closed is True + + +def test_default_file_opener_rejects_oversize_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A file body larger than 8 MiB is a transport failure.""" + + class HugeConnection(_FakeFileConnection): + """Return more bytes than the file cap.""" + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Initialize an oversized body.""" + super().__init__(host, timeout, context=context) + self._body = b"x" * (files.MAX_FILE_BODY_BYTES + 1) + + monkeypatch.setattr(files.http.client, "HTTPSConnection", HugeConnection) + with pytest.raises(auth.FigmaAuthError) as oversize: + files.default_file_opener(files.build_request_path(FILE_KEY), {auth.TOKEN_HEADER: TOKEN}) + assert oversize.value.exit_code == auth.EXIT_TRANSPORT + assert str(files.MAX_FILE_BODY_BYTES) in str(oversize.value) + + +def test_default_file_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" + + class FailingConnection(_FakeFileConnection): + """Raise a transport error after the host is already pinned.""" + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Fail after recording the request.""" + super().request(method, path, headers) + raise TimeoutError("timed out") + + monkeypatch.setattr(files.http.client, "HTTPSConnection", FailingConnection) + with pytest.raises(auth.FigmaAuthError) as transport: + files.default_file_opener(files.build_request_path(FILE_KEY), {auth.TOKEN_HEADER: TOKEN}) + assert transport.value.exit_code == auth.EXIT_TRANSPORT + assert "timed out" in str(transport.value) + assert TOKEN not in str(transport.value) + assert FailingConnection.last is not None + assert FailingConnection.last.closed is True + + +def test_parse_json_object_rejects_non_objects() -> None: + """Non-JSON and non-object bodies are transport failures.""" + with pytest.raises(auth.FigmaAuthError) as invalid_json: + files.parse_json_object(b"not-json") + assert invalid_json.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as not_object: + files.parse_json_object(b'["file"]') + assert not_object.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError): + files.parse_json_object(b"\xff") + + +def test_outline_and_image_summary_stay_token_free() -> None: + """Outlines keep names and drop token-shaped or non-https image URLs.""" + assert files.outline_node("not-a-node", 2) is None + assert files.outline_node({"children": "nope"}, 2) is None + outline = files.outline_node( + { + "id": "0:0", + "name": "Document", + "type": "DOCUMENT", + "children": [ + {"id": "1:2", "name": "Page 1", "type": "CANVAS", "children": [{"id": "3:4"}]} + ], + }, + 1, + ) + assert outline is not None + assert outline["node_id"] == "0:0" + assert outline["child_nodes"][0]["node_name"] == "Page 1" + assert "child_nodes" not in outline["child_nodes"][0] + assert files.https_image_url(None) is None + assert files.https_image_url("http://insecure.example/x") is None + assert files.https_image_url(f"https://x/{TOKEN}") is None + assert files.https_image_url(f"https://x/{auth.TOKEN_ENV_NAME}") is None + assert files.https_image_url("https://figma-alpha-api.s3.amazonaws.com/x.png") + + +def test_summarize_file_payload_covers_file_nodes_and_images() -> None: + """File metadata, selected nodes, and https image URLs are kept.""" + empty = files.summarize_file_payload({}, 2) + assert empty["read_status"].startswith("Figma REST file read succeeded") + summary = files.summarize_file_payload( + { + "name": "Checkout", + "lastModified": "2026-08-16T00:00:00Z", + "version": "9", + "editorType": "figma", + "role": "viewer", + "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + "nodes": { + "12:34": {"document": {"id": "12:34", "name": "Hero", "type": "FRAME"}}, + "skip": {"document": {"id": "9:9"}}, + "1:2": "not-a-map", + }, + "images": { + "12:34": "https://figma-alpha-api.s3.amazonaws.com/hero.png", + "1:2": None, + }, + }, + 2, + ) + assert summary["file_name"] == "Checkout" + assert summary["last_modified"] == "2026-08-16T00:00:00Z" + assert summary["file_version"] == "9" + assert summary["editor_type"] == "figma" + assert summary["viewer_role"] == "viewer" + assert summary["document_outline"]["node_type"] == "DOCUMENT" + assert summary["selected_nodes"]["12:34"]["node_name"] == "Hero" + assert "skip" not in summary["selected_nodes"] + assert summary["image_urls"]["12:34"].startswith("https://") + filtered = files.summarize_file_payload( + { + "nodes": {"skip": {"document": {"id": "9:9"}}, "1:2": "not-a-map"}, + "images": {"1:2": None, "3:4": "http://insecure.example/x"}, + }, + 2, + ) + assert "selected_nodes" not in filtered + assert "image_urls" not in filtered + assert filtered["read_status"].startswith("Figma REST file read succeeded") + + +@pytest.mark.parametrize( + ("status", "exit_code"), + [ + (401, auth.EXIT_REJECTED), + (403, auth.EXIT_REJECTED), + (404, files.EXIT_NOT_FOUND), + (400, files.EXIT_INVALID_TARGET), + (503, auth.EXIT_TRANSPORT), + ], +) +def test_classify_file_status_maps_operator_next_action(status: int, exit_code: int) -> None: + """HTTP classes tell the operator whether to rotate, fix the key, or retry.""" + with pytest.raises(auth.FigmaAuthError) as classified: + files.classify_file_status(status) + assert classified.value.exit_code == exit_code + assert TOKEN not in str(classified.value) + + +def test_fetch_file_document_reads_url_and_extra_node_ids() -> None: + """A 200 file response becomes a token-free outline for design-to-code.""" + seen: dict[str, Any] = {} + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + seen["path"] = path + seen["headers"] = dict(headers) + return 200, _file_body( + name="Checkout", + document={"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + ) + + summary = files.fetch_file_document( + FILE_URL, + {auth.TOKEN_ENV_NAME: TOKEN}, + extra_node_ids=["1:2"], + opener=opener, + ) + assert seen["path"] == f"/v1/files/{FILE_KEY}/nodes?ids=12:34,1:2&depth=2" + assert seen["headers"] == {auth.TOKEN_HEADER: TOKEN} + assert summary["file_name"] == "Checkout" + assert TOKEN not in json.dumps(summary) + + +def test_fetch_file_document_rejects_unauthorized_token() -> None: + """401/403 stay distinct from a missing file key.""" + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path, headers + return 403, b'{"status":403,"err":"Invalid token"}' + + with pytest.raises(auth.FigmaAuthError) as rejected: + files.fetch_file_document(FILE_KEY, {auth.TOKEN_ENV_NAME: TOKEN}, opener=opener) + assert rejected.value.exit_code == auth.EXIT_REJECTED + assert TOKEN not in str(rejected.value) + + +def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None: + """Semgrep ``dynamic-urllib-use-detected`` must not apply to this helper.""" + source = Path(files.__file__).read_text(encoding="utf-8") + source_lines = source.splitlines() + sink_lines = [line for line in source_lines if "http.client.HTTPSConnection(" in line] + assert "urlopen(" not in source + assert len(sink_lines) == 1 + assert f"# nosemgrep: {_SEMGREP_HTTPSCONNECTION_RULE}" in sink_lines[0] + assert "ssl.create_default_context()" in source + assert "FIGMA_API_HOST" in source + + +def test_main_writes_json_and_error_channels() -> None: + """CLI success and failure stay on stdout/stderr and never echo the token.""" + stdout = io.StringIO() + stderr = io.StringIO() + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path, headers + return 200, _file_body(name="Checkout") + + ok = files.main( + argv=["figma_rest_file.py", FILE_KEY], + environ={auth.TOKEN_ENV_NAME: TOKEN}, + opener=opener, + stdout=stdout, + stderr=stderr, + ) + assert ok == auth.EXIT_OK + assert json.loads(stdout.getvalue())["file_name"] == "Checkout" + assert stderr.getvalue() == "" + assert TOKEN not in stdout.getvalue() + + missing_out = io.StringIO() + missing_err = io.StringIO() + missing = files.main( + argv=[FILE_KEY], + environ={}, + opener=opener, + stdout=missing_out, + stderr=missing_err, + ) + assert missing == auth.EXIT_MISSING_TOKEN + assert missing_out.getvalue() == "" + assert auth.TOKEN_ENV_NAME in missing_err.getvalue() + + +def test_main_uses_process_streams_when_unspecified( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The default CLI path reads ``os.environ`` and writes process streams.""" + monkeypatch.setenv(auth.TOKEN_ENV_NAME, TOKEN) + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path + assert headers[auth.TOKEN_HEADER] == TOKEN + return 200, _file_body(name="Home") + + monkeypatch.setattr( + "sys.argv", + ["figma_rest_file.py", FILE_KEY, "--depth", "1", "--node-id", "1:2", "--images"], + ) + assert files.main(opener=opener) == auth.EXIT_OK + captured = capsys.readouterr() + assert captured.out + assert TOKEN not in captured.out + + +def test_main_maps_argparse_errors_to_invalid_target(capsys: pytest.CaptureFixture[str]) -> None: + """A missing locator tells the operator to pass a file key or URL.""" + assert files.main(argv=["figma_rest_file.py"], environ={}) == files.EXIT_INVALID_TARGET + captured = capsys.readouterr() + assert captured.err + assert files.main(argv=["figma_rest_file.py", "--help"], environ={}) == auth.EXIT_OK + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (SystemExit(), auth.EXIT_OK), + (SystemExit(3), 3), + (SystemExit("usage exploded"), files.EXIT_INVALID_TARGET), + ], +) +def test_main_maps_system_exit_shapes(exc: SystemExit, expected: int) -> None: + """Argparse abort codes stay fail-closed and never echo the token.""" + stderr = io.StringIO() + + def boom(_argv: list[str]) -> None: + raise exc + + original = files.parse_cli_args + files.parse_cli_args = boom # type: ignore[method-assign] + try: + code = files.main(argv=[FILE_KEY], environ={}, stdout=io.StringIO(), stderr=stderr) + finally: + files.parse_cli_args = original # type: ignore[method-assign] + assert code == expected + if expected == files.EXIT_INVALID_TARGET: + assert "usage exploded" in stderr.getvalue() + assert TOKEN not in stderr.getvalue() + + +def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None: + """Agents must run the file helper, not treat whoami as file read.""" + doctoring = DOCTORING.read_text(encoding="utf-8") + agents = AGENTS.read_text(encoding="utf-8") + master = MASTER.read_text(encoding="utf-8") + changelog = CHANGELOG.read_text(encoding="utf-8") + architecture = ARCHITECTURE.read_text(encoding="utf-8") + claude = CLAUDE.read_text(encoding="utf-8") + for text in (doctoring, agents, master): + assert "FIGMA_ACCESS_TOKEN" in text + assert "mcp.figma.com" in text + assert "scripts/ci/figma_rest_file.py" in text + assert "APA 7th references" in doctoring + assert "Retrieved August 16, 2026" in doctoring + assert "file-endpoints" in doctoring + assert "CWE-22" in doctoring + assert "plan access token" in doctoring + assert "X-Figma-Token" in changelog + assert "scripts/ci/figma_rest_file.py" in changelog + assert "Figma Cloud Agent REST" in architecture + assert "FIGMA_ACCESS_TOKEN" in claude + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master