From 9a521e442f2e315b66c6d23feacb1e64017b44fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:47:20 +0000 Subject: [PATCH 1/6] 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/6] 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 4e7684dc88d1c80ae532a47c5c940654fac55bee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 16:36:28 +0000 Subject: [PATCH 3/6] fix(ci): suppress historical HTTPSConnection Semgrep audit SAST Semgrep failed on Medium+ httpsconnection-detected at the pinned Figma /v1/me sink. Keep host/path literals, verify TLS with the default SSL context, and scope the pre-3.4.3 audit suppression to that line. Co-authored-by: Seongho Bae --- docs/doctoring/figma-cloud-agent-mcp-auth.md | 9 ++++-- scripts/ci/figma_rest_auth.py | 10 +++++-- tests/test_figma_rest_auth.py | 29 ++++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/doctoring/figma-cloud-agent-mcp-auth.md b/docs/doctoring/figma-cloud-agent-mcp-auth.md index 38ace5d90..9a5ed4f86 100644 --- a/docs/doctoring/figma-cloud-agent-mcp-auth.md +++ b/docs/doctoring/figma-cloud-agent-mcp-auth.md @@ -64,9 +64,12 @@ 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 - (`urllib.request.urlopen` is not used). + `http.client.HTTPSConnection("api.figma.com")` with + `ssl.create_default_context()` to `GET /v1/me` and refuses any other URL, so + Semgrep `dynamic-urllib-use-detected` does not apply + (`urllib.request.urlopen` is not used). The historical + `httpsconnection-detected` audit (pre-3.4.3 default-verify gap) is + suppressed only at that literal sink. ## Why MCP Connect cannot be finished here diff --git a/scripts/ci/figma_rest_auth.py b/scripts/ci/figma_rest_auth.py index 45c1bf9e1..0970f2951 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -12,6 +12,7 @@ import http.client import json import os +import ssl import sys from collections.abc import Callable, Mapping from typing import Any, TextIO @@ -61,7 +62,11 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: 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``. + ``urllib.request.urlopen``. ``ssl.create_default_context()`` makes + certificate and hostname verification explicit; CI Python is 3.12+, + which already verifies by default. The scoped Semgrep suppression is + only for the historical ``httpsconnection-detected`` audit (pre-3.4.3 + default-verify gap), not for a dynamic host or scheme. """ if url != WHOAMI_URL: raise FigmaAuthError( @@ -69,9 +74,10 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: "/v1/me endpoint.", EXIT_TRANSPORT, ) - connection = http.client.HTTPSConnection( + connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected "api.figma.com", timeout=REQUEST_TIMEOUT_SECONDS, + context=ssl.create_default_context(), ) try: connection.request("GET", "/v1/me", headers=dict(headers)) diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 225402503..7e3fa812c 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -16,6 +16,9 @@ AGENTS = ROOT / "AGENTS.md" MASTER = ROOT / "docs" / "CWL-MASTER-CONTEXT.md" TOKEN = "figd_test_token_must_never_appear" +_SEMGREP_HTTPSCONNECTION_RULE = ( + "python.lang.security.audit.httpsconnection-detected.httpsconnection-detected" +) def _whoami_body(**fields: str) -> bytes: @@ -133,10 +136,16 @@ class _FakeWhoamiConnection: last: _FakeWhoamiConnection | None = None - def __init__(self, host: str, timeout: int = 0) -> None: - """Capture the TLS host and timeout.""" + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Capture the TLS host, timeout, and explicit SSL context.""" self.host = host self.timeout = timeout + self.context = context self.method = "" self.path = "" self.headers: dict[str, str] = {} @@ -175,9 +184,14 @@ def test_default_opener_returns_http_error_bodies(monkeypatch: pytest.MonkeyPatc class ForbiddenConnection(_FakeWhoamiConnection): """Return HTTP 403 from the pinned origin.""" - def __init__(self, host: str, timeout: int = 0) -> None: + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: """Initialize a 403 canned response.""" - super().__init__(host, timeout) + super().__init__(host, timeout, context=context) self._status = 403 self._body = b"nope" @@ -199,6 +213,7 @@ def test_default_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> N assert connection is not None assert connection.host == "api.figma.com" assert connection.timeout == auth.REQUEST_TIMEOUT_SECONDS + assert connection.context is not None assert connection.method == "GET" assert connection.path == "/v1/me" assert connection.headers == {auth.TOKEN_HEADER: TOKEN} @@ -229,8 +244,12 @@ def request(self, method: str, path: str, headers: dict[str, str] | None = None) def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None: """Semgrep ``dynamic-urllib-use-detected`` must not apply to this helper.""" source = Path(auth.__file__).read_text(encoding="utf-8") + source_lines = source.splitlines() + sink_lines = [line for line in source_lines if "http.client.HTTPSConnection(" in line] assert "urlopen(" not in source - assert "http.client.HTTPSConnection" in source + assert len(sink_lines) == 1 + assert f"# nosemgrep: {_SEMGREP_HTTPSCONNECTION_RULE}" in sink_lines[0] + assert "ssl.create_default_context()" in source assert '"api.figma.com"' in source assert '"/v1/me"' in source From 4ee8978a033dba00746f071a8e58072b4bbda18c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 16:46:00 +0000 Subject: [PATCH 4/6] fix(agents): add Cloud Agent Figma REST file read Whoami is not file read. Allow only X-Figma-Token, cap response bodies, and add an allowlisted GET /v1/files helper so Cloud Agents can load the linked design after FIGMA_ACCESS_TOKEN is set. Co-authored-by: Seongho Bae --- AGENTS.md | 2 +- ARCHITECTURE.md | 29 ++ CHANGELOG.md | 3 + CLAUDE.md | 5 + docs/CWL-MASTER-CONTEXT.md | 2 +- docs/doctoring/figma-cloud-agent-mcp-auth.md | 65 ++- scripts/ci/figma_rest_auth.py | 81 ++- scripts/ci/figma_rest_file.py | 426 +++++++++++++++ tests/test_figma_rest_auth.py | 56 +- tests/test_figma_rest_file.py | 519 +++++++++++++++++++ 10 files changed, 1163 insertions(+), 25 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..48f44e38c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -90,6 +90,33 @@ sequenceDiagram - Rust remains the psychometric arithmetic owner. Repair never substitutes Python for scoring math. +## Figma Cloud Agent REST fallback + +```mermaid +flowchart TD + Need["Cloud Agent needs Figma"] + Mcp{"Figma MCP OAuth available?"} + Desktop["Desktop / CLI: Settings → Tools and MCP → Figma → Connect"] + Token{"FIGMA_ACCESS_TOKEN set?"} + Whoami["python3 scripts/ci/figma_rest_auth.py"] + File["python3 scripts/ci/figma_rest_file.py file-key-or-url"] + Mint["Mint a Figma PAT with file_content:read and store the secret"] + + Need --> Mcp + Mcp -->|"yes, Desktop/CLI"| Desktop + Mcp -->|"no, Cloud Agent"| Token + Token -->|"no"| Mint + Mint --> Whoami + Token -->|"yes"| Whoami + Whoami --> File +``` + +Cloud Agents never complete Figma MCP OAuth. Whoami alone is not file +read. The file helper allowlists the key and node ids, opens a +pinned `HTTPSConnection("api.figma.com")`, and prints a token-free JSON +outline. See +[`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md). + ## Quality gates `scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. @@ -106,5 +133,7 @@ tests pin workflow structure and governance prose so drift fails closed. contract. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. +- [`docs/doctoring/figma-cloud-agent-mcp-auth.md`](docs/doctoring/figma-cloud-agent-mcp-auth.md) + — Cloud Agent Figma MCP boundary and REST file-read fallback. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..d579099ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added Cloud Agent Figma REST helpers `scripts/ci/figma_rest_auth.py` and `scripts/ci/figma_rest_file.py` that verify `FIGMA_ACCESS_TOKEN` against pinned `GET /v1/me` and then read an allowlisted `GET /v1/files/{key}` (optional `/nodes` or `/images`) so whoami is not treated as file read. - Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. - Added a permanent exact-head contract workflow for the hourly review-repair scheduler, immutable reusable-workflow source, NVIDIA NIM model boundary, credential isolation, and fail-closed unattended-agent permissions. @@ -39,6 +40,7 @@ Semantic Versioning where the repository publishes a release. ### Security +- Pin Figma REST calls to `http.client.HTTPSConnection("api.figma.com")`, allow only the `X-Figma-Token` header, allowlist file keys and node ids, and cap whoami/file bodies so `file://`, `Host` retargeting, and path traversal cannot leave the pinned origin. - Reject `.github/` and `scripts/ci/` from review-thread-derived autofix path authority so an untrusted inline reviewer cannot authorize the write-capable repair agent to modify workflows, CODEOWNERS, actions, scheduler code, or CI helpers that govern its own control plane. - Require the model-write snapshot and exact-path allowlist to remain outside the pull-request worktree, checking both absolute and resolved locations so repository-local controls and outside-looking symlinks resolving into the repository fail closed before they can authorize or verify model changes. - Snapshot the complete pre-model worktree for ordinary and conflict repair and reject every model-caused created, deleted, modified, mode-changed, retargeted, ignored, dangling, directory-backed, external-link, metadata-race, or out-of-scope path before staging or push. @@ -55,6 +57,7 @@ Semantic Versioning where the repository publishes a release. ### Documentation +- Added an APA 7 doctoring record for the Cloud Agent Figma MCP OAuth boundary, REST whoami and file-read helpers, operator next-action contract, and Figma file-endpoint citations. - Added an APA 7 doctoring record for conflict-control evidence isolation, including the Strix-reported trust-boundary failure, test-first remediation, canonical-path rule, operator contract, rollback, MITRE CWE-22, and current GitHub Actions secure-use guidance. - Added operator and APA 7 doctoring records for the hourly cadence, immutable source identity, NVIDIA NIM provider and secret boundary, high-reasoning Mistral Small 4 writer, model-process credential isolation, modular MSA ownership, product-specific caller activation, verification contract, and rollback. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. diff --git a/CLAUDE.md b/CLAUDE.md index 7127d3c1c..f8158b145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,3 +128,8 @@ repeatable compile command. cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. +- **Figma on Cloud Agents is REST-only.** Do not retry MCP Connect against + `https://mcp.figma.com/mcp`. Store `FIGMA_ACCESS_TOKEN`, run + `python3 scripts/ci/figma_rest_auth.py`, then + `python3 scripts/ci/figma_rest_file.py `. Desktop/CLI keep + the full MCP path. See `docs/doctoring/figma-cloud-agent-mcp-auth.md`. diff --git a/docs/CWL-MASTER-CONTEXT.md b/docs/CWL-MASTER-CONTEXT.md index 726b48b73..5aa0b2cd8 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 `. 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 9a5ed4f86..c41bc9fe5 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 (`X-Figma-Token` on `https://api.figma.com/v1/...`) | +| 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, @@ -70,6 +77,27 @@ authorizes the REST API. Do not commit the token. Do not put it in (`urllib.request.urlopen` is not used). The historical `httpsconnection-detected` audit (pre-3.4.3 default-verify gap) is suppressed only at that literal sink. +4. **Read the file the buyer asked for.** Whoami is not file read. After the + secret verifies, run: + + ```bash + python3 scripts/ci/figma_rest_file.py 'https://www.figma.com/design//?node-id=12-34' + ``` + + Or pass the file key and node id directly: + + ```bash + python3 scripts/ci/figma_rest_file.py '' --node-id 12:34 + python3 scripts/ci/figma_rest_file.py '' --node-id 12:34 --images + ``` + + The helper allowlists the file key (10-128 letters or digits) and node + ids before they enter the path, opens the same pinned + `api.figma.com` origin, and prints a token-free JSON outline (pages and + top-level frames at depth 2 by default). `--images` returns HTTPS PNG + URLs for those nodes. `file://`, `http://`, and `api.figma.com` locators + are refused. Use the outline or image URLs as the next design-to-code + input; do not retry MCP Connect. ## Why MCP Connect cannot be finished here @@ -80,17 +108,38 @@ 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 + +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. +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 0970f2951..74fac7af1 100644 --- a/scripts/ci/figma_rest_auth.py +++ b/scripts/ci/figma_rest_auth.py @@ -21,11 +21,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): @@ -56,6 +58,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,15 +111,23 @@ def default_opener(url: str, headers: Mapping[str, str]) -> tuple[int, bytes]: "/v1/me endpoint.", EXIT_TRANSPORT, ) + request_headers = sanitize_request_headers(headers) connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected "api.figma.com", timeout=REQUEST_TIMEOUT_SECONDS, context=ssl.create_default_context(), ) try: - connection.request("GET", "/v1/me", headers=dict(headers)) + connection.request( + "GET", + "/v1/me", + headers=request_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}", @@ -109,18 +154,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..d47951aa8 --- /dev/null +++ b/scripts/ci/figma_rest_file.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Read a Figma file over REST for Cloud Agents. + +Cursor Cloud Agents cannot complete Figma MCP OAuth. After +``figma_rest_auth.py`` confirms ``FIGMA_ACCESS_TOKEN``, this helper GETs +``/v1/files/{file_key}`` (optional ``/nodes`` or ``/images``) on a pinned +``api.figma.com`` HTTPS connection. File keys and node IDs are allowlisted +before they enter the request path. The token is never printed. +""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import re +import ssl +import sys +from collections.abc import Callable, Mapping, Sequence +from typing import Any, TextIO +from urllib.parse import parse_qs, unquote, urlparse + +from scripts.ci.figma_rest_auth import ( + EXIT_OK, + EXIT_REJECTED, + EXIT_TRANSPORT, + REQUEST_TIMEOUT_SECONDS, + TOKEN_ENV_NAME, + TOKEN_HEADER, + FigmaAuthError, + identity_field, + read_access_token, + read_bounded_body, + sanitize_request_headers, +) + +EXIT_INVALID_TARGET = 5 +EXIT_NOT_FOUND = 6 +FIGMA_API_HOST = "api.figma.com" +FILE_KEY_PATTERN = re.compile(r"^[A-Za-z0-9]{10,128}$") +NODE_ID_PATTERN = re.compile(r"^\d+:\d+$") +FILE_URL_TYPES = frozenset({"file", "design", "board", "proto", "slides", "deck", "figjam"}) +FILE_URL_HOSTS = frozenset({"www.figma.com", "figma.com"}) +ALLOWED_REQUEST_PATH = re.compile( + r"^/v1/(?:" + r"files/[A-Za-z0-9]{10,128}(?:\?depth=[1-8])?" + r"|files/[A-Za-z0-9]{10,128}/nodes\?ids=\d+:\d+(?:,\d+:\d+)*(?:&depth=[1-8])?" + r"|images/[A-Za-z0-9]{10,128}\?ids=\d+:\d+(?:,\d+:\d+)*&format=png" + r")$" +) +DEFAULT_TREE_DEPTH = 2 +MAX_TREE_DEPTH = 8 +MAX_FILE_BODY_BYTES = 8_388_608 +FileOpener = Callable[[str, Mapping[str, str]], tuple[int, bytes]] + + +def validate_file_key(raw: str) -> str: + """Return an allowlisted Figma file or branch key.""" + key = raw.strip() + if not FILE_KEY_PATTERN.fullmatch(key): + raise FigmaAuthError( + "Figma file key must be 10-128 letters or digits. Paste the key " + "or a https://www.figma.com/design//... URL; do not pass " + "paths, schemes, or query strings as the key.", + EXIT_INVALID_TARGET, + ) + return key + + +def validate_node_id(raw: str) -> str: + """Return a Figma node id in ``page:node`` form.""" + candidate = raw.strip().replace("-", ":", 1) + if not NODE_ID_PATTERN.fullmatch(candidate): + raise FigmaAuthError( + "Figma node id must look like 12:34 (URL form 12-34 is accepted).", + EXIT_INVALID_TARGET, + ) + return candidate + + +def parse_file_locator(raw: str) -> tuple[str, list[str]]: + """Return ``(file_key, node_ids)`` from a key or Figma file URL.""" + text = raw.strip() + if not text: + raise FigmaAuthError( + "Pass a Figma file key or https://www.figma.com/design//... URL.", + EXIT_INVALID_TARGET, + ) + if "://" in text or text.startswith(("figma.com/", "www.figma.com/")): + parsed = urlparse(text if "://" in text else f"https://{text}") + if parsed.scheme != "https": + raise FigmaAuthError( + "Figma file URLs must use https://www.figma.com. " + "file:// and http:// locators are refused.", + EXIT_INVALID_TARGET, + ) + host = (parsed.hostname or "").lower() + if host not in FILE_URL_HOSTS: + raise FigmaAuthError( + "Figma file URLs must be on www.figma.com. " + "api.figma.com paths are not locators.", + EXIT_INVALID_TARGET, + ) + parts = [unquote(part) for part in parsed.path.split("/") if part] + if len(parts) < 2 or parts[0] not in FILE_URL_TYPES: + raise FigmaAuthError( + "Figma file URL must look like " + "https://www.figma.com/design//.", + EXIT_INVALID_TARGET, + ) + node_ids = [ + validate_node_id(value) for value in parse_qs(parsed.query).get("node-id", []) + ] + return validate_file_key(parts[1]), node_ids + return validate_file_key(text), [] + + +def unique_node_ids(values: Sequence[str]) -> list[str]: + """Return allowlisted node ids in first-seen order.""" + seen: set[str] = set() + ordered: list[str] = [] + for raw in values: + node_id = validate_node_id(raw) + if node_id in seen: + continue + seen.add(node_id) + ordered.append(node_id) + return ordered + + +def build_request_path( + file_key: str, + *, + node_ids: Sequence[str] = (), + depth: int = DEFAULT_TREE_DEPTH, + images: bool = False, +) -> str: + """Return a pinned Figma REST path after allowlisting every parameter.""" + key = validate_file_key(file_key) + ids = unique_node_ids(node_ids) + if not isinstance(depth, int) or isinstance(depth, bool) or depth < 1 or depth > MAX_TREE_DEPTH: + raise FigmaAuthError( + f"Figma tree depth must be an integer from 1 to {MAX_TREE_DEPTH}.", + EXIT_INVALID_TARGET, + ) + if images: + if not ids: + raise FigmaAuthError( + "Image export needs at least one --node-id so Figma can render " + "those nodes. Pass the frame id from the Figma URL.", + EXIT_INVALID_TARGET, + ) + return f"/v1/images/{key}?ids={','.join(ids)}&format=png" + if ids: + return f"/v1/files/{key}/nodes?ids={','.join(ids)}&depth={depth}" + return f"/v1/files/{key}?depth={depth}" + + +def default_file_opener(path: str, headers: Mapping[str, str]) -> tuple[int, bytes]: + """GET an allowlisted Figma REST path and return ``(status, body)``. + + Host is the string literal ``api.figma.com``. ``path`` must match + ``ALLOWED_REQUEST_PATH`` so ``file://``, ``..``, and other schemes never + reach the TLS sink. This path does not call ``urllib.request.urlopen``. + ``ssl.create_default_context()`` makes certificate verification explicit. + The scoped Semgrep suppression is only for the historical + ``httpsconnection-detected`` audit, not for a dynamic host or scheme. + """ + if ALLOWED_REQUEST_PATH.fullmatch(path) is None: + raise FigmaAuthError( + "Figma REST file opener refuses paths other than allowlisted " + "/v1/files or /v1/images requests on api.figma.com.", + EXIT_TRANSPORT, + ) + request_headers = sanitize_request_headers(headers) + connection = http.client.HTTPSConnection( # nosemgrep: python.lang.security.audit.httpsconnection-detected.httpsconnection-detected + FIGMA_API_HOST, + timeout=REQUEST_TIMEOUT_SECONDS, + context=ssl.create_default_context(), + ) + try: + connection.request("GET", path, headers=request_headers) + response = connection.getresponse() + return int(response.status), read_bounded_body(response.read, MAX_FILE_BODY_BYTES) + except OSError as exc: + raise FigmaAuthError( + f"Figma REST transport failed: {exc}", + EXIT_TRANSPORT, + ) from exc + finally: + connection.close() + + +def parse_json_object(body: bytes) -> dict[str, Any]: + """Parse a Figma JSON object or raise ``FigmaAuthError``.""" + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise FigmaAuthError( + "Figma REST file endpoint returned a non-JSON body.", + EXIT_TRANSPORT, + ) from exc + if not isinstance(payload, dict): + raise FigmaAuthError( + "Figma REST file endpoint returned a JSON value that is not an object.", + EXIT_TRANSPORT, + ) + return payload + + +def outline_node(node: object, remaining_depth: int) -> dict[str, Any] | None: + """Return a token-free id/name/type outline of one Figma node.""" + if not isinstance(node, Mapping): + return None + summary: dict[str, Any] = {} + node_id = identity_field(node.get("id")) + name = identity_field(node.get("name")) + node_type = identity_field(node.get("type")) + if node_id is not None: + summary["node_id"] = node_id + if name is not None: + summary["node_name"] = name + if node_type is not None: + summary["node_type"] = node_type + if remaining_depth > 0: + children = node.get("children") + if isinstance(children, list): + outlined = [outline_node(child, remaining_depth - 1) for child in children] + summary["child_nodes"] = [child for child in outlined if child is not None] + return summary or None + + +def https_image_url(value: object) -> str | None: + """Return a https image URL, refusing token-shaped or non-https values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned.startswith("https://"): + return None + if "figd_" in cleaned or TOKEN_ENV_NAME in cleaned: + return None + return cleaned + + +def summarize_file_payload(payload: Mapping[str, Any], outline_depth: int) -> dict[str, Any]: + """Return a compact, token-free file, node, or image summary.""" + summary: dict[str, Any] = {} + file_name = identity_field(payload.get("name")) + last_modified = identity_field(payload.get("lastModified")) + version = identity_field(payload.get("version")) + editor_type = identity_field(payload.get("editorType")) + role = identity_field(payload.get("role")) + if file_name is not None: + summary["file_name"] = file_name + if last_modified is not None: + summary["last_modified"] = last_modified + if version is not None: + summary["file_version"] = version + if editor_type is not None: + summary["editor_type"] = editor_type + if role is not None: + summary["viewer_role"] = role + document = outline_node(payload.get("document"), outline_depth) + if document is not None: + summary["document_outline"] = document + raw_nodes = payload.get("nodes") + if isinstance(raw_nodes, Mapping): + nodes: dict[str, Any] = {} + for raw_id, raw_node in raw_nodes.items(): + candidate = str(raw_id).replace("-", ":", 1) + node_id = validate_node_id(str(raw_id)) if NODE_ID_PATTERN.fullmatch(candidate) else None + if node_id is None: + continue + document_node = raw_node.get("document") if isinstance(raw_node, Mapping) else None + outlined = outline_node(document_node, outline_depth) + if outlined is not None: + nodes[node_id] = outlined + if nodes: + summary["selected_nodes"] = nodes + raw_images = payload.get("images") + if isinstance(raw_images, Mapping): + images = { + str(node_id): image_url + for node_id, raw_url in raw_images.items() + if (image_url := https_image_url(raw_url)) is not None + } + if images: + summary["image_urls"] = images + if not summary: + return {"read_status": "Figma REST file read succeeded with no outline fields."} + return summary + + +def classify_file_status(status: int) -> None: + """Raise ``FigmaAuthError`` for every non-200 Figma file status.""" + if status in {401, 403}: + raise FigmaAuthError( + f"Figma REST rejected {TOKEN_ENV_NAME} with HTTP {status}. " + "Regenerate the personal access token with file_content:read " + "and confirm the token can open that file.", + EXIT_REJECTED, + ) + if status == 404: + raise FigmaAuthError( + "Figma REST returned HTTP 404. Check the file key and that the " + "token's owner can open the file.", + EXIT_NOT_FOUND, + ) + if status == 400: + raise FigmaAuthError( + "Figma REST returned HTTP 400. Check --node-id and --depth.", + EXIT_INVALID_TARGET, + ) + if status != 200: + raise FigmaAuthError( + f"Figma REST file endpoint returned HTTP {status}.", + EXIT_TRANSPORT, + ) + + +def fetch_file_document( + locator: str, + environ: Mapping[str, str], + *, + extra_node_ids: Sequence[str] = (), + depth: int = DEFAULT_TREE_DEPTH, + images: bool = False, + opener: FileOpener = default_file_opener, +) -> dict[str, Any]: + """Authenticate and return a token-free Figma file summary.""" + file_key, url_node_ids = parse_file_locator(locator) + node_ids = unique_node_ids([*url_node_ids, *extra_node_ids]) + path = build_request_path( + file_key, + node_ids=node_ids, + depth=depth, + images=images, + ) + token = read_access_token(environ) + status, body = opener(path, {TOKEN_HEADER: token}) + classify_file_status(status) + return summarize_file_payload(parse_json_object(body), depth) + + +def build_argument_parser() -> argparse.ArgumentParser: + """Return the Cloud Agent CLI for Figma file reads.""" + parser = argparse.ArgumentParser( + prog="figma_rest_file.py", + description=( + "Read a Figma file over REST after FIGMA_ACCESS_TOKEN is set. " + "Prints a token-free JSON outline so a Cloud Agent can continue " + "design-to-code without Figma MCP." + ), + ) + parser.add_argument( + "locator", + help="Figma file key or https://www.figma.com/design//... URL", + ) + parser.add_argument( + "--depth", + type=int, + default=DEFAULT_TREE_DEPTH, + help="Tree depth 1-8 (default 2: pages and top-level frames)", + ) + parser.add_argument( + "--node-id", + action="append", + default=[], + dest="node_ids", + help="Figma node id (12:34 or URL form 12-34). Repeatable.", + ) + parser.add_argument( + "--images", + action="store_true", + help="Render --node-id frames as PNG URLs instead of JSON outline", + ) + return parser + + +def parse_cli_args(argv: Sequence[str]) -> argparse.Namespace: + """Parse CLI arguments, ignoring a leading script path.""" + raw = list(argv) + if raw and raw[0].endswith("figma_rest_file.py"): + raw = raw[1:] + return build_argument_parser().parse_args(raw) + + +def main( + argv: list[str] | None = None, + environ: Mapping[str, str] | None = None, + opener: FileOpener = default_file_opener, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + """Read a Figma file and print a token-free JSON outline.""" + out = stdout if stdout is not None else sys.stdout + err = stderr if stderr is not None else sys.stderr + env = environ if environ is not None else os.environ + try: + args = parse_cli_args(sys.argv if argv is None else argv) + summary = fetch_file_document( + args.locator, + env, + extra_node_ids=args.node_ids, + depth=args.depth, + images=args.images, + opener=opener, + ) + out.write(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + except FigmaAuthError as exc: + err.write(str(exc) + "\n") + return exc.exit_code + except SystemExit as exc: + code = exc.code + if code in {None, 0}: + return EXIT_OK + if isinstance(code, int): + return EXIT_INVALID_TARGET if code == 2 else code + err.write(str(code) + "\n") + return EXIT_INVALID_TARGET + return EXIT_OK + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/tests/test_figma_rest_auth.py b/tests/test_figma_rest_auth.py index 7e3fa812c..d3e04eff3 100644 --- a/tests/test_figma_rest_auth.py +++ b/tests/test_figma_rest_auth.py @@ -55,8 +55,11 @@ 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_field(True) is None + assert auth.identity_field({"id": "x"}) is None + assert auth.identity_field(" hello world ") == "hello world" def test_parse_whoami_payload_rejects_non_objects() -> None: @@ -126,9 +129,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: @@ -169,6 +174,47 @@ def close(self) -> None: self.closed = True +def test_sanitize_request_headers_allows_only_figma_token() -> None: + """A caller ``Host`` header must not reach ``HTTPSConnection.request``.""" + assert auth.sanitize_request_headers({auth.TOKEN_HEADER: TOKEN}) == { + auth.TOKEN_HEADER: TOKEN + } + with pytest.raises(auth.FigmaAuthError) as refused: + auth.sanitize_request_headers({"Host": "evil.example", auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert "Host" in str(refused.value) + assert TOKEN not in str(refused.value) + with pytest.raises(auth.FigmaAuthError) as blank: + auth.sanitize_request_headers({auth.TOKEN_HEADER: " "}) + assert blank.value.exit_code == auth.EXIT_TRANSPORT + + +def test_read_bounded_body_rejects_oversize_and_nonpositive_limits() -> None: + """Whoami bodies stay inside the 64 KiB cap.""" + assert auth.read_bounded_body(lambda amt: b"ok"[:amt], 8) == b"ok" + with pytest.raises(auth.FigmaAuthError) as oversize: + auth.read_bounded_body(lambda amt: b"x" * amt, 4) + assert oversize.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as invalid_limit: + auth.read_bounded_body(lambda amt: b"", 0) + assert invalid_limit.value.exit_code == auth.EXIT_TRANSPORT + + +def test_default_opener_refuses_host_override(monkeypatch: pytest.MonkeyPatch) -> None: + """Header sanitization runs before the pinned whoami request.""" + constructed: list[object] = [] + + def forbidden_connection(*args: object, **kwargs: object) -> object: + constructed.append((args, kwargs)) + return _FakeWhoamiConnection(*args, **kwargs) + + monkeypatch.setattr(auth.http.client, "HTTPSConnection", forbidden_connection) + with pytest.raises(auth.FigmaAuthError) as refused: + auth.default_opener(auth.WHOAMI_URL, {"Host": "evil.example"}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert constructed == [] + + def test_default_opener_rejects_non_whoami_urls() -> None: """``file://`` and other caller URLs never reach the TLS sink.""" with pytest.raises(auth.FigmaAuthError) as refused: @@ -319,5 +365,7 @@ 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 "scripts/ci/figma_rest_file.py" in doctoring + assert "scripts/ci/figma_rest_file.py" in agents assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master diff --git a/tests/test_figma_rest_file.py b/tests/test_figma_rest_file.py new file mode 100644 index 000000000..fbd93fa8c --- /dev/null +++ b/tests/test_figma_rest_file.py @@ -0,0 +1,519 @@ +"""Contracts for Cloud Agent Figma REST file reads.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import figma_rest_auth as auth +from scripts.ci import figma_rest_file as files + +ROOT = Path(__file__).resolve().parents[1] +DOCTORING = ROOT / "docs" / "doctoring" / "figma-cloud-agent-mcp-auth.md" +AGENTS = ROOT / "AGENTS.md" +MASTER = ROOT / "docs" / "CWL-MASTER-CONTEXT.md" +CHANGELOG = ROOT / "CHANGELOG.md" +ARCHITECTURE = ROOT / "ARCHITECTURE.md" +CLAUDE = ROOT / "CLAUDE.md" +TOKEN = "figd_test_token_must_never_appear" +FILE_KEY = "TestFileKey0123456789" +FILE_URL = f"https://www.figma.com/design/{FILE_KEY}/Checkout?node-id=12-34" +_SEMGREP_HTTPSCONNECTION_RULE = ( + "python.lang.security.audit.httpsconnection-detected.httpsconnection-detected" +) + + +def _file_body(**fields: object) -> bytes: + """Return a Figma file JSON body.""" + return json.dumps(fields).encode("utf-8") + + +def test_validate_file_key_rejects_paths_and_schemes() -> None: + """File keys cannot smuggle slashes, dots, or URL schemes.""" + for raw in ("", "short", "../passwd", "file://x", "https://x", "abc/def", "key with space"): + with pytest.raises(auth.FigmaAuthError) as invalid: + files.validate_file_key(raw) + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + assert TOKEN not in str(invalid.value) + assert files.validate_file_key(f" {FILE_KEY} \n") == FILE_KEY + + +def test_validate_node_id_accepts_url_hyphen_form() -> None: + """Figma share URLs use 12-34; the REST API uses 12:34.""" + assert files.validate_node_id("12-34") == "12:34" + assert files.validate_node_id("12:34") == "12:34" + with pytest.raises(auth.FigmaAuthError) as invalid: + files.validate_node_id("root") + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_parse_file_locator_reads_design_url() -> None: + """A Figma design URL yields the file key and node-id query.""" + key, node_ids = files.parse_file_locator(FILE_URL) + assert key == FILE_KEY + assert node_ids == ["12:34"] + assert files.parse_file_locator(FILE_KEY) == (FILE_KEY, []) + + +@pytest.mark.parametrize( + "locator", + [ + "", + "file:///etc/passwd", + "http://www.figma.com/design/TestFileKey0123456789/x", + "https://api.figma.com/v1/files/TestFileKey0123456789", + "https://www.figma.com/onlykey", + "https://www.figma.com/unknown/TestFileKey0123456789/x", + "www.figma.com/design/nope/x", + ], +) +def test_parse_file_locator_refuses_unsafe_or_incomplete_urls(locator: str) -> None: + """Non-https, non-figma, and incomplete locators fail closed.""" + with pytest.raises(auth.FigmaAuthError) as invalid: + files.parse_file_locator(locator) + assert invalid.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_parse_file_locator_accepts_host_without_scheme() -> None: + """Operators may paste www.figma.com/... without the scheme prefix.""" + key, node_ids = files.parse_file_locator(f"www.figma.com/file/{FILE_KEY}/Home") + assert key == FILE_KEY + assert node_ids == [] + + +def test_unique_node_ids_preserve_first_seen_order() -> None: + """Repeated node ids from a URL plus --node-id stay unique.""" + assert files.unique_node_ids(["12-34", "12:34", "1:2"]) == ["12:34", "1:2"] + + +def test_build_request_path_emits_only_allowlisted_shapes() -> None: + """File, node, and image paths stay inside the opener allowlist.""" + file_path = files.build_request_path(FILE_KEY) + assert file_path == f"/v1/files/{FILE_KEY}?depth=2" + assert files.ALLOWED_REQUEST_PATH.fullmatch(file_path) + nodes_path = files.build_request_path(FILE_KEY, node_ids=["12:34"], depth=1) + assert nodes_path == f"/v1/files/{FILE_KEY}/nodes?ids=12:34&depth=1" + assert files.ALLOWED_REQUEST_PATH.fullmatch(nodes_path) + image_path = files.build_request_path(FILE_KEY, node_ids=["12:34"], images=True) + assert image_path == f"/v1/images/{FILE_KEY}?ids=12:34&format=png" + assert files.ALLOWED_REQUEST_PATH.fullmatch(image_path) + + +def test_build_request_path_rejects_images_without_nodes_and_bad_depth() -> None: + """Image export and depth stay fail-closed for the operator.""" + with pytest.raises(auth.FigmaAuthError) as missing_nodes: + files.build_request_path(FILE_KEY, images=True) + assert missing_nodes.value.exit_code == files.EXIT_INVALID_TARGET + for depth in (0, 9, True): + with pytest.raises(auth.FigmaAuthError) as invalid_depth: + files.build_request_path(FILE_KEY, depth=depth) # type: ignore[arg-type] + assert invalid_depth.value.exit_code == files.EXIT_INVALID_TARGET + + +def test_default_file_opener_refuses_non_allowlisted_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``file://`` and host-relative traversal never construct TLS.""" + constructed: list[object] = [] + + def forbidden_connection(*args: object, **kwargs: object) -> object: + constructed.append((args, kwargs)) + raise AssertionError("file opener must not connect for a refused path") + + monkeypatch.setattr(files.http.client, "HTTPSConnection", forbidden_connection) + for path in ( + "file:///etc/passwd", + "/v1/files/../secrets", + f"/v1/files/{FILE_KEY}?callback=http://evil", + "/v1/me", + ): + with pytest.raises(auth.FigmaAuthError) as refused: + files.default_file_opener(path, {auth.TOKEN_HEADER: TOKEN}) + assert refused.value.exit_code == auth.EXIT_TRANSPORT + assert TOKEN not in str(refused.value) + assert constructed == [] + + +class _FakeFileResponse: + """Minimal ``HTTPResponse`` stand-in for ``HTTPSConnection.getresponse``.""" + + def __init__(self, status: int, body: bytes) -> None: + """Record the canned status and body.""" + self.status = status + self._body = body + + def read(self, amt: int | None = None) -> bytes: + """Return the canned body, honoring an optional byte limit.""" + if amt is None: + return self._body + return self._body[:amt] + + +class _FakeFileConnection: + """Record the pinned Figma origin used by ``default_file_opener``.""" + + last: _FakeFileConnection | None = None + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Capture the TLS host, timeout, and explicit SSL context.""" + self.host = host + self.timeout = timeout + self.context = context + self.method = "" + self.path = "" + self.headers: dict[str, str] = {} + self.closed = False + self._status = 200 + self._body = b'{"name":"Checkout"}' + type(self).last = self + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Record the allowlisted GET.""" + self.method = method + self.path = path + self.headers = dict(headers or {}) + + def getresponse(self) -> _FakeFileResponse: + """Return the canned file response.""" + return _FakeFileResponse(self._status, self._body) + + def close(self) -> None: + """Mark the connection closed.""" + self.closed = True + + +def test_default_file_opener_reads_success_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful HTTPS response yields its status and body bytes.""" + monkeypatch.setattr(files.http.client, "HTTPSConnection", _FakeFileConnection) + path = files.build_request_path(FILE_KEY) + status, body = files.default_file_opener(path, {auth.TOKEN_HEADER: TOKEN}) + assert status == 200 + assert body == b'{"name":"Checkout"}' + connection = _FakeFileConnection.last + assert connection is not None + assert connection.host == "api.figma.com" + assert connection.timeout == auth.REQUEST_TIMEOUT_SECONDS + assert connection.context is not None + assert connection.method == "GET" + assert connection.path == path + assert connection.headers == {auth.TOKEN_HEADER: TOKEN} + assert connection.closed is True + + +def test_default_file_opener_rejects_oversize_body(monkeypatch: pytest.MonkeyPatch) -> None: + """A file body larger than 8 MiB is a transport failure.""" + + class HugeConnection(_FakeFileConnection): + """Return more bytes than the file cap.""" + + def __init__( + self, + host: str, + timeout: int = 0, + context: object | None = None, + ) -> None: + """Initialize an oversized body.""" + super().__init__(host, timeout, context=context) + self._body = b"x" * (files.MAX_FILE_BODY_BYTES + 1) + + monkeypatch.setattr(files.http.client, "HTTPSConnection", HugeConnection) + with pytest.raises(auth.FigmaAuthError) as oversize: + files.default_file_opener(files.build_request_path(FILE_KEY), {auth.TOKEN_HEADER: TOKEN}) + assert oversize.value.exit_code == auth.EXIT_TRANSPORT + assert str(files.MAX_FILE_BODY_BYTES) in str(oversize.value) + + +def test_default_file_opener_wraps_os_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Network failures become ``EXIT_TRANSPORT`` without leaking the token.""" + + class FailingConnection(_FakeFileConnection): + """Raise a transport error after the host is already pinned.""" + + def request(self, method: str, path: str, headers: dict[str, str] | None = None) -> None: + """Fail after recording the request.""" + super().request(method, path, headers) + raise TimeoutError("timed out") + + monkeypatch.setattr(files.http.client, "HTTPSConnection", FailingConnection) + with pytest.raises(auth.FigmaAuthError) as transport: + files.default_file_opener(files.build_request_path(FILE_KEY), {auth.TOKEN_HEADER: TOKEN}) + assert transport.value.exit_code == auth.EXIT_TRANSPORT + assert "timed out" in str(transport.value) + assert TOKEN not in str(transport.value) + assert FailingConnection.last is not None + assert FailingConnection.last.closed is True + + +def test_parse_json_object_rejects_non_objects() -> None: + """Non-JSON and non-object bodies are transport failures.""" + with pytest.raises(auth.FigmaAuthError) as invalid_json: + files.parse_json_object(b"not-json") + assert invalid_json.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError) as not_object: + files.parse_json_object(b'["file"]') + assert not_object.value.exit_code == auth.EXIT_TRANSPORT + with pytest.raises(auth.FigmaAuthError): + files.parse_json_object(b"\xff") + + +def test_outline_and_image_summary_stay_token_free() -> None: + """Outlines keep names and drop token-shaped or non-https image URLs.""" + assert files.outline_node("not-a-node", 2) is None + assert files.outline_node({"children": "nope"}, 2) is None + outline = files.outline_node( + { + "id": "0:0", + "name": "Document", + "type": "DOCUMENT", + "children": [ + {"id": "1:2", "name": "Page 1", "type": "CANVAS", "children": [{"id": "3:4"}]} + ], + }, + 1, + ) + assert outline is not None + assert outline["node_id"] == "0:0" + assert outline["child_nodes"][0]["node_name"] == "Page 1" + assert "child_nodes" not in outline["child_nodes"][0] + assert files.https_image_url(None) is None + assert files.https_image_url("http://insecure.example/x") is None + assert files.https_image_url(f"https://x/{TOKEN}") is None + assert files.https_image_url(f"https://x/{auth.TOKEN_ENV_NAME}") is None + assert files.https_image_url("https://figma-alpha-api.s3.amazonaws.com/x.png") + + +def test_summarize_file_payload_covers_file_nodes_and_images() -> None: + """File metadata, selected nodes, and https image URLs are kept.""" + empty = files.summarize_file_payload({}, 2) + assert empty["read_status"].startswith("Figma REST file read succeeded") + summary = files.summarize_file_payload( + { + "name": "Checkout", + "lastModified": "2026-08-16T00:00:00Z", + "version": "9", + "editorType": "figma", + "role": "viewer", + "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + "nodes": { + "12:34": {"document": {"id": "12:34", "name": "Hero", "type": "FRAME"}}, + "skip": {"document": {"id": "9:9"}}, + "1:2": "not-a-map", + }, + "images": { + "12:34": "https://figma-alpha-api.s3.amazonaws.com/hero.png", + "1:2": None, + }, + }, + 2, + ) + assert summary["file_name"] == "Checkout" + assert summary["last_modified"] == "2026-08-16T00:00:00Z" + assert summary["file_version"] == "9" + assert summary["editor_type"] == "figma" + assert summary["viewer_role"] == "viewer" + assert summary["document_outline"]["node_type"] == "DOCUMENT" + assert summary["selected_nodes"]["12:34"]["node_name"] == "Hero" + assert "skip" not in summary["selected_nodes"] + assert summary["image_urls"]["12:34"].startswith("https://") + filtered = files.summarize_file_payload( + { + "nodes": {"skip": {"document": {"id": "9:9"}}, "1:2": "not-a-map"}, + "images": {"1:2": None, "3:4": "http://insecure.example/x"}, + }, + 2, + ) + assert "selected_nodes" not in filtered + assert "image_urls" not in filtered + assert filtered["read_status"].startswith("Figma REST file read succeeded") + + +@pytest.mark.parametrize( + ("status", "exit_code"), + [ + (401, auth.EXIT_REJECTED), + (403, auth.EXIT_REJECTED), + (404, files.EXIT_NOT_FOUND), + (400, files.EXIT_INVALID_TARGET), + (503, auth.EXIT_TRANSPORT), + ], +) +def test_classify_file_status_maps_operator_next_action(status: int, exit_code: int) -> None: + """HTTP classes tell the operator whether to rotate, fix the key, or retry.""" + with pytest.raises(auth.FigmaAuthError) as classified: + files.classify_file_status(status) + assert classified.value.exit_code == exit_code + assert TOKEN not in str(classified.value) + + +def test_fetch_file_document_reads_url_and_extra_node_ids() -> None: + """A 200 file response becomes a token-free outline for design-to-code.""" + seen: dict[str, Any] = {} + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + seen["path"] = path + seen["headers"] = dict(headers) + return 200, _file_body( + name="Checkout", + document={"id": "0:0", "name": "Document", "type": "DOCUMENT"}, + ) + + summary = files.fetch_file_document( + FILE_URL, + {auth.TOKEN_ENV_NAME: TOKEN}, + extra_node_ids=["1:2"], + opener=opener, + ) + assert seen["path"] == f"/v1/files/{FILE_KEY}/nodes?ids=12:34,1:2&depth=2" + assert seen["headers"] == {auth.TOKEN_HEADER: TOKEN} + assert summary["file_name"] == "Checkout" + assert TOKEN not in json.dumps(summary) + + +def test_fetch_file_document_rejects_unauthorized_token() -> None: + """401/403 stay distinct from a missing file key.""" + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path, headers + return 403, b'{"status":403,"err":"Invalid token"}' + + with pytest.raises(auth.FigmaAuthError) as rejected: + files.fetch_file_document(FILE_KEY, {auth.TOKEN_ENV_NAME: TOKEN}, opener=opener) + assert rejected.value.exit_code == auth.EXIT_REJECTED + assert TOKEN not in str(rejected.value) + + +def test_helper_pins_https_origin_instead_of_dynamic_urllib() -> None: + """Semgrep ``dynamic-urllib-use-detected`` must not apply to this helper.""" + source = Path(files.__file__).read_text(encoding="utf-8") + source_lines = source.splitlines() + sink_lines = [line for line in source_lines if "http.client.HTTPSConnection(" in line] + assert "urlopen(" not in source + assert len(sink_lines) == 1 + assert f"# nosemgrep: {_SEMGREP_HTTPSCONNECTION_RULE}" in sink_lines[0] + assert "ssl.create_default_context()" in source + assert "FIGMA_API_HOST" in source + + +def test_main_writes_json_and_error_channels() -> None: + """CLI success and failure stay on stdout/stderr and never echo the token.""" + stdout = io.StringIO() + stderr = io.StringIO() + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path, headers + return 200, _file_body(name="Checkout") + + ok = files.main( + argv=["figma_rest_file.py", FILE_KEY], + environ={auth.TOKEN_ENV_NAME: TOKEN}, + opener=opener, + stdout=stdout, + stderr=stderr, + ) + assert ok == auth.EXIT_OK + assert json.loads(stdout.getvalue())["file_name"] == "Checkout" + assert stderr.getvalue() == "" + assert TOKEN not in stdout.getvalue() + + missing_out = io.StringIO() + missing_err = io.StringIO() + missing = files.main( + argv=[FILE_KEY], + environ={}, + opener=opener, + stdout=missing_out, + stderr=missing_err, + ) + assert missing == auth.EXIT_MISSING_TOKEN + assert missing_out.getvalue() == "" + assert auth.TOKEN_ENV_NAME in missing_err.getvalue() + + +def test_main_uses_process_streams_when_unspecified( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """The default CLI path reads ``os.environ`` and writes process streams.""" + monkeypatch.setenv(auth.TOKEN_ENV_NAME, TOKEN) + + def opener(path: str, headers: dict[str, str]) -> tuple[int, bytes]: + del path + assert headers[auth.TOKEN_HEADER] == TOKEN + return 200, _file_body(name="Home") + + monkeypatch.setattr( + "sys.argv", + ["figma_rest_file.py", FILE_KEY, "--depth", "1", "--node-id", "1:2", "--images"], + ) + assert files.main(opener=opener) == auth.EXIT_OK + captured = capsys.readouterr() + assert captured.out + assert TOKEN not in captured.out + + +def test_main_maps_argparse_errors_to_invalid_target(capsys: pytest.CaptureFixture[str]) -> None: + """A missing locator tells the operator to pass a file key or URL.""" + assert files.main(argv=["figma_rest_file.py"], environ={}) == files.EXIT_INVALID_TARGET + captured = capsys.readouterr() + assert captured.err + assert files.main(argv=["figma_rest_file.py", "--help"], environ={}) == auth.EXIT_OK + + +@pytest.mark.parametrize( + ("exc", "expected"), + [ + (SystemExit(), auth.EXIT_OK), + (SystemExit(3), 3), + (SystemExit("usage exploded"), files.EXIT_INVALID_TARGET), + ], +) +def test_main_maps_system_exit_shapes(exc: SystemExit, expected: int) -> None: + """Argparse abort codes stay fail-closed and never echo the token.""" + stderr = io.StringIO() + + def boom(_argv: list[str]) -> None: + raise exc + + original = files.parse_cli_args + files.parse_cli_args = boom # type: ignore[method-assign] + try: + code = files.main(argv=[FILE_KEY], environ={}, stdout=io.StringIO(), stderr=stderr) + finally: + files.parse_cli_args = original # type: ignore[method-assign] + assert code == expected + if expected == files.EXIT_INVALID_TARGET: + assert "usage exploded" in stderr.getvalue() + assert TOKEN not in stderr.getvalue() + + +def test_doctoring_and_entry_docs_pin_file_read_fallback() -> None: + """Agents must run the file helper, not treat whoami as file read.""" + doctoring = DOCTORING.read_text(encoding="utf-8") + agents = AGENTS.read_text(encoding="utf-8") + master = MASTER.read_text(encoding="utf-8") + changelog = CHANGELOG.read_text(encoding="utf-8") + architecture = ARCHITECTURE.read_text(encoding="utf-8") + claude = CLAUDE.read_text(encoding="utf-8") + for text in (doctoring, agents, master): + assert "FIGMA_ACCESS_TOKEN" in text + assert "mcp.figma.com" in text + assert "scripts/ci/figma_rest_file.py" in text + assert "APA 7th references" in doctoring + assert "Retrieved August 16, 2026" in doctoring + assert "file-endpoints" in doctoring + assert "CWE-22" in doctoring + assert "plan access token" in doctoring + assert "X-Figma-Token" in changelog + assert "scripts/ci/figma_rest_file.py" in changelog + assert "Figma Cloud Agent REST" in architecture + assert "FIGMA_ACCESS_TOKEN" in claude + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in agents + assert "docs/doctoring/figma-cloud-agent-mcp-auth.md" in master From 1a87505cac8cf802a51acc552ee2c6fe05395d39 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 17:23:12 +0000 Subject: [PATCH 5/6] fix(ci): retry CodeQL init after GitHub API 503 Required CodeQL jobs failed in Initialize CodeQL with HttpError "No server is currently available" while fetching feature flags. Retry init once after 20s so a partial GitHub outage is not a Medium+ finding on the PR head. Co-authored-by: Seongho Bae --- .github/workflows/codeql-pr.yml | 30 +++++++++++++++++++++++ CHANGELOG.md | 1 + tests/test_codeql_pr_workflow_contract.py | 3 +++ 3 files changed, 34 insertions(+) diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 2a170fa8a..06fa743af 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -90,6 +90,21 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Initialize CodeQL + id: init-codeql + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + # Feature-enablement GET /code-scanning/codeql-action/features returns + # HTTP 503 "No server is currently available" during GitHub API outages. + - name: Wait before CodeQL init retry + if: steps.init-codeql.outcome == 'failure' + run: sleep 20 + + - name: Retry Initialize CodeQL after GitHub API outage + if: steps.init-codeql.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} @@ -197,6 +212,21 @@ jobs: ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} - name: Initialize CodeQL + id: init-codeql + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + # Feature-enablement GET /code-scanning/codeql-action/features returns + # HTTP 503 "No server is currently available" during GitHub API outages. + - name: Wait before CodeQL init retry + if: steps.init-codeql.outcome == 'failure' + run: sleep 20 + + - name: Retry Initialize CodeQL after GitHub API outage + if: steps.init-codeql.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d579099ac..af8c6f433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Retried CodeQL `init` after a GitHub feature-enablement API 503 (`No server is currently available`) so a partial GitHub outage does not fail the required compatibility analysis. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 813385b23..4d9ca88ed 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -33,6 +33,9 @@ def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: assert "analyze-merge:" in workflow assert "merge_commit_sha != ''" in workflow assert "CodeQL merge preview" in workflow + assert workflow.count("Retry Initialize CodeQL after GitHub API outage") == 2 + assert workflow.count("No server is currently available") == 2 + assert workflow.count("id: init-codeql") == 2 assert "github.event.pull_request.head.sha" in workflow assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow From c8f6c3d9e5e3fac436f27715520127f0f57a4b11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 23:11:36 +0000 Subject: [PATCH 6/6] fix(ci): give CodeQL init a second outage retry The required actions compatibility job still failed after one 20s retry: first init 503'd (continue-on-error), the retry 503'd, and analysis never ran. Sibling python/merge jobs recovered. Wait 30s then 60s and retry init twice before failing the gate. Co-authored-by: Seongho Bae --- .github/workflows/codeql-pr.yml | 42 +++++++++++++++++++++-- CHANGELOG.md | 2 +- tests/test_codeql_pr_workflow_contract.py | 7 +++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 06fa743af..bfac36c97 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -99,18 +99,37 @@ jobs: # Feature-enablement GET /code-scanning/codeql-action/features returns # HTTP 503 "No server is currently available" during GitHub API outages. + # One 20s retry was not enough when sibling CodeQL jobs recovered later. - name: Wait before CodeQL init retry if: steps.init-codeql.outcome == 'failure' - run: sleep 20 + run: sleep 30 - name: Retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry if: steps.init-codeql.outcome == 'failure' + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Wait before CodeQL init second retry + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' + run: sleep 60 + + - name: Second retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry-2 + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis + if: >- + steps.init-codeql.outcome == 'success' || + steps.init-codeql-retry.outcome == 'success' || + steps.init-codeql-retry-2.outcome == 'success' uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{ matrix.language }}" @@ -221,18 +240,37 @@ jobs: # Feature-enablement GET /code-scanning/codeql-action/features returns # HTTP 503 "No server is currently available" during GitHub API outages. + # One 20s retry was not enough when sibling CodeQL jobs recovered later. - name: Wait before CodeQL init retry if: steps.init-codeql.outcome == 'failure' - run: sleep 20 + run: sleep 30 - name: Retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry if: steps.init-codeql.outcome == 'failure' + continue-on-error: true + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Wait before CodeQL init second retry + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' + run: sleep 60 + + - name: Second retry Initialize CodeQL after GitHub API outage + id: init-codeql-retry-2 + if: steps.init-codeql.outcome == 'failure' && steps.init-codeql-retry.outcome == 'failure' uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis + if: >- + steps.init-codeql.outcome == 'success' || + steps.init-codeql-retry.outcome == 'success' || + steps.init-codeql-retry-2.outcome == 'success' uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{ matrix.language }}-merge" diff --git a/CHANGELOG.md b/CHANGELOG.md index af8c6f433..8e8795ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed -- Retried CodeQL `init` after a GitHub feature-enablement API 503 (`No server is currently available`) so a partial GitHub outage does not fail the required compatibility analysis. +- Retried CodeQL `init` up to three times (30s then 60s) after a GitHub feature-enablement API 503 (`No server is currently available`) so a brief outage does not fail the required compatibility analysis. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 4d9ca88ed..79af844ff 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -34,8 +34,13 @@ def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: assert "merge_commit_sha != ''" in workflow assert "CodeQL merge preview" in workflow assert workflow.count("Retry Initialize CodeQL after GitHub API outage") == 2 + assert workflow.count("Second retry Initialize CodeQL after GitHub API outage") == 2 assert workflow.count("No server is currently available") == 2 - assert workflow.count("id: init-codeql") == 2 + assert workflow.count("id: init-codeql\n") == 2 + assert workflow.count("id: init-codeql-retry\n") == 2 + assert workflow.count("id: init-codeql-retry-2\n") == 2 + assert workflow.count("sleep 30") == 2 + assert workflow.count("sleep 60") == 2 assert "github.event.pull_request.head.sha" in workflow assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow