From 9a521e442f2e315b66c6d23feacb1e64017b44fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:47:20 +0000 Subject: [PATCH 1/5] 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 16f0981c0..016eb69bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +Cursor Cloud Agents cannot complete Figma MCP OAuth against `https://mcp.figma.com/mcp` (allowlisted-client catalog; Cloud unsupported). Desktop/CLI remain the MCP path. Cloud Agents that must read Figma files 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 8dd905ead4f0a1095d90a9a520350d3413168c71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:14:43 +0000 Subject: [PATCH 2/5] 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 8fe679d8e298daf775694bd488d30f6e21586ff8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:39:02 +0000 Subject: [PATCH 3/5] 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 | 2 +- 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(+), 20 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 016eb69bf..b68fc6ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. -Cursor Cloud Agents cannot complete Figma MCP OAuth against `https://mcp.figma.com/mcp` (allowlisted-client catalog; Cloud unsupported). Desktop/CLI remain the MCP path. Cloud Agents that must read Figma files 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). +Cursor Cloud Agents cannot complete Figma MCP OAuth against `https://mcp.figma.com/mcp` (allowlisted-client catalog; Cloud unsupported). Desktop/CLI remain the MCP path. Cloud Agents that must read Figma files store `FIGMA_ACCESS_TOKEN`, run `python3 scripts/ci/figma_rest_auth.py`, then `python3 scripts/ci/figma_rest_file.py `. See [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..e0bc97d11 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,33 @@ sequenceDiagram - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +## Figma Cloud Agent REST fallback + +```mermaid +flowchart TD + Need["Cloud Agent needs Figma"] + Mcp{"Figma MCP OAuth available?"} + Desktop["Desktop / CLI: Settings → Tools and MCP → Figma → Connect"] + Token{"FIGMA_ACCESS_TOKEN set?"} + Whoami["python3 scripts/ci/figma_rest_auth.py"] + File["python3 scripts/ci/figma_rest_file.py file-key-or-url"] + Mint["Mint a Figma PAT with file_content:read and store the secret"] + + Need --> Mcp + Mcp -->|"yes, Desktop 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. @@ -107,4 +134,6 @@ tests pin workflow structure and governance prose so drift fails closed. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) - — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file + — product-specific psychometric repair heartbeat and scientific gates. +- [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md) + — Cloud Agent Figma MCP boundary and REST file-read fallback. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..dc36991ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added Cloud Agent Figma REST helpers `scripts/ci/figma_rest_auth.py` and `scripts/ci/figma_rest_file.py` that verify `FIGMA_ACCESS_TOKEN` against pinned `GET /v1/me` and then read an allowlisted `GET /v1/files/{file_key}` (optional `/nodes` or `/images`) without printing the secret, 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. @@ -55,6 +56,7 @@ Semantic Versioning where the repository publishes a release. ### Documentation +- Added an APA 7 doctoring record for the Cloud Agent Figma MCP OAuth boundary, REST whoami and file-read helpers, operator next-action contract, and Figma file-endpoint citations. - Added an APA 7 doctoring record for conflict-control evidence isolation, including the Strix-reported trust-boundary failure, test-first remediation, canonical-path rule, operator contract, rollback, MITRE CWE-22, and current GitHub Actions secure-use guidance. - Added operator and APA 7 doctoring records for the hourly cadence, immutable source identity, NVIDIA NIM provider and secret boundary, high-reasoning Mistral Small 4 writer, model-process credential isolation, modular MSA ownership, product-specific caller activation, verification contract, and rollback. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. diff --git a/CLAUDE.md b/CLAUDE.md index 7127d3c1c..f8158b145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,3 +128,8 @@ repeatable compile command. cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. +- **Figma on Cloud Agents is REST-only.** Do not retry MCP Connect against + `https://mcp.figma.com/mcp`. Store `FIGMA_ACCESS_TOKEN`, run + `python3 scripts/ci/figma_rest_auth.py`, then + `python3 scripts/ci/figma_rest_file.py `. Desktop/CLI keep + the full MCP path. See `docs/doctoring/figma-cloud-agent-mcp-auth.md`. diff --git a/docs/CWL-MASTER-CONTEXT.md b/docs/CWL-MASTER-CONTEXT.md index 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 b332f91d736bf6526b418fc3acfa0215d53aed38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:41:34 +0000 Subject: [PATCH 4/5] 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 dc36991ca..4893869de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Semantic Versioning where the repository publishes a release. ### Security +- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids, 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 From 78fec7784eda87af7d428eaf41c0f044e6a713e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:59:00 +0000 Subject: [PATCH 5/5] fix(agents): keep Figma REST design fields and branch keys Cloud Agent file read now returns geometry, solid fills, text, and auto-layout instead of name/type only, uses branch keys from branch URLs, accepts instance node ids, and stops claiming MCP-equivalent design-to-code. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 4 +- docs/doctoring/figma-cloud-agent-mcp-auth.md | 39 ++- scripts/ci/figma_rest_auth.py | 13 +- scripts/ci/figma_rest_file.py | 304 +++++++++++++++++-- tests/test_figma_rest_auth.py | 14 + tests/test_figma_rest_file.py | 138 ++++++++- 7 files changed, 474 insertions(+), 46 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e0bc97d11..9118f67af 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -112,9 +112,11 @@ flowchart TD ``` 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 +read. The file helper allowlists the file or branch key and node ids, +opens a pinned `HTTPSConnection("api.figma.com")`, and prints a +token-free JSON outline with geometry, solid fills, text, and +auto-layout. `--images` returns expiring PNG URLs. Desktop/CLI Figma +MCP remains the `get_design_context` path. See [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). ## Quality gates diff --git a/CHANGELOG.md b/CHANGELOG.md index 4893869de..35c6c4557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added -- Added Cloud Agent Figma REST helpers `scripts/ci/figma_rest_auth.py` and `scripts/ci/figma_rest_file.py` that verify `FIGMA_ACCESS_TOKEN` against pinned `GET /v1/me` and then read an allowlisted `GET /v1/files/{file_key}` (optional `/nodes` or `/images`) without printing the secret, so design-to-code continues when Figma MCP OAuth is unavailable. +- Added Cloud Agent Figma REST helpers `scripts/ci/figma_rest_auth.py` and `scripts/ci/figma_rest_file.py` that verify `FIGMA_ACCESS_TOKEN` against pinned `GET /v1/me` and then read an allowlisted `GET /v1/files/{file_key}` (optional `/nodes` or `/images`) without printing the secret, returning geometry, solid fills, text, auto-layout, and optional expiring PNG URLs. Desktop/CLI Figma MCP remains the `get_design_context` path. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. - Added a permanent exact-head contract workflow for the hourly review-repair scheduler, immutable reusable-workflow source, NVIDIA NIM model boundary, credential isolation, and fail-closed unattended-agent permissions. @@ -40,7 +40,7 @@ Semantic Versioning where the repository publishes a release. ### Security -- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids, and cap whoami/file bodies so `file://`, `Host` retargeting, and unbounded reads cannot leave the helper. +- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids (CWE-22), parse locators without fetching them (CWE-918), reject control characters in the token (CWE-113), and cap whoami/file bodies so `file://` and unbounded reads cannot leave the helper. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. diff --git a/docs/doctoring/figma-cloud-agent-mcp-auth.md b/docs/doctoring/figma-cloud-agent-mcp-auth.md index 6576931b1..4cdbd1f41 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -50,8 +50,12 @@ 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). +capped (64 KiB / 8 MiB). File keys and node ids are allowlisted before they +enter the request path (CWE-22; MITRE, 2026a). Locators are parsed and never +fetched; TLS already pins `api.figma.com` (CWE-918; MITRE, 2026b). Control +characters in the token are rejected so they cannot split `X-Figma-Token` +(CWE-113; MITRE, 2026c). The opener still refuses every header except +`X-Figma-Token`. ## Operator procedure @@ -88,13 +92,21 @@ capped (64 KiB / 8 MiB). The opener refuses every header except 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. + The helper allowlists the file or branch key (10-128 letters or digits) + and node ids (including instance ids such as `I12:34;56:78`) before they + enter the path, opens the same pinned `api.figma.com` origin, and prints + a token-free JSON outline. `GET /v1/files/:key?depth=2` is pages and + top-level frames. A URL with `?node-id=` uses `GET /v1/files/:key/nodes`, + where `depth` counts levels under the selected node (Figma, 2026c). The + outline keeps `absoluteBoundingBox`, SOLID fills, TEXT `characters` and + type, auto-layout padding, constraints, `thumbnailUrl`, and bounded + component/style names. `--images` returns HTTPS PNG URLs that expire + after 30 days (Figma, 2026c). `file://`, `http://`, userinfo, and + `api.figma.com` locators are refused. Branch URLs + (`/design//branch//...`) use the branch key. + Implement from that outline on Cloud Agents. Desktop/CLI Figma MCP + `get_design_context` remains the richer design-to-code path; do not + retry MCP Connect here. ## Why MCP Connect cannot be finished here @@ -130,9 +142,16 @@ 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 +MITRE. (2026a). *CWE-22: Improper limitation of a pathname to a restricted directory ('Path Traversal')*. https://cwe.mitre.org/data/definitions/22.html +MITRE. (2026b). *CWE-918: Server-side request forgery (SSRF)*. +https://cwe.mitre.org/data/definitions/918.html + +MITRE. (2026c). *CWE-113: Improper neutralization of CRLF sequences in HTTP +headers ('HTTP Request/Response Splitting')*. +https://cwe.mitre.org/data/definitions/113.html + Neilson, K. (2026, June 10). Reply in *Figma MCP shows "Forbidden" in Automations / Cloud Agents*. Cursor Forum. Retrieved August 16, 2026, from https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969 diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py index 12d24ec39..f6a7e8b91 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -54,11 +54,17 @@ def read_access_token(environ: Mapping[str, str]) -> str: "token and store it as this secret; do not commit it.", EXIT_MISSING_TOKEN, ) + if any(ord(character) < 32 for character in token): + raise FigmaAuthError( + f"{TOKEN_ENV_NAME} contains control characters. Store a " + "single-line token; do not paste a multiline secret.", + EXIT_MISSING_TOKEN, + ) return token def sanitize_request_headers(headers: Mapping[str, str]) -> dict[str, str]: - """Allow only ``X-Figma-Token`` so a ``Host`` header cannot retarget TLS.""" + """Allow only ``X-Figma-Token`` so a ``Host`` header never reaches request.""" sanitized: dict[str, str] = {} for name, value in headers.items(): if name.lower() != TOKEN_HEADER.lower(): @@ -72,6 +78,11 @@ def sanitize_request_headers(headers: Mapping[str, str]) -> dict[str, str]: "Figma REST token header is empty.", EXIT_TRANSPORT, ) + if any(ord(character) < 32 for character in value): + raise FigmaAuthError( + "Figma REST token header contains control characters.", + EXIT_TRANSPORT, + ) sanitized[TOKEN_HEADER] = value return sanitized diff --git a/scripts/ci/figma_rest_file.py b/scripts/ci/figma_rest_file.py index d2db57c75..12bd8d422 100644 --- a/scripts/ci/figma_rest_file.py +++ b/scripts/ci/figma_rest_file.py @@ -5,7 +5,10 @@ ``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. +before they enter the request path. The token is never printed. The JSON +outline keeps geometry, solid fills, text, and auto-layout so a Cloud Agent +can implement a frame; Desktop/CLI Figma MCP remains the +``get_design_context`` path. """ from __future__ import annotations @@ -13,6 +16,7 @@ import argparse import http.client import json +import math import os import re import sys @@ -38,19 +42,25 @@ 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+$") +NODE_ID_PATTERN = re.compile(r"^I?\d+:\d+(?:;\d+:\d+)*$") +NODE_ID_QUERY = r"I?\d+:\d+(?:(?:;|%3B)\d+:\d+)*" FILE_URL_TYPES = frozenset({"file", "design", "board", "proto", "slides", "deck", "figjam"}) FILE_URL_HOSTS = frozenset({"www.figma.com", "figma.com"}) ALLOWED_REQUEST_PATH = re.compile( r"^/v1/(?:" r"files/[A-Za-z0-9]{10,128}(?:\?depth=[1-8])?" - 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" + rf"|files/[A-Za-z0-9]{{10,128}}/nodes\?ids={NODE_ID_QUERY}(?:,{NODE_ID_QUERY})*(?:&depth=[1-8])?" + rf"|images/[A-Za-z0-9]{{10,128}}\?ids={NODE_ID_QUERY}(?:,{NODE_ID_QUERY})*&format=png" r")$" ) DEFAULT_TREE_DEPTH = 2 MAX_TREE_DEPTH = 8 MAX_FILE_BODY_BYTES = 8_388_608 +MAX_NODE_IDS = 16 +MAX_TEXT_CHARS = 2_000 +MAX_SOLID_FILLS = 8 +MAX_CATALOG_ITEMS = 32 +MAX_LAYOUT_ABS = 10_000_000 FileOpener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] @@ -68,16 +78,26 @@ def validate_file_key(raw: str) -> str: def validate_node_id(raw: str) -> str: - """Return a Figma node id in ``page:node`` form.""" - candidate = raw.strip().replace("-", ":", 1) + """Return a Figma node id, including instance ids such as ``I12:34;56:78``.""" + candidate = raw.strip().replace("%3B", ";").replace("%3b", ";").replace("-", ":") if not NODE_ID_PATTERN.fullmatch(candidate): raise FigmaAuthError( - "Figma node id must look like 12:34 (URL form 12-34 is accepted).", + "Figma node id must look like 12:34 or I12:34;56:78 " + "(URL hyphens are accepted).", EXIT_INVALID_TARGET, ) return candidate +def file_key_from_url_parts(parts: Sequence[str]) -> str: + """Return the file or branch key from a parsed Figma URL path.""" + if len(parts) >= 4 and parts[2] == "branch": + return validate_file_key(parts[3]) + if len(parts) >= 5 and parts[3] == "branch": + return validate_file_key(parts[4]) + return validate_file_key(parts[1]) + + def parse_file_locator(raw: str) -> tuple[str, list[str]]: """Return ``(file_key, node_ids)`` from a key or Figma file URL.""" text = raw.strip() @@ -94,6 +114,12 @@ def parse_file_locator(raw: str) -> tuple[str, list[str]]: "file:// and http:// locators are refused.", EXIT_INVALID_TARGET, ) + if parsed.username is not None or parsed.password is not None: + raise FigmaAuthError( + "Figma file URLs cannot include userinfo. Paste the " + "https://www.figma.com/design/ URL only.", + EXIT_INVALID_TARGET, + ) host = (parsed.hostname or "").lower() if host not in FILE_URL_HOSTS: raise FigmaAuthError( @@ -109,7 +135,7 @@ def parse_file_locator(raw: str) -> tuple[str, list[str]]: 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 file_key_from_url_parts(parts), node_ids return validate_file_key(text), [] @@ -123,9 +149,20 @@ def unique_node_ids(values: Sequence[str]) -> list[str]: continue seen.add(node_id) ordered.append(node_id) + if len(ordered) > MAX_NODE_IDS: + raise FigmaAuthError( + f"Pass at most {MAX_NODE_IDS} node ids so the files/images query " + "stays bounded. Select the frames the buyer asked to implement.", + EXIT_INVALID_TARGET, + ) return ordered +def encode_node_id_query(node_id: str) -> str: + """Encode ``;`` in instance ids so the query string stays one parameter.""" + return validate_node_id(node_id).replace(";", "%3B") + + def build_request_path( file_key: str, *, @@ -141,6 +178,7 @@ def build_request_path( f"Figma tree depth must be an integer from 1 to {MAX_TREE_DEPTH}.", EXIT_INVALID_TARGET, ) + encoded_ids = ",".join(encode_node_id_query(node_id) for node_id in ids) if images: if not ids: raise FigmaAuthError( @@ -148,9 +186,9 @@ def build_request_path( "those nodes. Pass the frame id from the Figma URL.", EXIT_INVALID_TARGET, ) - return f"/v1/images/{key}?ids={','.join(ids)}&format=png" + return f"/v1/images/{key}?ids={encoded_ids}&format=png" if ids: - return f"/v1/files/{key}/nodes?ids={','.join(ids)}&depth={depth}" + return f"/v1/files/{key}/nodes?ids={encoded_ids}&depth={depth}" return f"/v1/files/{key}?depth={depth}" @@ -201,20 +239,200 @@ def parse_json_object(body: bytes) -> dict[str, Any]: return payload +def safe_label(value: object) -> str | None: + """Return a single-line label that cannot look like a Figma token.""" + label = identity_field(value) + if label is None: + return None + if "figd_" in label or TOKEN_ENV_NAME in label: + return None + return label + + +def finite_number(value: object) -> float | None: + """Return a finite layout number, refusing NaN and unbounded magnitudes.""" + if isinstance(value, bool) or not isinstance(value, int | float): + return None + number = float(value) + if not math.isfinite(number) or abs(number) > MAX_LAYOUT_ABS: + return None + return number + + +def bounding_box(value: object) -> dict[str, float] | None: + """Return ``x``/``y``/``width``/``height`` from a Figma box object.""" + if not isinstance(value, Mapping): + return None + box: dict[str, float] = {} + for key in ("x", "y", "width", "height"): + number = finite_number(value.get(key)) + if number is not None: + box[key] = number + return box or None + + +def solid_fills(value: object) -> list[dict[str, Any]]: + """Return bounded SOLID fill colors from a Figma ``fills`` array.""" + if not isinstance(value, list): + return [] + fills: list[dict[str, Any]] = [] + for raw_fill in value: + if len(fills) >= MAX_SOLID_FILLS: + break + if not isinstance(raw_fill, Mapping): + continue + if safe_label(raw_fill.get("type")) != "SOLID": + continue + color = raw_fill.get("color") + if not isinstance(color, Mapping): + continue + channels: dict[str, float] = {} + for channel in ("r", "g", "b", "a"): + number = finite_number(color.get(channel)) + if number is not None: + channels[channel] = number + if not channels: + continue + fill: dict[str, Any] = {"fill_type": "SOLID", "color": channels} + opacity = finite_number(raw_fill.get("opacity")) + if opacity is not None: + fill["opacity"] = opacity + fills.append(fill) + return fills + + +def text_style(value: object) -> dict[str, Any] | None: + """Return implementable type fields from a Figma TEXT ``style`` object.""" + if not isinstance(value, Mapping): + return None + style: dict[str, Any] = {} + family = safe_label(value.get("fontFamily")) + align = safe_label(value.get("textAlignHorizontal")) + if family is not None: + style["font_family"] = family + weight = finite_number(value.get("fontWeight")) + if weight is not None: + style["font_weight"] = weight + size = finite_number(value.get("fontSize")) + if size is not None: + style["font_size"] = size + if align is not None: + style["text_align"] = align + letter_spacing = finite_number(value.get("letterSpacing")) + if letter_spacing is not None: + style["letter_spacing"] = letter_spacing + line_height = finite_number(value.get("lineHeightPx")) + if line_height is not None: + style["line_height_px"] = line_height + return style or None + + +def layout_metrics(node: Mapping[str, Any]) -> dict[str, Any]: + """Return auto-layout and padding fields used to implement a frame.""" + metrics: dict[str, Any] = {} + layout_mode = safe_label(node.get("layoutMode")) + if layout_mode is not None: + metrics["layout_mode"] = layout_mode + primary = safe_label(node.get("primaryAxisAlignItems")) + if primary is not None: + metrics["primary_axis_align"] = primary + counter = safe_label(node.get("counterAxisAlignItems")) + if counter is not None: + metrics["counter_axis_align"] = counter + for source, dest in ( + ("paddingLeft", "padding_left"), + ("paddingRight", "padding_right"), + ("paddingTop", "padding_top"), + ("paddingBottom", "padding_bottom"), + ("itemSpacing", "item_spacing"), + ("cornerRadius", "corner_radius"), + ("opacity", "opacity"), + ("strokeWeight", "stroke_weight"), + ): + number = finite_number(node.get(source)) + if number is not None: + metrics[dest] = number + return metrics + + +def constraint_axes(value: object) -> dict[str, str] | None: + """Return horizontal/vertical constraints from a Figma node.""" + if not isinstance(value, Mapping): + return None + axes: dict[str, str] = {} + horizontal = safe_label(value.get("horizontal")) + vertical = safe_label(value.get("vertical")) + if horizontal is not None: + axes["horizontal"] = horizontal + if vertical is not None: + axes["vertical"] = vertical + return axes or None + + +def bounded_text(value: object) -> str | None: + """Return TEXT ``characters`` capped so a prompt cannot swallow the file.""" + text = safe_label(value) + if text is None: + return None + if len(text) > MAX_TEXT_CHARS: + return text[:MAX_TEXT_CHARS] + return text + + +def named_catalog(value: object, name_key: str, type_key: str | None) -> list[dict[str, str]]: + """Return a bounded name catalog from ``components`` or ``styles``.""" + if not isinstance(value, Mapping): + return [] + items: list[dict[str, str]] = [] + for raw_item in value.values(): + if len(items) >= MAX_CATALOG_ITEMS: + break + if not isinstance(raw_item, Mapping): + continue + name = safe_label(raw_item.get("name")) + if name is None: + continue + entry = {name_key: name} + if type_key is not None: + style_type = safe_label(raw_item.get(type_key)) + if style_type is not None: + entry["style_type"] = style_type + items.append(entry) + return items + + def outline_node(node: object, remaining_depth: int) -> dict[str, Any] | None: - """Return a token-free id/name/type outline of one Figma node.""" + """Return a token-free implementable 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")) + node_id = safe_label(node.get("id")) + name = safe_label(node.get("name")) + node_type = safe_label(node.get("type")) if node_id is not None: summary["node_id"] = node_id if name is not None: summary["node_name"] = name if node_type is not None: summary["node_type"] = node_type + box = bounding_box(node.get("absoluteBoundingBox")) + if box is not None: + summary["absolute_bounding_box"] = box + fills = solid_fills(node.get("fills")) + if fills: + summary["solid_fills"] = fills + style = text_style(node.get("style")) + if style is not None: + summary["text_style"] = style + characters = bounded_text(node.get("characters")) + if characters is not None: + summary["characters"] = characters + metrics = layout_metrics(node) + if metrics: + summary["layout"] = metrics + constraints = constraint_axes(node.get("constraints")) + if constraints is not None: + summary["constraints"] = constraints if remaining_depth > 0: children = node.get("children") if isinstance(children, list): @@ -223,26 +441,40 @@ def outline_node(node: object, remaining_depth: int) -> dict[str, Any] | None: return summary or None +def allowed_image_host(host: str) -> bool: + """Return whether ``host`` is a Figma or Figma-S3 image origin.""" + lowered = host.lower().rstrip(".") + if lowered == "figma.com" or lowered.endswith(".figma.com"): + return True + return lowered.startswith("figma-") and lowered.endswith(".amazonaws.com") + + def https_image_url(value: object) -> str | None: - """Return a https image URL, refusing token-shaped or non-https values.""" + """Return a https Figma/S3 image URL, refusing token-shaped 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 + parsed = urlparse(cleaned) + if parsed.scheme != "https": + return None + if parsed.username is not None or parsed.password is not None: + return None + host = parsed.hostname or "" + if not allowed_image_host(host): + return None return cleaned def summarize_file_payload(payload: Mapping[str, Any], outline_depth: int) -> dict[str, Any]: """Return a compact, token-free file, node, or image summary.""" summary: dict[str, Any] = {} - file_name = 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")) + file_name = safe_label(payload.get("name")) + last_modified = safe_label(payload.get("lastModified")) + version = safe_label(payload.get("version")) + editor_type = safe_label(payload.get("editorType")) + role = safe_label(payload.get("role")) if file_name is not None: summary["file_name"] = file_name if last_modified is not None: @@ -253,14 +485,24 @@ def summarize_file_payload(payload: Mapping[str, Any], outline_depth: int) -> di summary["editor_type"] = editor_type if role is not None: summary["viewer_role"] = role + thumbnail = https_image_url(payload.get("thumbnailUrl")) + if thumbnail is not None: + summary["thumbnail_url"] = thumbnail document = outline_node(payload.get("document"), outline_depth) if document is not None: summary["document_outline"] = document + components = named_catalog(payload.get("components"), "component_name", None) + if components: + summary["component_names"] = components + styles = named_catalog(payload.get("styles"), "style_name", "styleType") + if styles: + summary["style_catalog"] = styles raw_nodes = payload.get("nodes") if isinstance(raw_nodes, Mapping): nodes: dict[str, Any] = {} for raw_id, raw_node in raw_nodes.items(): - node_id = validate_node_id(str(raw_id)) if NODE_ID_PATTERN.fullmatch(str(raw_id).replace("-", ":", 1)) else None + normalized = str(raw_id).replace("%3B", ";").replace("%3b", ";").replace("-", ":") + node_id = validate_node_id(normalized) if NODE_ID_PATTERN.fullmatch(normalized) else None if node_id is None: continue document_node = raw_node.get("document") if isinstance(raw_node, Mapping) else None @@ -340,31 +582,35 @@ def build_argument_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." + "Prints a token-free JSON outline with geometry, solid fills, " + "text, and auto-layout. Desktop/CLI Figma MCP remains the " + "get_design_context path." ), ) parser.add_argument( "locator", - help="Figma file key or https://www.figma.com/design//... URL", + help="Figma file or branch key, or https://www.figma.com/design//... URL", ) parser.add_argument( "--depth", type=int, default=DEFAULT_TREE_DEPTH, - help="Tree depth 1-8 (default 2: pages and top-level frames)", + help=( + "Tree depth 1-8. GET /v1/files uses pages + top-level frames at 2; " + "GET /v1/files/.../nodes counts levels under the selected node" + ), ) parser.add_argument( "--node-id", action="append", default=[], dest="node_ids", - help="Figma node id (12:34 or URL form 12-34). Repeatable.", + help="Figma node id (12:34, I12:34;56:78, or URL hyphen form). Repeatable.", ) parser.add_argument( "--images", action="store_true", - help="Render --node-id frames as PNG URLs instead of JSON outline", + help="Render --node-id frames as expiring HTTPS PNG URLs instead of JSON outline", ) return parser diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 673743456..a79773c86 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_read_access_token_rejects_embedded_control_characters() -> None: + """A multiline secret cannot reach ``X-Figma-Token`` (CWE-113).""" + with pytest.raises(auth.FigmaAuthError) as control: + auth.read_access_token({auth.TOKEN_ENV_NAME: f"{TOKEN}\r\ninjected"}) + assert control.value.exit_code == auth.EXIT_MISSING_TOKEN + assert "control" in str(control.value) + assert TOKEN not in str(control.value) + + def test_identity_field_normalizes_scalar_values() -> None: """Booleans and non-text values never become identity tokens.""" assert auth.identity_field(True) is None @@ -265,6 +274,11 @@ def test_sanitize_request_headers_allows_only_figma_token() -> None: with pytest.raises(auth.FigmaAuthError) as blank: auth.sanitize_request_headers({auth.TOKEN_HEADER: " "}) assert blank.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as control: + auth.sanitize_request_headers({auth.TOKEN_HEADER: f"{TOKEN}\r\nHost: evil"}) + assert control.value.exit_code == auth.EXIT_TRANSPORT + assert "control" in str(control.value) + assert TOKEN not in str(control.value) def test_read_bounded_body_rejects_oversize_and_nonpositive_limits() -> None: diff --git a/tests/test_figma_rest_file.py b/tests/test_figma_rest_file.py index 4b18c8a7a..94dea76a0 100644 --- a/tests/test_figma_rest_file.py +++ b/tests/test_figma_rest_file.py @@ -43,6 +43,9 @@ def test_validate_node_id_accepts_url_hyphen_form() -> None: """Figma share URLs use 12-34; the REST API uses 12:34.""" assert files.validate_node_id("12-34") == "12:34" assert files.validate_node_id("12:34") == "12:34" + assert files.validate_node_id("I12-34;56-78") == "I12:34;56:78" + assert files.validate_node_id("I12:34%3B56:78") == "I12:34;56:78" + assert files.validate_node_id("I12:34%3b56:78") == "I12:34;56:78" with pytest.raises(auth.FigmaAuthError) as invalid: files.validate_node_id("root") assert invalid.value.exit_code == files.EXIT_INVALID_TARGET @@ -66,6 +69,8 @@ def test_parse_file_locator_reads_design_url() -> None: "https://www.figma.com/onlykey", "https://www.figma.com/unknown/TestFileKey0123456789/x", "www.figma.com/design/nope/x", + "https://www.figma.com@evil.example/design/TestFileKey0123456789/x", + f"https://user:pass@www.figma.com/design/{FILE_KEY}/x", ], ) def test_parse_file_locator_refuses_unsafe_or_incomplete_urls(locator: str) -> None: @@ -82,11 +87,34 @@ def test_parse_file_locator_accepts_host_without_scheme() -> None: assert node_ids == [] +def test_parse_file_locator_uses_branch_key_not_main_file() -> None: + """A branch URL must GET the branch key, not silently outline main.""" + branch_key = "BranchKey901234567890" + key, node_ids = files.parse_file_locator( + f"https://www.figma.com/design/{FILE_KEY}/branch/{branch_key}/Checkout" + ) + assert key == branch_key + assert node_ids == [] + named = files.parse_file_locator( + f"https://figma.com/design/{FILE_KEY}/Checkout/branch/{branch_key}/Alt" + ) + assert named == (branch_key, []) + + def test_unique_node_ids_preserve_first_seen_order() -> None: """Repeated node ids from a URL plus --node-id stay unique.""" assert files.unique_node_ids(["12-34", "12:34", "1:2"]) == ["12:34", "1:2"] +def test_unique_node_ids_reject_unbounded_lists() -> None: + """A huge --node-id list cannot build an unbounded images query.""" + too_many = [f"1:{index}" for index in range(files.MAX_NODE_IDS + 1)] + with pytest.raises(auth.FigmaAuthError) as bounded: + files.unique_node_ids(too_many) + assert bounded.value.exit_code == files.EXIT_INVALID_TARGET + assert str(files.MAX_NODE_IDS) in str(bounded.value) + + def test_build_request_path_emits_only_allowlisted_shapes() -> None: """File, node, and image paths stay inside the opener allowlist.""" file_path = files.build_request_path(FILE_KEY) @@ -98,6 +126,9 @@ def test_build_request_path_emits_only_allowlisted_shapes() -> None: image_path = files.build_request_path(FILE_KEY, node_ids=["12:34"], images=True) assert image_path == f"/v1/images/{FILE_KEY}?ids=12:34&format=png" assert files.ALLOWED_REQUEST_PATH.fullmatch(image_path) + instance_path = files.build_request_path(FILE_KEY, node_ids=["I12:34;56:78"], images=True) + assert instance_path == f"/v1/images/{FILE_KEY}?ids=I12:34%3B56:78&format=png" + assert files.ALLOWED_REQUEST_PATH.fullmatch(instance_path) def test_build_request_path_rejects_images_without_nodes_and_bad_depth() -> None: @@ -269,11 +300,50 @@ def test_outline_and_image_summary_stay_token_free() -> None: assert outline["node_id"] == "0:0" assert outline["child_nodes"][0]["node_name"] == "Page 1" assert "child_nodes" not in outline["child_nodes"][0] + designed = files.outline_node( + { + "id": "I12:34;56:78", + "name": "Hero", + "type": "FRAME", + "absoluteBoundingBox": {"x": 0, "y": 8, "width": 360, "height": 80}, + "fills": [ + {"type": "SOLID", "color": {"r": 0.1, "g": 0.2, "b": 0.3, "a": 1}, "opacity": 0.9}, + {"type": "IMAGE"}, + "skip", + ], + "style": { + "fontFamily": "Inter", + "fontWeight": 600, + "fontSize": 16, + "textAlignHorizontal": "CENTER", + "letterSpacing": 0.2, + "lineHeightPx": 24, + }, + "characters": "Pay now", + "layoutMode": "HORIZONTAL", + "primaryAxisAlignItems": "CENTER", + "counterAxisAlignItems": "CENTER", + "paddingLeft": 16, + "constraints": {"horizontal": "SCALE", "vertical": "TOP"}, + }, + 0, + ) + assert designed is not None + assert designed["node_id"] == "I12:34;56:78" + assert designed["absolute_bounding_box"]["width"] == 360 + assert designed["solid_fills"][0]["color"]["r"] == 0.1 + assert designed["text_style"]["font_family"] == "Inter" + assert designed["characters"] == "Pay now" + assert designed["layout"]["layout_mode"] == "HORIZONTAL" + assert designed["constraints"]["horizontal"] == "SCALE" assert files.https_image_url(None) is None assert files.https_image_url("http://insecure.example/x") is None assert files.https_image_url(f"https://x/{TOKEN}") is None assert files.https_image_url(f"https://x/{auth.TOKEN_ENV_NAME}") is None + assert files.https_image_url("https://evil.example/x.png") is None + assert files.https_image_url("https://user:pass@figma-alpha-api.s3.amazonaws.com/x.png") is None assert files.https_image_url("https://figma-alpha-api.s3.amazonaws.com/x.png") + assert files.https_image_url("https://s3-alpha-sig.figma.com/img/x") def test_summarize_file_payload_covers_file_nodes_and_images() -> None: @@ -287,9 +357,13 @@ def test_summarize_file_payload_covers_file_nodes_and_images() -> None: "version": "9", "editorType": "figma", "role": "viewer", + "thumbnailUrl": "https://figma-alpha-api.s3.amazonaws.com/thumb.png", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + "components": {"1:9": {"name": "Button"}, "bad": "skip", "1:8": {"name": None}}, + "styles": {"S:1": {"name": "Ink", "styleType": "FILL"}, "bad": []}, "nodes": { "12:34": {"document": {"id": "12:34", "name": "Hero", "type": "FRAME"}}, + "I12:34;56:78": {"document": {"id": "I12:34;56:78", "name": "Instance"}}, "skip": {"document": {"id": "9:9"}}, "1:2": "not-a-map", }, @@ -305,8 +379,12 @@ def test_summarize_file_payload_covers_file_nodes_and_images() -> None: assert summary["file_version"] == "9" assert summary["editor_type"] == "figma" assert summary["viewer_role"] == "viewer" + assert summary["thumbnail_url"].startswith("https://") + assert summary["component_names"][0]["component_name"] == "Button" + assert summary["style_catalog"][0]["style_name"] == "Ink" assert summary["document_outline"]["node_type"] == "DOCUMENT" assert summary["selected_nodes"]["12:34"]["node_name"] == "Hero" + assert summary["selected_nodes"]["I12:34;56:78"]["node_name"] == "Instance" assert "skip" not in summary["selected_nodes"] assert summary["image_urls"]["12:34"].startswith("https://") filtered = files.summarize_file_payload( @@ -436,7 +514,7 @@ def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: ) 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 json.loads(captured.out)["file_name"] == "Home" assert TOKEN not in captured.out @@ -491,6 +569,8 @@ def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None: assert "Retrieved August 16, 2026" in doctoring assert "file-endpoints" in doctoring assert "CWE-22" in doctoring + assert "CWE-918" in doctoring + assert "get_design_context" in doctoring assert "plan access token" in doctoring assert "X-Figma-Token" in changelog assert "scripts/ci/figma_rest_file.py" in changelog @@ -498,3 +578,59 @@ def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None: assert "FIGMA_ACCESS_TOKEN" in claude assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master + + +def test_design_field_helpers_stay_bounded_and_token_free() -> None: + """Geometry, fill, type, and catalog helpers refuse junk and tokens.""" + assert files.safe_label(f"keep {TOKEN}") is None + assert files.safe_label(auth.TOKEN_ENV_NAME) is None + assert files.finite_number(True) is None + assert files.finite_number("8") is None + assert files.finite_number(float("nan")) is None + assert files.finite_number(float("inf")) is None + assert files.finite_number(files.MAX_LAYOUT_ABS + 1) is None + assert files.finite_number(12) == 12.0 + assert files.bounding_box("nope") is None + assert files.bounding_box({"x": True}) is None + assert files.solid_fills("nope") == [] + assert files.solid_fills([{"type": "SOLID", "color": {"r": True}}]) == [] + assert files.solid_fills([{"type": "SOLID", "color": "nope"}]) == [] + overflow = [{"type": "SOLID", "color": {"r": 1}} for _ in range(files.MAX_SOLID_FILLS + 2)] + assert len(files.solid_fills(overflow)) == files.MAX_SOLID_FILLS + assert files.text_style("nope") is None + assert files.text_style({}) is None + assert files.constraint_axes("nope") is None + assert files.constraint_axes({}) is None + assert files.bounded_text(" ") is None + long_text = "a" * (files.MAX_TEXT_CHARS + 8) + assert files.bounded_text(long_text) == "a" * files.MAX_TEXT_CHARS + assert files.named_catalog("nope", "component_name", None) == [] + assert files.named_catalog({"1": {"name": "Ink"}}, "style_name", "styleType") == [ + {"style_name": "Ink"} + ] + catalog = {str(index): {"name": f"C{index}"} for index in range(files.MAX_CATALOG_ITEMS + 3)} + assert len(files.named_catalog(catalog, "component_name", None)) == files.MAX_CATALOG_ITEMS + assert files.allowed_image_host("figma.com") is True + assert files.allowed_image_host("evil.amazonaws.com") is False + assert files.encode_node_id_query("I1:2;3:4") == "I1:2%3B3:4" + metrics = files.layout_metrics( + { + "paddingRight": 1, + "paddingTop": 2, + "paddingBottom": 3, + "itemSpacing": 4, + "cornerRadius": 5, + "opacity": 0.5, + "strokeWeight": 1, + } + ) + assert metrics["padding_right"] == 1 + assert metrics["stroke_weight"] == 1 + + +def test_live_unauthenticated_file_read_is_rejected_by_figma() -> None: + """The real files endpoint rejects a missing token with HTTP 401/403/404.""" + status, body = files.default_file_opener(files.build_request_path(FILE_KEY), {}) + assert status in {401, 403, 404} + decoded = body.decode("utf-8", errors="replace") + assert TOKEN not in decoded