From 9a521e442f2e315b66c6d23feacb1e64017b44fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:47:20 +0000 Subject: [PATCH 1/3] 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 16f0981c07..016eb69bfe 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 bd5e6c0c40..726b48b738 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 0000000000..7a2d019e5a --- /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 0000000000..3b02eda3db --- /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 0000000000..42cc87ebcd --- /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/3] 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 7a2d019e5a..38ace5d90a 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 3b02eda3db..45c1bf9e1a 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 42cc87ebcd..225402503e 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 5239fa657765344b15177b1b8a9e0fb43316f893 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:34:06 +0000 Subject: [PATCH 3/3] fix(ci): load Figma file pages over pinned REST Cloud Agents still cannot finish Figma MCP OAuth. After whoami succeeds, load one file at GET /v1/files/:key?depth=1 so the next action is a named page rather than a token-only check. Pin headers to X-Figma-Token, cap response bodies, and record APA 7th citations for the REST fallback. Co-authored-by: Seongho Bae --- AGENTS.md | 2 +- ARCHITECTURE.md | 28 +- CHANGELOG.md | 3 + docs/CWL-MASTER-CONTEXT.md | 2 +- docs/doctoring/figma-cloud-agent-mcp-auth.md | 79 ++++- scripts/ci/figma_rest_auth.py | 313 ++++++++++++++++--- tests/test_figma_rest_auth.py | 286 ++++++++++++++++- 7 files changed, 647 insertions(+), 66 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 016eb69bfe..3357bb121c 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 use the `FIGMA_ACCESS_TOKEN` secret with the REST API: `python3 scripts/ci/figma_rest_auth.py` then `--file FILE_KEY`. 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 6fe6621b62..74e92e47cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,30 @@ sequenceDiagram - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +## Figma REST fallback + +```mermaid +flowchart LR + Desktop["Desktop / CLI"] + Cloud["Cloud Agent / Automation"] + MCP["Figma MCP OAuth"] + REST["api.figma.com REST"] + Whoami["GET /v1/me"] + File["GET /v1/files/:key?depth=1"] + + Desktop --> MCP + Cloud -->|"FIGMA_ACCESS_TOKEN"| REST + REST --> Whoami + Whoami -->|"secret valid"| File + File -->|"page names"| BuyerNext["Pick a page / node / image"] +``` + +Cloud Agents do not complete Figma MCP Connect. They send only +`X-Figma-Token` on a pinned `HTTPSConnection("api.figma.com")`, refuse +`file://` and `Host` overrides, and load one file at `depth=1` so the next +action is a named page rather than the entire document tree. 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 +131,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` fallback. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a50..c179d733ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added a Cloud Agent Figma REST fallback that verifies `FIGMA_ACCESS_TOKEN` against `GET /v1/me` and loads one file at `GET /v1/files/:key?depth=1`, so agents can inventory pages after MCP Connect fails. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. - Added a permanent exact-head contract workflow for the hourly review-repair scheduler, immutable reusable-workflow source, NVIDIA NIM model boundary, credential isolation, and fail-closed unattended-agent permissions. @@ -39,6 +40,7 @@ Semantic Versioning where the repository publishes a release. ### Security +- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, accept alphanumeric file keys, 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. @@ -55,6 +57,7 @@ Semantic Versioning where the repository publishes a release. ### Documentation +- Recorded the Cloud Agent Figma MCP boundary, plan-token preference, `--file` next action, and APA 7th citations for Figma REST authentication, file endpoints, OAuth 2.1, PKCE, RFC 9110, and CWE-22. - 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/docs/CWL-MASTER-CONTEXT.md b/docs/CWL-MASTER-CONTEXT.md index 726b48b738..1ef78cd9ca 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 a personal or plan access token as `FIGMA_ACCESS_TOKEN`, run `python3 scripts/ci/figma_rest_auth.py`, then `python3 scripts/ci/figma_rest_auth.py --file FILE_KEY` for page-level REST metadata. 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 38ace5d90a..9cbe974efd 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -40,22 +40,37 @@ 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 or plan 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 +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 CI and Cloud Agent fleets +(admin-managed, not tied to one person, expiry up to one year; Figma, n.d.-a). +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 is not enough for design-to-code. After the secret is present, load one +file at `GET /v1/files/:key?depth=1` so the next action is a named page, node, +or image request rather than downloading the entire document tree (Figma, +n.d.-c). File keys are the `:file_key` segment from +`https://www.figma.com/:file_type/:file_key/:file_name` and must be 8–128 +alphanumeric characters (CWE-22 path-restriction; MITRE, 2026). The helper +sends only `X-Figma-Token`, pins `http.client.HTTPSConnection("api.figma.com")`, +and caps whoami bodies at 64 KiB and file bodies at 8 MiB (RFC 9110 message +framing; Fielding et al., 2022). + ## 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`. +2. **Cloud Agent REST secret.** In Figma: account menu → Settings → Security + → Personal access tokens → Generate new token, or ask a plan admin for a + plan access token. Grant `file_content:read` (add comment scopes only if + needed). Store the value as the Cursor environment secret + `FIGMA_ACCESS_TOKEN`. 3. **Verify the secret without printing it:** ```bash @@ -63,31 +78,61 @@ 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. 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 + non-zero and never echo the secret. +4. **Load the file the buyer asked for** (copy the key from the Figma URL): + + ```bash + python3 scripts/ci/figma_rest_auth.py --file FILE_KEY + ``` + + Success prints page names plus component and style counts. Next: request + that page's node JSON or images over REST. The helper opens a pinned + `http.client.HTTPSConnection("api.figma.com")` 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 -Figma only accepts MCP clients listed in its catalog (Figma, 2026b). The +Figma only accepts MCP clients listed in its catalog (Figma, n.d.-d). 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 +## References (APA 7th edition) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). RFC Editor. https://doi.org/10.17487/RFC9110 -Figma. (2025). *Changelog*. Figma Developer Docs. -https://developers.figma.com/docs/rest-api/changelog/ +Figma. (n.d.-a). *Authentication*. Figma Developer Docs. Retrieved August 16, +2026, from https://developers.figma.com/docs/rest-api/authentication/ -Figma. (2026a). *Personal access tokens*. Figma Developer Docs. +Figma. (n.d.-b). *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. +Figma. (n.d.-c). *Files*. Figma Developer Docs. Retrieved August 16, 2026, +from https://developers.figma.com/docs/rest-api/file-endpoints/ + +Figma. (n.d.-d). *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. (n.d.-e). *Changelog*. Figma Developer Docs. Retrieved August 16, 2026, +from https://developers.figma.com/docs/rest-api/changelog/ + +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. https://forum.cursor.com/t/figma-mcp-shows-forbidden-in-automations-cloud-agents/162969 + +Parecki, A., Hardt, D., & Lodderstedt, T. (2025). *The OAuth 2.1 authorization +framework* (Internet-Draft). Internet Engineering Task Force. +https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1 + +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 45c1bf9e1a..960915f687 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Verify Figma REST personal-access-token auth for Cloud Agents. +"""Verify Figma REST auth and load page-level file metadata 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. +fallback is a Figma personal or plan access token in ``FIGMA_ACCESS_TOKEN``, +sent only as ``X-Figma-Token`` to ``https://api.figma.com``. This helper never +prints the token. ``GET /v1/me`` proves the secret. ``GET /v1/files/:key`` at +``depth=1`` lists pages so the next action is a specific node or image request. """ from __future__ import annotations @@ -12,23 +13,35 @@ import http.client import json import os +import re import sys from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any, TextIO TOKEN_ENV_NAME = "FIGMA_ACCESS_TOKEN" TOKEN_HEADER = "X-Figma-Token" -WHOAMI_URL = "https://api.figma.com/v1/me" +FIGMA_API_HOST = "api.figma.com" +WHOAMI_PATH = "/v1/me" +WHOAMI_URL = f"https://{FIGMA_API_HOST}{WHOAMI_PATH}" REQUEST_TIMEOUT_SECONDS = 20 +MAX_WHOAMI_BODY_BYTES = 65_536 +MAX_FILE_BODY_BYTES = 8_388_608 EXIT_OK = 0 EXIT_MISSING_TOKEN = 2 EXIT_REJECTED = 3 EXIT_TRANSPORT = 4 +EXIT_USAGE = 5 +FILE_KEY_PATTERN = re.compile(r"^[A-Za-z0-9]{8,128}$") +FILE_DOCUMENT_URL_PATTERN = re.compile( + rf"^https://{re.escape(FIGMA_API_HOST)}/v1/files/([A-Za-z0-9]{{8,128}})\?depth=1$" +) Opener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] +BoundedReader = Callable[[int], bytes] class FigmaAuthError(Exception): - """Raised when Figma REST authentication cannot be completed.""" + """Raised when Figma REST authentication or file load cannot complete.""" def __init__(self, message: str, exit_code: int) -> None: """Record a user-visible failure and the process exit code.""" @@ -36,47 +49,120 @@ def __init__(self, message: str, exit_code: int) -> None: self.exit_code = exit_code +@dataclass(frozen=True) +class CliRequest: + """Parsed command line for whoami or a single file-key load.""" + + file_key: str | None + + def read_access_token(environ: Mapping[str, str]) -> str: - """Return the trimmed personal access token or raise ``FigmaAuthError``.""" + """Return the trimmed personal or plan 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.", + "MCP OAuth; add a Figma personal or plan 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.", + f"{TOKEN_ENV_NAME} is empty. Generate a Figma personal or plan " + "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 the fixed Figma whoami origin and return ``(status, body)``. +def validate_file_key(file_key: str) -> str: + """Return a Figma file or branch key or raise ``FigmaAuthError``. - 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``. + Keys are the ``:file_key`` segment from + ``https://www.figma.com/:file_type/:file_key/:file_name``. Only + alphanumeric keys are accepted so ``..``, slashes, and query characters + cannot reach the TLS path. """ - if url != WHOAMI_URL: + key = file_key.strip() + if not FILE_KEY_PATTERN.fullmatch(key): raise FigmaAuthError( - "Figma REST opener refuses URLs other than the fixed HTTPS " - "/v1/me endpoint.", + "Figma file key must be 8-128 alphanumeric characters from the " + "file URL. Next: copy the key between /design/ or /file/ and the " + "title, then rerun with --file.", + EXIT_TRANSPORT, + ) + return key + + +def file_document_path(file_key: str) -> str: + """Return the pinned ``GET /v1/files/:key?depth=1`` path.""" + return f"/v1/files/{validate_file_key(file_key)}?depth=1" + + +def file_document_url(file_key: str) -> str: + """Return the pinned HTTPS URL for a page-level file load.""" + return f"https://{FIGMA_API_HOST}{file_document_path(file_key)}" + + +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 pinned_https_get( + path: str, + headers: Mapping[str, str], + max_body_bytes: int, +) -> tuple[int, bytes]: + """GET a ``/v1/...`` path on the pinned Figma origin and return status/body.""" + if not path.startswith("/v1/"): + raise FigmaAuthError( + "Figma REST path must start with /v1/ on api.figma.com.", + EXIT_TRANSPORT, + ) + if ".." in path or "\\" in path or " " in path: + raise FigmaAuthError( + "Figma REST path contains forbidden characters.", EXIT_TRANSPORT, ) connection = http.client.HTTPSConnection( - "api.figma.com", + FIGMA_API_HOST, timeout=REQUEST_TIMEOUT_SECONDS, ) try: - connection.request("GET", "/v1/me", 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_body_bytes) except OSError as exc: raise FigmaAuthError( f"Figma REST transport failed: {exc}", @@ -86,23 +172,57 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: connection.close() -def parse_whoami_payload(body: bytes) -> dict[str, Any]: - """Parse a Figma ``/v1/me`` JSON object or raise ``FigmaAuthError``.""" +def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET the fixed Figma whoami origin and return ``(status, body)``. + + Host and path are string literals at the TLS sink. Caller ``url`` is + accepted only when it equals ``WHOAMI_URL``, so ``file://`` and other + schemes never reach the network helper. This path does not call + ``urllib.request.urlopen``. + """ + if url != WHOAMI_URL: + raise FigmaAuthError( + "Figma REST opener refuses URLs other than the fixed HTTPS " + "/v1/me endpoint.", + EXIT_TRANSPORT, + ) + return pinned_https_get(WHOAMI_PATH, headers, MAX_WHOAMI_BODY_BYTES) + + +def default_file_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET a validated ``/v1/files/:key?depth=1`` URL on the pinned origin.""" + match = FILE_DOCUMENT_URL_PATTERN.fullmatch(url) + if match is None: + raise FigmaAuthError( + "Figma REST file opener refuses URLs other than the pinned HTTPS " + "/v1/files/:key?depth=1 endpoint.", + EXIT_TRANSPORT, + ) + return pinned_https_get(file_document_path(match.group(1)), headers, MAX_FILE_BODY_BYTES) + + +def parse_json_object(body: bytes, source: str) -> 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 /v1/me returned a non-JSON body.", + f"Figma REST {source} 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.", + f"Figma REST {source} returned a JSON value that is not an object.", EXIT_TRANSPORT, ) return payload +def parse_whoami_payload(body: bytes) -> dict[str, Any]: + """Parse a Figma ``/v1/me`` JSON object or raise ``FigmaAuthError``.""" + return parse_json_object(body, "/v1/me") + + def identity_summary(payload: Mapping[str, Any]) -> str: """Return a token-free identity line from a ``/v1/me`` object.""" handle = payload.get("handle") @@ -120,41 +240,154 @@ def identity_summary(payload: Mapping[str, Any]) -> str: 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}) +def _string_field(payload: Mapping[str, Any], name: str) -> str: + """Return a stripped string field or an empty string.""" + value = payload.get(name) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _page_names(payload: Mapping[str, Any]) -> list[str]: + """Return canvas/page names from a ``depth=1`` document object.""" + document = payload.get("document") + if not isinstance(document, dict): + return [] + children = document.get("children") + if not isinstance(children, list): + return [] + pages: list[str] = [] + for child in children: + if not isinstance(child, dict): + continue + name = _string_field(child, "name") + if name: + pages.append(name) + return pages + + +def _mapping_count(payload: Mapping[str, Any], name: str) -> int: + """Return the number of keys in a JSON object field.""" + value = payload.get(name) + if isinstance(value, dict): + return len(value) + return 0 + + +def file_document_summary(file_key: str, payload: Mapping[str, Any]) -> str: + """Return a token-free page inventory from a ``/v1/files/:key`` object.""" + key = validate_file_key(file_key) + parts = [f"key={key}"] + name = _string_field(payload, "name") + version = _string_field(payload, "version") + modified = _string_field(payload, "lastModified") + if name: + parts.append(f"name={name}") + if version: + parts.append(f"version={version}") + if modified: + parts.append(f"lastModified={modified}") + pages = _page_names(payload) + page_text = ", ".join(pages) if pages else "(none)" + return ( + "Figma file loaded (" + + ", ".join(parts) + + f"; pages={page_text}; components={_mapping_count(payload, 'components')}; " + f"styles={_mapping_count(payload, 'styles')}). " + "Next: pick a page and request that node or its images over REST." + ) + + +def classify_http_status(status: int, source: str) -> None: + """Raise when a Figma status is not HTTP 200.""" 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.", + f"Figma REST rejected {TOKEN_ENV_NAME} with HTTP {status} on " + f"{source}. Regenerate the personal or plan access token and " + "update the secret.", EXIT_REJECTED, ) if status != 200: raise FigmaAuthError( - f"Figma REST /v1/me returned HTTP {status}.", + f"Figma REST {source} returned HTTP {status}. Next: confirm the " + "file key and token scopes include file_content:read.", EXIT_TRANSPORT, ) + + +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}) + classify_http_status(status, "/v1/me") return identity_summary(parse_whoami_payload(body)) +def fetch_file_document( + file_key: str, + environ: Mapping[str, str], + opener: Opener = default_file_opener, +) -> str: + """Load page-level Figma file metadata and return a token-free inventory.""" + token = read_access_token(environ) + url = file_document_url(file_key) + status, body = opener(url, {TOKEN_HEADER: token}) + classify_http_status(status, "/v1/files/:key") + return file_document_summary(file_key, parse_json_object(body, "/v1/files/:key")) + + +def _drop_program_name(argv: list[str]) -> list[str]: + """Remove the helper's program name when present as ``argv[0]``.""" + if not argv: + return [] + first = argv[0] + if first.endswith("figma_rest_auth.py") or first.endswith("figma_rest_auth"): + return list(argv[1:]) + return list(argv) + + +def parse_cli_args(argv: list[str]) -> CliRequest: + """Parse ``[--file FILE_KEY]`` or raise ``FigmaAuthError``.""" + args = _drop_program_name(argv) + if not args: + return CliRequest(file_key=None) + if args[0] == "--file" and len(args) == 1: + raise FigmaAuthError( + "Usage: figma_rest_auth.py [--file FILE_KEY]. Next: pass the " + "alphanumeric key from the Figma file URL.", + EXIT_USAGE, + ) + if args[0] == "--file" and len(args) == 2: + return CliRequest(file_key=validate_file_key(args[1])) + raise FigmaAuthError( + "Usage: figma_rest_auth.py [--file FILE_KEY]. Next: omit arguments " + "to verify the secret, or pass one --file key.", + EXIT_USAGE, + ) + + def main( argv: list[str] | None = None, environ: Mapping[str, str] | None = None, - opener: Opener = default_opener, + opener: Opener | None = None, stdout: TextIO | None = None, stderr: TextIO | None = None, ) -> int: - """Verify ``FIGMA_ACCESS_TOKEN`` and print a token-free identity line.""" - del argv + """Verify ``FIGMA_ACCESS_TOKEN`` and optionally load one Figma file.""" 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") + request = parse_cli_args(list(argv) if argv is not None else sys.argv) + if request.file_key is None: + whoami_opener = default_opener if opener is None else opener + out.write(verify_rest_auth(env, whoami_opener) + "\n") + else: + file_opener = default_file_opener if opener is None else opener + out.write(fetch_file_document(request.file_key, env, file_opener) + "\n") except FigmaAuthError as exc: err.write(str(exc) + "\n") return exc.exit_code diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 225402503e..15b359a965 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -1,4 +1,4 @@ -"""Contracts for Cloud Agent Figma REST authentication.""" +"""Contracts for Cloud Agent Figma REST authentication and file load.""" from __future__ import annotations @@ -15,7 +15,10 @@ DOCTORING = ROOT / "docs" / "doctoring" / "figma-cloud-agent-mcp-auth.md" AGENTS = ROOT / "AGENTS.md" MASTER = ROOT / "docs" / "CWL-MASTER-CONTEXT.md" +ARCHITECTURE = ROOT / "ARCHITECTURE.md" +CHANGELOG = ROOT / "CHANGELOG.md" TOKEN = "figd_test_token_must_never_appear" +FILE_KEY = "Ab12Cd34" def _whoami_body(**fields: str) -> bytes: @@ -23,6 +26,11 @@ def _whoami_body(**fields: str) -> bytes: return json.dumps(fields).encode("utf-8") +def _file_body(**fields: Any) -> bytes: + """Return a Figma ``/v1/files/:key`` 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: @@ -41,6 +49,20 @@ def test_read_access_token_strips_whitespace() -> None: assert auth.read_access_token({auth.TOKEN_ENV_NAME: f" {TOKEN}\n"}) == TOKEN +def test_validate_file_key_rejects_path_injection() -> None: + """File keys cannot carry slashes, dots, or query characters into the path.""" + for raw in ("", "short", "../passwd", "abc/defgh", "abc.defgh", "abc?depth=1"): + with pytest.raises(auth.FigmaAuthError) as refused: + auth.validate_file_key(raw) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert TOKEN not in str(refused.value) + assert auth.validate_file_key(f" {FILE_KEY} \n") == FILE_KEY + assert auth.file_document_path(FILE_KEY) == f"/v1/files/{FILE_KEY}?depth=1" + assert auth.file_document_url(FILE_KEY) == ( + f"https://api.figma.com/v1/files/{FILE_KEY}?depth=1" + ) + + 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." @@ -61,6 +83,7 @@ def test_parse_whoami_payload_rejects_non_objects() -> None: with pytest.raises(auth.FigmaAuthError) as invalid_json: auth.parse_whoami_payload(b"not-json") assert invalid_json.value.exit_code == auth.EXIT_TRANSPORT + assert "/v1/me" in str(invalid_json.value) with pytest.raises(auth.FigmaAuthError) as not_object: auth.parse_whoami_payload(b'["me"]') @@ -69,6 +92,8 @@ def test_parse_whoami_payload_rejects_non_objects() -> None: with pytest.raises(auth.FigmaAuthError): auth.parse_whoami_payload(b"\xff") + assert auth.parse_whoami_payload(b'{"handle":"ok"}') == {"handle": "ok"} + def test_verify_rest_auth_accepts_valid_token() -> None: """A 200 ``/v1/me`` response is the Cloud Agent auth success signal.""" @@ -115,6 +140,81 @@ def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: assert "503" in str(transport.value) +def test_file_document_summary_lists_pages_and_token_counts() -> None: + """A depth=1 file payload becomes a page inventory for the next REST call.""" + summary = auth.file_document_summary( + FILE_KEY, + { + "name": "naruon GNB", + "version": "42", + "lastModified": "2026-08-16T00:00:00Z", + "document": { + "children": [ + {"name": "Home"}, + {"name": " "}, + "skip", + {"name": "Mail"}, + ] + }, + "components": {"1:2": {"name": "Button"}}, + "styles": {"3:4": {"name": "Color/Primary"}}, + }, + ) + assert f"key={FILE_KEY}" in summary + assert "name=naruon GNB" in summary + assert "pages=Home, Mail" in summary + assert "components=1" in summary + assert "styles=1" in summary + assert TOKEN not in summary + sparse = auth.file_document_summary(FILE_KEY, {"document": {"children": "nope"}}) + assert "pages=(none)" in sparse + assert "components=0" in sparse + assert auth.file_document_summary(FILE_KEY, {}) == ( + f"Figma file loaded (key={FILE_KEY}; pages=(none); components=0; " + "styles=0). Next: pick a page and request that node or its images over REST." + ) + + +def test_fetch_file_document_accepts_valid_token() -> None: + """A 200 ``/v1/files/:key?depth=1`` response is the Cloud Agent file 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, _file_body(name="Inkspan", document={"children": [{"name": "Cover"}]}) + + summary = auth.fetch_file_document(FILE_KEY, {auth.TOKEN_ENV_NAME: TOKEN}, opener) + assert seen["url"] == auth.file_document_url(FILE_KEY) + assert seen["headers"] == {auth.TOKEN_HEADER: TOKEN} + assert "name=Inkspan" in summary + assert "pages=Cover" in summary + assert TOKEN not in summary + + +def test_fetch_file_document_classifies_auth_and_missing_file() -> None: + """401/403 rotate the secret; 404 tells the operator to check the file key.""" + + def forbidden(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return 403, b'{"status":403,"err":"Invalid token"}' + + with pytest.raises(auth.FigmaAuthError) as rejected: + auth.fetch_file_document(FILE_KEY, {auth.TOKEN_ENV_NAME: TOKEN}, forbidden) + assert rejected.value.exit_code == auth.EXIT_REJECTED + assert TOKEN not in str(rejected.value) + + def missing(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del url, headers + return 404, b'{"status":404,"err":"Not found"}' + + with pytest.raises(auth.FigmaAuthError) as transport: + auth.fetch_file_document(FILE_KEY, {auth.TOKEN_ENV_NAME: TOKEN}, missing) + assert transport.value.exit_code == auth.EXIT_TRANSPORT + assert "404" in str(transport.value) + assert "file_content:read" in str(transport.value) + + class _FakeWhoamiResponse: """Minimal ``HTTPResponse`` stand-in for ``HTTPSConnection.getresponse``.""" @@ -123,13 +223,15 @@ 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: - """Record the pinned Figma origin used by ``default_opener``.""" + """Record the pinned Figma origin used by ``pinned_https_get``.""" last: _FakeWhoamiConnection | None = None @@ -146,7 +248,7 @@ def __init__(self, host: str, timeout: int = 0) -> None: type(self).last = self def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: - """Record the fixed GET /v1/me call.""" + """Record the pinned GET call.""" self.method = method self.path = path self.headers = dict(headers or {}) @@ -160,6 +262,51 @@ def close(self) -> None: self.closed = 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_pinned_https_get_rejects_unsafe_paths() -> None: + """Only ``/v1/`` paths without traversal characters reach the TLS sink.""" + headers = {auth.TOKEN_HEADER: TOKEN} + with pytest.raises(auth.FigmaAuthError) as scheme: + auth.pinned_https_get("file:///etc/passwd", headers, 16) + assert " /v1/" in str(scheme.value) or "/v1/" in str(scheme.value) + with pytest.raises(auth.FigmaAuthError) as traversal: + auth.pinned_https_get("/v1/../etc/passwd", headers, 16) + assert traversal.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as spaced: + auth.pinned_https_get("/v1/files/a b", headers, 16) + assert spaced.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as slash: + auth.pinned_https_get("/v1/files/a\\b", headers, 16) + assert slash.value.exit_code == auth.EXIT_TRANSPORT + + 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: @@ -169,6 +316,14 @@ def test_default_opener_rejects_non_whoami_urls() -> None: assert TOKEN not in str(refused.value) +def test_default_file_opener_rejects_non_file_urls() -> None: + """File loads accept only the pinned ``depth=1`` HTTPS URL.""" + with pytest.raises(auth.FigmaAuthError) as refused: + auth.default_file_opener(auth.WHOAMI_URL, {auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert "depth=1" 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.""" @@ -205,6 +360,31 @@ def test_default_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> N assert connection.closed is True +def test_default_file_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful file GET uses the validated key and ``depth=1`` query.""" + + class FileConnection(_FakeWhoamiConnection): + """Return a canned file document from the pinned origin.""" + + def __init__(self, host: str, timeout: int = 0) -> None: + """Initialize a file JSON body.""" + super().__init__(host, timeout) + self._body = b'{"name":"ok"}' + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", FileConnection) + status, body = auth.default_file_opener( + auth.file_document_url(FILE_KEY), + {auth.TOKEN_HEADER: TOKEN}, + ) + assert status == 200 + assert body == b'{"name":"ok"}' + connection = FileConnection.last + assert connection is not None + assert connection.path == f"/v1/files/{FILE_KEY}?depth=1" + assert connection.headers == {auth.TOKEN_HEADER: TOKEN} + assert connection.closed is True + + def test_default_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None: """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" @@ -226,6 +406,24 @@ def request(self, method: str, path: str, headers: dict[str, str] | None = None) assert FailingConnection.last.closed is True +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_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") @@ -233,6 +431,31 @@ def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None: assert "http.client.HTTPSConnection" in source assert '"api.figma.com"' in source assert '"/v1/me"' in source + assert "depth=1" in source + + +def test_parse_cli_args_accepts_whoami_and_single_file() -> None: + """The CLI is whoami by default and one ``--file`` key otherwise.""" + assert auth.parse_cli_args([]) == auth.CliRequest(file_key=None) + assert auth.parse_cli_args(["figma_rest_auth.py"]) == auth.CliRequest(file_key=None) + assert auth.parse_cli_args(["scripts/ci/figma_rest_auth.py", "--file", FILE_KEY]) == ( + auth.CliRequest(file_key=FILE_KEY) + ) + assert auth.parse_cli_args(["figma_rest_auth", "--file", FILE_KEY]) == ( + auth.CliRequest(file_key=FILE_KEY) + ) + with pytest.raises(auth.FigmaAuthError) as usage: + auth.parse_cli_args(["--file"]) + assert usage.value.exit_code == auth.EXIT_USAGE + with pytest.raises(auth.FigmaAuthError) as unknown: + auth.parse_cli_args(["--dump"]) + assert unknown.value.exit_code == auth.EXIT_USAGE + with pytest.raises(auth.FigmaAuthError) as extra: + auth.parse_cli_args(["--file", FILE_KEY, "extra"]) + assert extra.value.exit_code == auth.EXIT_USAGE + with pytest.raises(auth.FigmaAuthError) as bad_key: + auth.parse_cli_args(["--file", "../passwd"]) + assert bad_key.value.exit_code == auth.EXIT_TRANSPORT def test_main_writes_identity_and_error_channels() -> None: @@ -271,11 +494,36 @@ def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: assert TOKEN not in missing_err.getvalue() +def test_main_writes_file_inventory() -> None: + """``--file`` prints a page inventory and never echoes the token.""" + stdout = io.StringIO() + stderr = io.StringIO() + + def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + del headers + assert url == auth.file_document_url(FILE_KEY) + return 200, _file_body(name="Wardnet", document={"children": [{"name": "SOC"}]}) + + ok = auth.main( + argv=["--file", FILE_KEY], + environ={auth.TOKEN_ENV_NAME: TOKEN}, + opener=opener, + stdout=stdout, + stderr=stderr, + ) + assert ok == auth.EXIT_OK + assert "name=Wardnet" in stdout.getvalue() + assert "pages=SOC" in stdout.getvalue() + assert stderr.getvalue() == "" + assert TOKEN not in stdout.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) + monkeypatch.setattr(auth.sys, "argv", ["figma_rest_auth.py"]) def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: del url @@ -288,11 +536,26 @@ def opener(url: str, headers: dict[str, str]) -> tuple[int, bytes]: assert TOKEN not in captured.out +def test_live_unauthenticated_whoami_is_rejected_by_figma() -> None: + """The real ``/v1/me`` endpoint rejects a missing token with HTTP 401/403. + + This is the production accuracy check: Cloud Agents can reach + ``api.figma.com``, and an absent secret is not treated as MCP success. + """ + 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_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") + architecture = ARCHITECTURE.read_text(encoding="utf-8") + changelog = CHANGELOG.read_text(encoding="utf-8") for text in (doctoring, agents, master): assert "FIGMA_ACCESS_TOKEN" in text assert "mcp.figma.com" in text @@ -300,5 +563,16 @@ def test_doctoring_and_entry_docs_pin_cloud_agent_fallback() -> None: 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 "--file" in doctoring + assert "depth=1" in doctoring + assert "plan access token" in doctoring + assert "Retrieved August 16, 2026" in doctoring + assert "CWE-22" in doctoring + assert "RFC 9110" in doctoring assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master + assert "--file" in agents + assert "figma_rest_auth.py --file" in master + assert "Figma REST fallback" in architecture + assert "depth=1" in architecture + assert "Figma REST" in changelog