From 1598d3e78a712122865d1891171db06e1e0ec6bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:47:20 +0000 Subject: [PATCH 1/4] fix(agents): document Cloud Agent Figma MCP auth fallback Cursor Cloud Agents cannot complete Figma MCP OAuth. Record the 401 challenge, the official Cloud-unsupported decision, and a REST FIGMA_ACCESS_TOKEN whoami helper that never prints the secret. Co-authored-by: Seongho Bae --- AGENTS.md | 1 + docs/CWL-MASTER-CONTEXT.md | 1 + docs/doctoring/figma-cloud-agent-mcp-auth.md | 90 +++++++ scripts/ci/figma_rest_auth.py | 149 ++++++++++++ tests/test_figma_rest_auth.py | 238 +++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 docs/doctoring/figma-cloud-agent-mcp-auth.md create mode 100644 scripts/ci/figma_rest_auth.py create mode 100644 tests/test_figma_rest_auth.py diff --git a/AGENTS.md b/AGENTS.md index bd6a96a11..66bea1850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include ( Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). +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 use the `FIGMA_ACCESS_TOKEN` secret with the REST API. See [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). diff --git a/docs/CWL-MASTER-CONTEXT.md b/docs/CWL-MASTER-CONTEXT.md index bd5e6c0c4..726b48b73 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 use the `FIGMA_ACCESS_TOKEN` secret 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..7a2d019e5 --- /dev/null +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -0,0 +1,90 @@ +# 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 access token in `FIGMA_ACCESS_TOKEN` | REST only (`X-Figma-Token` on `https://api.figma.com/v1/...`) | + +A personal 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. + +## 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. + +## 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. + +## References + +Figma. (2025). *Changelog*. Figma Developer Docs. +https://developers.figma.com/docs/rest-api/changelog/ + +Figma. (2026a). *Personal access tokens*. Figma Developer Docs. +https://developers.figma.com/docs/rest-api/personal-access-tokens/ + +Figma. (2026b). *Set up the remote server (recommended)*. Figma Developer Docs. +https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/ + +Neilson, K. (2026, June 10). Reply in *Figma MCP shows "Forbidden" in +Automations / Cloud Agents*. Cursor Forum. +https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969 diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py new file mode 100644 index 000000000..3b02eda3d --- /dev/null +++ b/scripts/ci/figma_rest_auth.py @@ -0,0 +1,149 @@ +#!/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 json +import os +import sys +import urllib.error +import urllib.request +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" +EXIT_OK = 0 +EXIT_MISSING_TOKEN = 2 +EXIT_REJECTED = 3 +EXIT_TRANSPORT = 4 +Opener = Callable[[str, Mapping[str, str]], tuple[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 default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET ``url`` with ``headers`` and return ``(status, body)``.""" + request = urllib.request.Request(url, headers=dict(headers), method="GET") + try: + with urllib.request.urlopen(request, timeout=20) as response: + return int(response.status), response.read() + except urllib.error.HTTPError as exc: + return int(exc.code), exc.read() + except urllib.error.URLError as exc: + raise FigmaAuthError( + f"Figma REST transport failed: {exc.reason}", + EXIT_TRANSPORT, + ) from exc + + +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_summary(payload: Mapping[str, Any]) -> str: + """Return a token-free identity line from a ``/v1/me`` object.""" + handle = payload.get("handle") + account_id = payload.get("id") + email = payload.get("email") + parts: list[str] = [] + if isinstance(handle, str) and handle.strip(): + parts.append(f"handle={handle.strip()}") + if isinstance(account_id, str) and account_id.strip(): + parts.append(f"id={account_id.strip()}") + if isinstance(email, str) and email.strip(): + parts.append(f"email={email.strip()}") + 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/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py new file mode 100644 index 000000000..42cc87ebc --- /dev/null +++ b/tests/test_figma_rest_auth.py @@ -0,0 +1,238 @@ +"""Contracts for Cloud Agent Figma REST authentication.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError + +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_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." + ) + + +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) + + +def test_default_opener_returns_http_error_bodies(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTPError is mapped to ``(status, body)`` so callers can classify 401/403.""" + + def fake_urlopen(request: object, timeout: int = 0) -> object: + del request, timeout + raise HTTPError(auth.WHOAMI_URL, 403, "Forbidden", hdrs=None, fp=io.BytesIO(b"nope")) + + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen) + status, body = auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert status == 403 + assert body == b"nope" + + +def test_default_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful urllib response yields its status and body bytes.""" + + class FakeResponse: + """Minimal urlopen context manager.""" + + status = 200 + + def read(self) -> bytes: + """Return a canned body.""" + return b'{"handle":"ok"}' + + def __enter__(self) -> FakeResponse: + """Return the fake response.""" + return self + + def __exit__(self, *exc: object) -> None: + """No cleanup.""" + + monkeypatch.setattr(auth.urllib.request, "urlopen", lambda *args, **kwargs: FakeResponse()) + status, body = auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert status == 200 + assert body == b'{"handle":"ok"}' + + +def test_default_opener_wraps_url_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" + + def failing_urlopen(request: object, timeout: int = 0) -> object: + del request, timeout + raise URLError(reason="timed out") + + monkeypatch.setattr(auth.urllib.request, "urlopen", failing_urlopen) + 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) + + +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 "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master From 1b16fc668cc2bd05a6403ee6fbb541302407ca6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:14:43 +0000 Subject: [PATCH 2/4] fix(ci): pin Figma whoami to HTTPSConnection Semgrep p/default flagged urllib.request.urlopen in figma_rest_auth.py as dynamic-urllib-use-detected. Open api.figma.com over TLS with a literal host and path, and refuse any other URL. Co-authored-by: Seongho Bae --- docs/doctoring/figma-cloud-agent-mcp-auth.md | 5 +- scripts/ci/figma_rest_auth.py | 36 ++++-- tests/test_figma_rest_auth.py | 124 ++++++++++++++----- 3 files changed, 125 insertions(+), 40 deletions(-) diff --git a/docs/doctoring/figma-cloud-agent-mcp-auth.md b/docs/doctoring/figma-cloud-agent-mcp-auth.md index 7a2d019e5..38ace5d90 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -63,7 +63,10 @@ authorizes the REST API. Do not commit the token. Do not put it in ``` Success prints a handle/id/email line. Missing or rejected tokens exit - non-zero and never echo the secret. + 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). ## Why MCP Connect cannot be finished here diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py index 3b02eda3d..45c1bf9e1 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -9,17 +9,17 @@ from __future__ import annotations +import http.client import json import os import sys -import urllib.error -import urllib.request 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 EXIT_OK = 0 EXIT_MISSING_TOKEN = 2 EXIT_REJECTED = 3 @@ -56,18 +56,34 @@ def read_access_token(environ: Mapping[str, str]) -> str: def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: - """GET ``url`` with ``headers`` and return ``(status, body)``.""" - request = urllib.request.Request(url, headers=dict(headers), method="GET") + """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: - with urllib.request.urlopen(request, timeout=20) as response: - return int(response.status), response.read() - except urllib.error.HTTPError as exc: - return int(exc.code), exc.read() - except urllib.error.URLError as exc: + connection.request("GET", "/v1/me", headers=dict(headers)) + response = connection.getresponse() + return int(response.status), response.read() + except OSError as exc: raise FigmaAuthError( - f"Figma REST transport failed: {exc.reason}", + f"Figma REST transport failed: {exc}", EXIT_TRANSPORT, ) from exc + finally: + connection.close() def parse_whoami_payload(body: bytes) -> dict[str, Any]: diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 42cc87ebc..225402503 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -6,7 +6,6 @@ import json from pathlib import Path from typing import Any -from urllib.error import HTTPError, URLError import pytest @@ -116,57 +115,124 @@ def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: assert "503" in str(transport.value) -def test_default_opener_returns_http_error_bodies(monkeypatch: pytest.MonkeyPatch) -> None: - """HTTPError is mapped to ``(status, body)`` so callers can classify 401/403.""" +class _FakeWhoamiResponse: + """Minimal ``HTTPResponse`` stand-in for ``HTTPSConnection.getresponse``.""" - def fake_urlopen(request: object, timeout: int = 0) -> object: - del request, timeout - raise HTTPError(auth.WHOAMI_URL, 403, "Forbidden", hdrs=None, fp=io.BytesIO(b"nope")) + def __init__(self, status: int, body: bytes) -> None: + """Record the canned status and body.""" + self.status = status + self._body = body - monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen) - status, body = auth.default_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) - assert status == 403 - assert body == b"nope" + def read(self) -> bytes: + """Return the canned body.""" + return self._body -def test_default_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: - """A successful urllib response yields its status and body bytes.""" +class _FakeWhoamiConnection: + """Record the pinned Figma origin used by ``default_opener``.""" + + last: _FakeWhoamiConnection | None = None - class FakeResponse: - """Minimal urlopen context manager.""" + 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 - status = 200 + 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 read(self) -> bytes: - """Return a canned body.""" - return b'{"handle":"ok"}' + def getresponse(self) -> _FakeWhoamiResponse: + """Return the canned whoami response.""" + return _FakeWhoamiResponse(self._status, self._body) - def __enter__(self) -> FakeResponse: - """Return the fake response.""" - return self + def close(self) -> None: + """Mark the connection closed.""" + self.closed = True + + +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.""" - def __exit__(self, *exc: object) -> None: - """No cleanup.""" + class ForbiddenConnection(_FakeWhoamiConnection): + """Return HTTP 403 from the pinned origin.""" - monkeypatch.setattr(auth.urllib.request, "urlopen", lambda *args, **kwargs: FakeResponse()) + 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_url_errors(monkeypatch: pytest.MonkeyPatch) -> None: +def test_default_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None: """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" - def failing_urlopen(request: object, timeout: int = 0) -> object: - del request, timeout - raise URLError(reason="timed out") + 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.urllib.request, "urlopen", failing_urlopen) + 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") + 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: From 00ce76edb04e046e347d408a6b4b7da05075f6a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:39:02 +0000 Subject: [PATCH 3/4] fix(agents): add Cloud Agent Figma REST file read Whoami alone does not read a Figma file. Add a pinned api.figma.com GET /v1/files helper with allowlisted keys and node ids, and record the operator path in ADR, changelog, and APA 7 doctoring so Cloud Agents can continue design-to-code. Co-authored-by: Seongho Bae --- AGENTS.md | 1 + ARCHITECTURE.md | 31 +- CHANGELOG.md | 2 + CLAUDE.md | 5 + docs/CWL-MASTER-CONTEXT.md | 2 +- docs/doctoring/figma-cloud-agent-mcp-auth.md | 47 +- scripts/ci/figma_rest_auth.py | 32 +- scripts/ci/figma_rest_file.py | 414 ++++++++++++++++ tests/test_figma_rest_auth.py | 46 +- tests/test_figma_rest_file.py | 477 +++++++++++++++++++ 10 files changed, 1038 insertions(+), 19 deletions(-) create mode 100644 scripts/ci/figma_rest_file.py create mode 100644 tests/test_figma_rest_file.py diff --git a/AGENTS.md b/AGENTS.md index 66bea1850..d673f59e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,4 @@ Conflict-scope roots fail closed when the immediate parent directory is a symbol OriginWeave hourly NVIDIA NIM repair is a thin caller at minute 10. See [`docs/doctoring/originweave-hourly-review-caller.md`](docs/doctoring/originweave-hourly-review-caller.md). nonnest2 hourly NVIDIA NIM repair is a thin caller at minute 16. See [`docs/doctoring/nonnest2-hourly-review-caller.md`](docs/doctoring/nonnest2-hourly-review-caller.md). 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 use the `FIGMA_ACCESS_TOKEN` secret with the REST API. See [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). +Cloud Agents that must read Figma files run `python3 scripts/ci/figma_rest_auth.py`, then `python3 scripts/ci/figma_rest_file.py ` with the secret in the environment. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e2e70b58..7041b68c4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -104,6 +104,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 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 key and node ids, opens a +pinned `HTTPSConnection("api.figma.com")`, and prints a token-free JSON +outline the agent can use for the next design-to-code step. 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. @@ -123,4 +150,6 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for - [`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 7bf8ad766..074651355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Semantic Versioning where the repository publishes a release. - Added an hourly organization commercial-readiness coordinator that discovers writable repositories, honors enabled dedicated writer leases and fully paginated live writer runs, refetches exact repository/workflow/run/PR state before dispatch, rotates bounded review-repair and opt-in NVIDIA OpenCode product-development targets, fails nonzero on fleet-wide inspection or dispatch outages, retains three-day JSON receipts, and keeps the existing 15-minute merge scheduler authoritative. - Added a dedicated OriginWeave hourly caller that invokes the product-neutral central scheduler with the exact repository, protected `main` branch, one-dispatch budget, two-hour same-head retry floor, non-cancelling single-flight heartbeat, job-scoped OIDC, and only the established scheduler credentials. +- 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, so design-to-code continues when Figma MCP OAuth is unavailable. - 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. @@ -64,6 +65,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 d73a5c169..0d5cf48e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,3 +133,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 726b48b73..e8ecad219 100644 --- a/docs/CWL-MASTER-CONTEXT.md +++ b/docs/CWL-MASTER-CONTEXT.md @@ -129,7 +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 use the `FIGMA_ACCESS_TOKEN` secret against the REST API. See `docs/doctoring/figma-cloud-agent-mcp-auth.md`. +- **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 index 38ace5d90..c25ba41eb 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -40,7 +40,7 @@ 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 access token in `FIGMA_ACCESS_TOKEN` | REST only (`X-Figma-Token` on `https://api.figma.com/v1/...`) | +| Cursor Cloud Agent | Figma personal 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` on pinned `https://api.figma.com/v1/me` and `/v1/files/{key}`) | A personal 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 @@ -67,6 +67,27 @@ authorizes the REST API. Do not commit the token. Do not put it in `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 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 @@ -77,17 +98,31 @@ 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. -## References +## APA 7th references -Figma. (2025). *Changelog*. Figma Developer Docs. -https://developers.figma.com/docs/rest-api/changelog/ +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. +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 + Neilson, K. (2026, June 10). Reply in *Figma MCP shows "Forbidden" in -Automations / Cloud Agents*. Cursor Forum. +Automations / Cloud Agents*. Cursor Forum. Retrieved August 16, 2026, from https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969 diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py index 45c1bf9e1..4f017403b 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -103,18 +103,32 @@ def parse_whoami_payload(body: bytes) -> dict[str, Any]: 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.""" - handle = payload.get("handle") - account_id = payload.get("id") - email = payload.get("email") parts: list[str] = [] - if isinstance(handle, str) and handle.strip(): - parts.append(f"handle={handle.strip()}") - if isinstance(account_id, str) and account_id.strip(): - parts.append(f"id={account_id.strip()}") - if isinstance(email, str) and email.strip(): - parts.append(f"email={email.strip()}") + 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) + ")." diff --git a/scripts/ci/figma_rest_file.py b/scripts/ci/figma_rest_file.py new file mode 100644 index 000000000..c3f8bb3ed --- /dev/null +++ b/scripts/ci/figma_rest_file.py @@ -0,0 +1,414 @@ +#!/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 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, +) + +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 +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``. + """ + 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=dict(headers)) + response = connection.getresponse() + return int(response.status), response.read() + 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(): + node_id = validate_node_id(str(raw_id)) if NODE_ID_PATTERN.fullmatch(str(raw_id).replace("-", ":", 1)) 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_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 225402503..6018d4abf 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -41,6 +41,15 @@ def test_read_access_token_strips_whitespace() -> None: assert auth.read_access_token({auth.TOKEN_ENV_NAME: f" {TOKEN}\n"}) == TOKEN +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." @@ -52,7 +61,10 @@ def test_identity_summary_omits_unknown_fields() -> None: "(handle=seonghobae, id=123, email=user@example.com)." ) assert auth.identity_summary({"handle": " ", "id": 17}) == ( - "Figma REST authentication succeeded." + "Figma REST authentication succeeded (id=17)." + ) + assert auth.identity_summary({"handle": "a\nb", "id": True}) == ( + "Figma REST authentication succeeded (handle=a b)." ) @@ -160,13 +172,24 @@ def close(self) -> None: self.closed = True -def test_default_opener_rejects_non_whoami_urls() -> None: +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: @@ -270,6 +293,25 @@ def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: 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] diff --git a/tests/test_figma_rest_file.py b/tests/test_figma_rest_file.py new file mode 100644 index 000000000..f215df587 --- /dev/null +++ b/tests/test_figma_rest_file.py @@ -0,0 +1,477 @@ +"""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" + 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) -> bytes: + """Return the canned body.""" + return self._body + + +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_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") + 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 "Home" in captured.out or "image" in captured.out or 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 "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 From 0135ce0ba67657c8eb76c9130c999d5790675574 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:34 +0000 Subject: [PATCH 4/4] fix(ci): cap Figma REST bodies and refuse Host overrides Absorb the #1032 security extras: allow only X-Figma-Token, cap whoami/file bodies, cite CWE-22 and plan tokens, and keep a live unauthenticated /v1/me accuracy check. Prefer this head over #1032. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring/figma-cloud-agent-mcp-auth.md | 20 ++++++- scripts/ci/figma_rest_auth.py | 41 ++++++++++++- scripts/ci/figma_rest_file.py | 7 ++- tests/test_figma_rest_auth.py | 63 +++++++++++++++++++- tests/test_figma_rest_file.py | 29 ++++++++- 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 074651355..57a0cd3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,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 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. diff --git a/docs/doctoring/figma-cloud-agent-mcp-auth.md b/docs/doctoring/figma-cloud-agent-mcp-auth.md index c25ba41eb..6576931b1 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -40,12 +40,19 @@ 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 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` on pinned `https://api.figma.com/v1/me` and `/v1/files/{key}`) | +| 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 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 +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, @@ -123,6 +130,13 @@ 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 index 4f017403b..12d24ec39 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -20,11 +20,13 @@ 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): @@ -55,6 +57,41 @@ def read_access_token(environ: Mapping[str, str]) -> str: 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)``. @@ -74,9 +111,9 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: timeout=REQUEST_TIMEOUT_SECONDS, ) try: - connection.request("GET", "/v1/me", headers=dict(headers)) + connection.request("GET", "/v1/me", headers=sanitize_request_headers(headers)) response = connection.getresponse() - return int(response.status), response.read() + 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}", diff --git a/scripts/ci/figma_rest_file.py b/scripts/ci/figma_rest_file.py index c3f8bb3ed..d2db57c75 100644 --- a/scripts/ci/figma_rest_file.py +++ b/scripts/ci/figma_rest_file.py @@ -30,6 +30,8 @@ FigmaAuthError, identity_field, read_access_token, + read_bounded_body, + sanitize_request_headers, ) EXIT_INVALID_TARGET = 5 @@ -48,6 +50,7 @@ ) DEFAULT_TREE_DEPTH = 2 MAX_TREE_DEPTH = 8 +MAX_FILE_BODY_BYTES = 8_388_608 FileOpener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] @@ -169,9 +172,9 @@ def default_file_opener(path: str, headers: Mapping[str, str]) -> tuple[int, byt timeout=REQUEST_TIMEOUT_SECONDS, ) try: - connection.request("GET", path, headers=dict(headers)) + connection.request("GET", path, headers=sanitize_request_headers(headers)) response = connection.getresponse() - return int(response.status), response.read() + 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}", diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 6018d4abf..673743456 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -135,9 +135,11 @@ def __init__(self, status: int, body: bytes) -> None: self.status = status self._body = body - def read(self) -> bytes: - """Return the canned body.""" - return self._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: @@ -249,6 +251,61 @@ def request(self, method: str, path: str, headers: dict[str, str] | None = 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 + + +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") diff --git a/tests/test_figma_rest_file.py b/tests/test_figma_rest_file.py index f215df587..4b18c8a7a 100644 --- a/tests/test_figma_rest_file.py +++ b/tests/test_figma_rest_file.py @@ -143,9 +143,11 @@ def __init__(self, status: int, body: bytes) -> None: self.status = status self._body = body - def read(self) -> bytes: - """Return the canned body.""" - return self._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: @@ -197,6 +199,24 @@ def test_default_file_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) 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.""" @@ -470,6 +490,9 @@ def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None: 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