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..9118f67af 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,35 @@ 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 or CLI"| Desktop + Mcp -->|"no, Cloud or Automation"| 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 file or branch key and node ids, +opens a pinned `HTTPSConnection("api.figma.com")`, and prints a +token-free JSON outline with geometry, solid fills, text, and +auto-layout. `--images` returns expiring PNG URLs. Desktop/CLI Figma +MCP remains the `get_design_context` path. 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. @@ -107,4 +136,6 @@ tests pin workflow structure and governance prose so drift fails closed. - [`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/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 + — product-specific psychometric repair heartbeat and scientific gates. +- [`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. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..35c6c4557 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/{file_key}` (optional `/nodes` or `/images`) without printing the secret, returning geometry, solid fills, text, auto-layout, and optional expiring PNG URLs. Desktop/CLI Figma MCP remains the `get_design_context` path. - 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. @@ -39,6 +40,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 (CWE-22), parse locators without fetching them (CWE-918), reject control characters in the token (CWE-113), and cap whoami/file bodies so `file://` and unbounded reads cannot leave the helper. - 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 +57,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..e8ecad219 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 ` against the REST API. 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..4cdbd1f41 --- /dev/null +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -0,0 +1,161 @@ +# 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). File keys and node ids are allowlisted before they +enter the request path (CWE-22; MITRE, 2026a). Locators are parsed and never +fetched; TLS already pins `api.figma.com` (CWE-918; MITRE, 2026b). Control +characters in the token are rejected so they cannot split `X-Figma-Token` +(CWE-113; MITRE, 2026c). The opener still refuses every header except +`X-Figma-Token`. + +## 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")` to `GET /v1/me` and refuses + any other URL, so Semgrep `dynamic-urllib-use-detected` does not apply + (`urllib.request.urlopen` is not used). +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 or branch key (10-128 letters or digits) + and node ids (including instance ids such as `I12:34;56:78`) before they + enter the path, opens the same pinned `api.figma.com` origin, and prints + a token-free JSON outline. `GET /v1/files/:key?depth=2` is pages and + top-level frames. A URL with `?node-id=` uses `GET /v1/files/:key/nodes`, + where `depth` counts levels under the selected node (Figma, 2026c). The + outline keeps `absoluteBoundingBox`, SOLID fills, TEXT `characters` and + type, auto-layout padding, constraints, `thumbnailUrl`, and bounded + component/style names. `--images` returns HTTPS PNG URLs that expire + after 30 days (Figma, 2026c). `file://`, `http://`, userinfo, and + `api.figma.com` locators are refused. Branch URLs + (`/design//branch//...`) use the branch key. + Implement from that outline on Cloud Agents. Desktop/CLI Figma MCP + `get_design_context` remains the richer design-to-code path; do not + retry MCP Connect here. + +## 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. (2026a). *CWE-22: Improper limitation of a pathname to a restricted +directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html + +MITRE. (2026b). *CWE-918: Server-side request forgery (SSRF)*. +https://cwe.mitre.org/data/definitions/918.html + +MITRE. (2026c). *CWE-113: Improper neutralization of CRLF sequences in HTTP +headers ('HTTP Request/Response Splitting')*. +https://cwe.mitre.org/data/definitions/113.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..f6a7e8b91 --- /dev/null +++ b/scripts/ci/figma_rest_auth.py @@ -0,0 +1,227 @@ +#!/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 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, + ) + if any(ord(character) < 32 for character in token): + raise FigmaAuthError( + f"{TOKEN_ENV_NAME} contains control characters. Store a " + "single-line token; do not paste a multiline secret.", + 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 never reaches request.""" + 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, + ) + if any(ord(character) < 32 for character in value): + raise FigmaAuthError( + "Figma REST token header contains control characters.", + 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``. + """ + if url != WHOAMI_URL: + raise FigmaAuthError( + "Figma REST opener refuses URLs other than the fixed HTTPS " + "/v1/me endpoint.", + EXIT_TRANSPORT, + ) + connection = http.client.HTTPSConnection( + "api.figma.com", + timeout=REQUEST_TIMEOUT_SECONDS, + ) + try: + connection.request("GET", "/v1/me", headers=sanitize_request_headers(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..12bd8d422 --- /dev/null +++ b/scripts/ci/figma_rest_file.py @@ -0,0 +1,663 @@ +#!/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. The JSON +outline keeps geometry, solid fills, text, and auto-layout so a Cloud Agent +can implement a frame; Desktop/CLI Figma MCP remains the +``get_design_context`` path. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import math +import os +import re +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"^I?\d+:\d+(?:;\d+:\d+)*$") +NODE_ID_QUERY = r"I?\d+:\d+(?:(?:;|%3B)\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])?" + rf"|files/[A-Za-z0-9]{{10,128}}/nodes\?ids={NODE_ID_QUERY}(?:,{NODE_ID_QUERY})*(?:&depth=[1-8])?" + rf"|images/[A-Za-z0-9]{{10,128}}\?ids={NODE_ID_QUERY}(?:,{NODE_ID_QUERY})*&format=png" + r")$" +) +DEFAULT_TREE_DEPTH = 2 +MAX_TREE_DEPTH = 8 +MAX_FILE_BODY_BYTES = 8_388_608 +MAX_NODE_IDS = 16 +MAX_TEXT_CHARS = 2_000 +MAX_SOLID_FILLS = 8 +MAX_CATALOG_ITEMS = 32 +MAX_LAYOUT_ABS = 10_000_000 +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, including instance ids such as ``I12:34;56:78``.""" + candidate = raw.strip().replace("%3B", ";").replace("%3b", ";").replace("-", ":") + if not NODE_ID_PATTERN.fullmatch(candidate): + raise FigmaAuthError( + "Figma node id must look like 12:34 or I12:34;56:78 " + "(URL hyphens are accepted).", + EXIT_INVALID_TARGET, + ) + return candidate + + +def file_key_from_url_parts(parts: Sequence[str]) -> str: + """Return the file or branch key from a parsed Figma URL path.""" + if len(parts) >= 4 and parts[2] == "branch": + return validate_file_key(parts[3]) + if len(parts) >= 5 and parts[3] == "branch": + return validate_file_key(parts[4]) + return validate_file_key(parts[1]) + + +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, + ) + if parsed.username is not None or parsed.password is not None: + raise FigmaAuthError( + "Figma file URLs cannot include userinfo. Paste the " + "https://www.figma.com/design/ URL only.", + 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 file_key_from_url_parts(parts), 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) + if len(ordered) > MAX_NODE_IDS: + raise FigmaAuthError( + f"Pass at most {MAX_NODE_IDS} node ids so the files/images query " + "stays bounded. Select the frames the buyer asked to implement.", + EXIT_INVALID_TARGET, + ) + return ordered + + +def encode_node_id_query(node_id: str) -> str: + """Encode ``;`` in instance ids so the query string stays one parameter.""" + return validate_node_id(node_id).replace(";", "%3B") + + +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, + ) + encoded_ids = ",".join(encode_node_id_query(node_id) for node_id in ids) + 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={encoded_ids}&format=png" + if ids: + return f"/v1/files/{key}/nodes?ids={encoded_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``. + """ + 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, + ) + connection = http.client.HTTPSConnection( + FIGMA_API_HOST, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + try: + connection.request("GET", path, headers=sanitize_request_headers(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 safe_label(value: object) -> str | None: + """Return a single-line label that cannot look like a Figma token.""" + label = identity_field(value) + if label is None: + return None + if "figd_" in label or TOKEN_ENV_NAME in label: + return None + return label + + +def finite_number(value: object) -> float | None: + """Return a finite layout number, refusing NaN and unbounded magnitudes.""" + if isinstance(value, bool) or not isinstance(value, int | float): + return None + number = float(value) + if not math.isfinite(number) or abs(number) > MAX_LAYOUT_ABS: + return None + return number + + +def bounding_box(value: object) -> dict[str, float] | None: + """Return ``x``/``y``/``width``/``height`` from a Figma box object.""" + if not isinstance(value, Mapping): + return None + box: dict[str, float] = {} + for key in ("x", "y", "width", "height"): + number = finite_number(value.get(key)) + if number is not None: + box[key] = number + return box or None + + +def solid_fills(value: object) -> list[dict[str, Any]]: + """Return bounded SOLID fill colors from a Figma ``fills`` array.""" + if not isinstance(value, list): + return [] + fills: list[dict[str, Any]] = [] + for raw_fill in value: + if len(fills) >= MAX_SOLID_FILLS: + break + if not isinstance(raw_fill, Mapping): + continue + if safe_label(raw_fill.get("type")) != "SOLID": + continue + color = raw_fill.get("color") + if not isinstance(color, Mapping): + continue + channels: dict[str, float] = {} + for channel in ("r", "g", "b", "a"): + number = finite_number(color.get(channel)) + if number is not None: + channels[channel] = number + if not channels: + continue + fill: dict[str, Any] = {"fill_type": "SOLID", "color": channels} + opacity = finite_number(raw_fill.get("opacity")) + if opacity is not None: + fill["opacity"] = opacity + fills.append(fill) + return fills + + +def text_style(value: object) -> dict[str, Any] | None: + """Return implementable type fields from a Figma TEXT ``style`` object.""" + if not isinstance(value, Mapping): + return None + style: dict[str, Any] = {} + family = safe_label(value.get("fontFamily")) + align = safe_label(value.get("textAlignHorizontal")) + if family is not None: + style["font_family"] = family + weight = finite_number(value.get("fontWeight")) + if weight is not None: + style["font_weight"] = weight + size = finite_number(value.get("fontSize")) + if size is not None: + style["font_size"] = size + if align is not None: + style["text_align"] = align + letter_spacing = finite_number(value.get("letterSpacing")) + if letter_spacing is not None: + style["letter_spacing"] = letter_spacing + line_height = finite_number(value.get("lineHeightPx")) + if line_height is not None: + style["line_height_px"] = line_height + return style or None + + +def layout_metrics(node: Mapping[str, Any]) -> dict[str, Any]: + """Return auto-layout and padding fields used to implement a frame.""" + metrics: dict[str, Any] = {} + layout_mode = safe_label(node.get("layoutMode")) + if layout_mode is not None: + metrics["layout_mode"] = layout_mode + primary = safe_label(node.get("primaryAxisAlignItems")) + if primary is not None: + metrics["primary_axis_align"] = primary + counter = safe_label(node.get("counterAxisAlignItems")) + if counter is not None: + metrics["counter_axis_align"] = counter + for source, dest in ( + ("paddingLeft", "padding_left"), + ("paddingRight", "padding_right"), + ("paddingTop", "padding_top"), + ("paddingBottom", "padding_bottom"), + ("itemSpacing", "item_spacing"), + ("cornerRadius", "corner_radius"), + ("opacity", "opacity"), + ("strokeWeight", "stroke_weight"), + ): + number = finite_number(node.get(source)) + if number is not None: + metrics[dest] = number + return metrics + + +def constraint_axes(value: object) -> dict[str, str] | None: + """Return horizontal/vertical constraints from a Figma node.""" + if not isinstance(value, Mapping): + return None + axes: dict[str, str] = {} + horizontal = safe_label(value.get("horizontal")) + vertical = safe_label(value.get("vertical")) + if horizontal is not None: + axes["horizontal"] = horizontal + if vertical is not None: + axes["vertical"] = vertical + return axes or None + + +def bounded_text(value: object) -> str | None: + """Return TEXT ``characters`` capped so a prompt cannot swallow the file.""" + text = safe_label(value) + if text is None: + return None + if len(text) > MAX_TEXT_CHARS: + return text[:MAX_TEXT_CHARS] + return text + + +def named_catalog(value: object, name_key: str, type_key: str | None) -> list[dict[str, str]]: + """Return a bounded name catalog from ``components`` or ``styles``.""" + if not isinstance(value, Mapping): + return [] + items: list[dict[str, str]] = [] + for raw_item in value.values(): + if len(items) >= MAX_CATALOG_ITEMS: + break + if not isinstance(raw_item, Mapping): + continue + name = safe_label(raw_item.get("name")) + if name is None: + continue + entry = {name_key: name} + if type_key is not None: + style_type = safe_label(raw_item.get(type_key)) + if style_type is not None: + entry["style_type"] = style_type + items.append(entry) + return items + + +def outline_node(node: object, remaining_depth: int) -> dict[str, Any] | None: + """Return a token-free implementable outline of one Figma node.""" + if not isinstance(node, Mapping): + return None + summary: dict[str, Any] = {} + node_id = safe_label(node.get("id")) + name = safe_label(node.get("name")) + node_type = safe_label(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 + box = bounding_box(node.get("absoluteBoundingBox")) + if box is not None: + summary["absolute_bounding_box"] = box + fills = solid_fills(node.get("fills")) + if fills: + summary["solid_fills"] = fills + style = text_style(node.get("style")) + if style is not None: + summary["text_style"] = style + characters = bounded_text(node.get("characters")) + if characters is not None: + summary["characters"] = characters + metrics = layout_metrics(node) + if metrics: + summary["layout"] = metrics + constraints = constraint_axes(node.get("constraints")) + if constraints is not None: + summary["constraints"] = constraints + 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 allowed_image_host(host: str) -> bool: + """Return whether ``host`` is a Figma or Figma-S3 image origin.""" + lowered = host.lower().rstrip(".") + if lowered == "figma.com" or lowered.endswith(".figma.com"): + return True + return lowered.startswith("figma-") and lowered.endswith(".amazonaws.com") + + +def https_image_url(value: object) -> str | None: + """Return a https Figma/S3 image URL, refusing token-shaped values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + if "figd_" in cleaned or TOKEN_ENV_NAME in cleaned: + return None + parsed = urlparse(cleaned) + if parsed.scheme != "https": + return None + if parsed.username is not None or parsed.password is not None: + return None + host = parsed.hostname or "" + if not allowed_image_host(host): + 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 = safe_label(payload.get("name")) + last_modified = safe_label(payload.get("lastModified")) + version = safe_label(payload.get("version")) + editor_type = safe_label(payload.get("editorType")) + role = safe_label(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 + thumbnail = https_image_url(payload.get("thumbnailUrl")) + if thumbnail is not None: + summary["thumbnail_url"] = thumbnail + document = outline_node(payload.get("document"), outline_depth) + if document is not None: + summary["document_outline"] = document + components = named_catalog(payload.get("components"), "component_name", None) + if components: + summary["component_names"] = components + styles = named_catalog(payload.get("styles"), "style_name", "styleType") + if styles: + summary["style_catalog"] = styles + raw_nodes = payload.get("nodes") + if isinstance(raw_nodes, Mapping): + nodes: dict[str, Any] = {} + for raw_id, raw_node in raw_nodes.items(): + normalized = str(raw_id).replace("%3B", ";").replace("%3b", ";").replace("-", ":") + node_id = validate_node_id(normalized) if NODE_ID_PATTERN.fullmatch(normalized) 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 with geometry, solid fills, " + "text, and auto-layout. Desktop/CLI Figma MCP remains the " + "get_design_context path." + ), + ) + parser.add_argument( + "locator", + help="Figma file or branch key, or https://www.figma.com/design//... URL", + ) + parser.add_argument( + "--depth", + type=int, + default=DEFAULT_TREE_DEPTH, + help=( + "Tree depth 1-8. GET /v1/files uses pages + top-level frames at 2; " + "GET /v1/files/.../nodes counts levels under the selected node" + ), + ) + parser.add_argument( + "--node-id", + action="append", + default=[], + dest="node_ids", + help="Figma node id (12:34, I12:34;56:78, or URL hyphen form). Repeatable.", + ) + parser.add_argument( + "--images", + action="store_true", + help="Render --node-id frames as expiring HTTPS 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_figma_rest_auth.py b/tests/test_figma_rest_auth.py new file mode 100644 index 000000000..a79773c86 --- /dev/null +++ b/tests/test_figma_rest_auth.py @@ -0,0 +1,417 @@ +"""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" + + +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_read_access_token_rejects_embedded_control_characters() -> None: + """A multiline secret cannot reach ``X-Figma-Token`` (CWE-113).""" + with pytest.raises(auth.FigmaAuthError) as control: + auth.read_access_token({auth.TOKEN_ENV_NAME: f"{TOKEN}\r\ninjected"}) + assert control.value.exit_code == auth.EXIT_MISSING_TOKEN + assert "control" in str(control.value) + assert TOKEN not in str(control.value) + + +def test_identity_field_normalizes_scalar_values() -> None: + """Booleans and non-text values never become identity tokens.""" + assert auth.identity_field(True) is None + assert auth.identity_field(3.14) is None + assert auth.identity_field(["x"]) is None + assert auth.identity_field(" ") is None + assert auth.identity_field(0) == "0" + + +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_summary({"handle": "a\nb", "id": True}) == ( + "Figma REST authentication succeeded (handle=a b)." + ) + + +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) -> None: + """Capture the TLS host and timeout.""" + self.host = host + self.timeout = timeout + 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_default_opener_rejects_non_whoami_urls(monkeypatch: pytest.MonkeyPatch) -> None: + """``file://`` and other caller URLs never reach the TLS sink.""" + constructed: list[object] = [] + + def forbidden_connection(*args: object, **kwargs: object) -> object: + constructed.append((args, kwargs)) + raise AssertionError("whoami opener must not connect for a refused URL") + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", forbidden_connection) + 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) + assert constructed == [] + with pytest.raises(auth.FigmaAuthError): + auth.default_opener("https://api.figma.com/v1/files/TestFileKey0123456789", {}) + assert constructed == [] + + +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) -> None: + """Initialize a 403 canned response.""" + super().__init__(host, timeout) + 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.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_sanitize_request_headers_allows_only_figma_token() -> None: + """A Host or empty token header never reaches ``HTTPSConnection.request``.""" + assert auth.sanitize_request_headers({}) == {} + assert auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN}) == { + auth.TOKEN_HEADER: TOKEN + } + with pytest.raises(auth.FigmaAuthError) as host: + auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN, "Host": "evil.example"}) + assert host.value.exit_code == auth.EXIT_TRANSPORT + assert "Host" in str(host.value) + assert TOKEN not in str(host.value) + with pytest.raises(auth.FigmaAuthError) as blank: + auth.sanitize_request_headers({auth.TOKEN_HEADER: " "}) + assert blank.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as control: + auth.sanitize_request_headers({auth.TOKEN_HEADER: f"{TOKEN}\r\nHost: evil"}) + assert control.value.exit_code == auth.EXIT_TRANSPORT + assert "control" in str(control.value) + assert TOKEN not in str(control.value) + + +def test_read_bounded_body_rejects_oversize_and_nonpositive_limits() -> None: + """Response bodies cannot grow past the configured byte 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 + assert "4" in str(oversize.value) + with pytest.raises(auth.FigmaAuthError) as invalid: + auth.read_bounded_body(lambda amt: b"", 0) + assert invalid.value.exit_code == auth.EXIT_TRANSPORT + + +def test_default_opener_rejects_oversize_whoami_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A whoami body larger than 64 KiB is a transport failure.""" + + class HugeConnection(_FakeWhoamiConnection): + """Return more bytes than the whoami cap.""" + + def __init__(self, host: str, timeout: int = 0) -> None: + """Initialize an oversized body.""" + super().__init__(host, timeout) + self._body = b"x" * (auth.MAX_WHOAMI_BODY_BYTES + 1) + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", HugeConnection) + with pytest.raises(auth.FigmaAuthError) as oversize: + auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert oversize.value.exit_code == auth.EXIT_TRANSPORT + assert str(auth.MAX_WHOAMI_BODY_BYTES) in str(oversize.value) + + +def test_live_unauthenticated_whoami_is_rejected_by_figma() -> None: + """The real ``/v1/me`` endpoint rejects a missing token with HTTP 401/403.""" + status, body = auth.default_opener(auth.WHOAMI_URL, {}) + assert status in {401, 403} + assert TOKEN not in body.decode("utf-8", errors="replace") + lowered = body.lower() + assert b"token" in lowered or b"unauthorized" in lowered or b"invalid" in lowered + + +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") + assert "urlopen(" not in source + assert "http.client.HTTPSConnection" 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() + + rejected_out = io.StringIO() + rejected_err = io.StringIO() + + def reject(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return 401, b'{"err":"Invalid token"}' + + rejected = auth.main( + argv=[], + environ={auth.TOKEN_ENV_NAME: TOKEN}, + opener=reject, + stdout=rejected_out, + stderr=rejected_err, + ) + assert rejected == auth.EXIT_REJECTED + assert rejected_out.getvalue() == "" + assert "401" in rejected_err.getvalue() + assert TOKEN not in rejected_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 "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..94dea76a0 --- /dev/null +++ b/tests/test_figma_rest_file.py @@ -0,0 +1,636 @@ +"""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" + + +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" + assert files.validate_node_id("I12-34;56-78") == "I12:34;56:78" + assert files.validate_node_id("I12:34%3B56:78") == "I12:34;56:78" + assert files.validate_node_id("I12:34%3b56:78") == "I12:34;56:78" + 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", + "https://www.figma.com@evil.example/design/TestFileKey0123456789/x", + f"https://user:pass@www.figma.com/design/{FILE_KEY}/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_parse_file_locator_uses_branch_key_not_main_file() -> None: + """A branch URL must GET the branch key, not silently outline main.""" + branch_key = "BranchKey901234567890" + key, node_ids = files.parse_file_locator( + f"https://www.figma.com/design/{FILE_KEY}/branch/{branch_key}/Checkout" + ) + assert key == branch_key + assert node_ids == [] + named = files.parse_file_locator( + f"https://figma.com/design/{FILE_KEY}/Checkout/branch/{branch_key}/Alt" + ) + assert named == (branch_key, []) + + +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_unique_node_ids_reject_unbounded_lists() -> None: + """A huge --node-id list cannot build an unbounded images query.""" + too_many = [f"1:{index}" for index in range(files.MAX_NODE_IDS + 1)] + with pytest.raises(auth.FigmaAuthError) as bounded: + files.unique_node_ids(too_many) + assert bounded.value.exit_code == files.EXIT_INVALID_TARGET + assert str(files.MAX_NODE_IDS) in str(bounded.value) + + +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) + instance_path = files.build_request_path(FILE_KEY, node_ids=["I12:34;56:78"], images=True) + assert instance_path == f"/v1/images/{FILE_KEY}?ids=I12:34%3B56:78&format=png" + assert files.ALLOWED_REQUEST_PATH.fullmatch(instance_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) -> None: + """Capture the TLS host and timeout.""" + self.host = host + self.timeout = timeout + 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.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) -> None: + """Initialize an oversized body.""" + super().__init__(host, timeout) + 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] + designed = files.outline_node( + { + "id": "I12:34;56:78", + "name": "Hero", + "type": "FRAME", + "absoluteBoundingBox": {"x": 0, "y": 8, "width": 360, "height": 80}, + "fills": [ + {"type": "SOLID", "color": {"r": 0.1, "g": 0.2, "b": 0.3, "a": 1}, "opacity": 0.9}, + {"type": "IMAGE"}, + "skip", + ], + "style": { + "fontFamily": "Inter", + "fontWeight": 600, + "fontSize": 16, + "textAlignHorizontal": "CENTER", + "letterSpacing": 0.2, + "lineHeightPx": 24, + }, + "characters": "Pay now", + "layoutMode": "HORIZONTAL", + "primaryAxisAlignItems": "CENTER", + "counterAxisAlignItems": "CENTER", + "paddingLeft": 16, + "constraints": {"horizontal": "SCALE", "vertical": "TOP"}, + }, + 0, + ) + assert designed is not None + assert designed["node_id"] == "I12:34;56:78" + assert designed["absolute_bounding_box"]["width"] == 360 + assert designed["solid_fills"][0]["color"]["r"] == 0.1 + assert designed["text_style"]["font_family"] == "Inter" + assert designed["characters"] == "Pay now" + assert designed["layout"]["layout_mode"] == "HORIZONTAL" + assert designed["constraints"]["horizontal"] == "SCALE" + 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://evil.example/x.png") is None + assert files.https_image_url("https://user:pass@figma-alpha-api.s3.amazonaws.com/x.png") is None + assert files.https_image_url("https://figma-alpha-api.s3.amazonaws.com/x.png") + assert files.https_image_url("https://s3-alpha-sig.figma.com/img/x") + + +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", + "thumbnailUrl": "https://figma-alpha-api.s3.amazonaws.com/thumb.png", + "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + "components": {"1:9": {"name": "Button"}, "bad": "skip", "1:8": {"name": None}}, + "styles": {"S:1": {"name": "Ink", "styleType": "FILL"}, "bad": []}, + "nodes": { + "12:34": {"document": {"id": "12:34", "name": "Hero", "type": "FRAME"}}, + "I12:34;56:78": {"document": {"id": "I12:34;56:78", "name": "Instance"}}, + "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["thumbnail_url"].startswith("https://") + assert summary["component_names"][0]["component_name"] == "Button" + assert summary["style_catalog"][0]["style_name"] == "Ink" + assert summary["document_outline"]["node_type"] == "DOCUMENT" + assert summary["selected_nodes"]["12:34"]["node_name"] == "Hero" + assert summary["selected_nodes"]["I12:34;56:78"]["node_name"] == "Instance" + 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") + assert "urlopen(" not in source + assert "http.client.HTTPSConnection" in source + assert '"api.figma.com"' in source or "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 json.loads(captured.out)["file_name"] == "Home" + 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 "CWE-918" in doctoring + assert "get_design_context" 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 + + +def test_design_field_helpers_stay_bounded_and_token_free() -> None: + """Geometry, fill, type, and catalog helpers refuse junk and tokens.""" + assert files.safe_label(f"keep {TOKEN}") is None + assert files.safe_label(auth.TOKEN_ENV_NAME) is None + assert files.finite_number(True) is None + assert files.finite_number("8") is None + assert files.finite_number(float("nan")) is None + assert files.finite_number(float("inf")) is None + assert files.finite_number(files.MAX_LAYOUT_ABS + 1) is None + assert files.finite_number(12) == 12.0 + assert files.bounding_box("nope") is None + assert files.bounding_box({"x": True}) is None + assert files.solid_fills("nope") == [] + assert files.solid_fills([{"type": "SOLID", "color": {"r": True}}]) == [] + assert files.solid_fills([{"type": "SOLID", "color": "nope"}]) == [] + overflow = [{"type": "SOLID", "color": {"r": 1}} for _ in range(files.MAX_SOLID_FILLS + 2)] + assert len(files.solid_fills(overflow)) == files.MAX_SOLID_FILLS + assert files.text_style("nope") is None + assert files.text_style({}) is None + assert files.constraint_axes("nope") is None + assert files.constraint_axes({}) is None + assert files.bounded_text(" ") is None + long_text = "a" * (files.MAX_TEXT_CHARS + 8) + assert files.bounded_text(long_text) == "a" * files.MAX_TEXT_CHARS + assert files.named_catalog("nope", "component_name", None) == [] + assert files.named_catalog({"1": {"name": "Ink"}}, "style_name", "styleType") == [ + {"style_name": "Ink"} + ] + catalog = {str(index): {"name": f"C{index}"} for index in range(files.MAX_CATALOG_ITEMS + 3)} + assert len(files.named_catalog(catalog, "component_name", None)) == files.MAX_CATALOG_ITEMS + assert files.allowed_image_host("figma.com") is True + assert files.allowed_image_host("evil.amazonaws.com") is False + assert files.encode_node_id_query("I1:2;3:4") == "I1:2%3B3:4" + metrics = files.layout_metrics( + { + "paddingRight": 1, + "paddingTop": 2, + "paddingBottom": 3, + "itemSpacing": 4, + "cornerRadius": 5, + "opacity": 0.5, + "strokeWeight": 1, + } + ) + assert metrics["padding_right"] == 1 + assert metrics["stroke_weight"] == 1 + + +def test_live_unauthenticated_file_read_is_rejected_by_figma() -> None: + """The real files endpoint rejects a missing token with HTTP 401/403/404.""" + status, body = files.default_file_opener(files.build_request_path(FILE_KEY), {}) + assert status in {401, 403, 404} + decoded = body.decode("utf-8", errors="replace") + assert TOKEN not in decoded