diff --git a/api/tests/test_compute_guard.py b/api/tests/test_compute_guard.py new file mode 100644 index 0000000..28b1eb8 --- /dev/null +++ b/api/tests/test_compute_guard.py @@ -0,0 +1,184 @@ +"""Compute guard: Windy Mind is the only door to AI compute (warn-only today).""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +_spec = importlib.util.spec_from_file_location("compute_guard", ROOT / "scripts" / "compute_guard.py") +cg = importlib.util.module_from_spec(_spec) +sys.modules["compute_guard"] = cg +_spec.loader.exec_module(cg) + +ALLOW = cg.load_allow(ROOT / "ci" / "compute-guard-allow.yml") + + +@pytest.mark.parametrize( + "path, text, kind", + [ + # audit #1 (windy-search, closed) and #2 (windy-chat, live): the shapes they had + ("service/app/anthropic_client.py", 'URL = "https://api.anthropic.com/v1/messages"', "provider host"), + ("service/app/config.py", 'token = os.environ["ANTHROPIC_OAUTH_TOKEN"]', "provider key"), + ("services/agent-roster/lib/llm.js", "const url = 'https://api.groq.com/openai/v1/chat/completions'", "provider host"), + ("docker-compose.yml", " GROQ_API_KEY: ${GROQ_API_KEY}", "provider key"), + # audit #3/#4 (windy-pro account-server) + ("account-server/src/routes/transcription.ts", "const r = await fetch('https://api.openai.com/v1/audio/transcriptions'", "provider host"), + ("account-server/src/config.ts", "openaiKey: process.env.OPENAI_API_KEY,", "provider key"), + # SDKs and deps + ("app/llm.py", "from anthropic import Anthropic", "provider SDK"), + ("app/llm.py", "import openai", "provider SDK"), + ("app/llm.py", "import google.generativeai as genai", "provider SDK"), + ("src/ai.ts", 'import Anthropic from "@anthropic-ai/sdk";', "provider SDK"), + ("src/ai.js", "const Groq = require('groq-sdk')", "provider SDK"), + ("package.json", ' "openai": "^4.52.0",', "provider SDK dep"), + ("requirements.txt", "anthropic>=0.40", "provider SDK dep"), + ("pyproject.toml", ' "google-generativeai>=0.8",', "provider SDK dep"), + ], +) +def test_audit_shapes_are_flagged(path, text, kind): + assert kind in [k for k, _ in cg.scan_line(path, text)] + + +@pytest.mark.parametrize( + "path, text", + [ + ("app/mind.py", 'MIND = "https://mind.windyword.ai/v1/chat/completions"'), # the door itself + ("app/models.py", "openai_compatible = True # Mind speaks the OpenAI wire format"), + ("app/x.py", "from app.openai_shim import x"), # a local module, not the SDK + ("package.json", ' "openai-types-lite": "1.0.0",'), # a different package + ("README.txt", "set OPENAI_API_KEY"), # scanned-by-rule, excluded by SKIP separately + ], +) +def test_near_misses_are_not_flagged(path, text): + if cg.SKIP.search(path): + return + assert cg.scan_line(path, text) == [] + + +@pytest.mark.parametrize( + "path", + ["tests/test_llm.py", "api/tests/x.py", "src/ai.test.ts", "web/foo.spec.js", "docs/setup.md", + "README.md", "package-lock.json", "uv.lock", "node_modules/openai/index.js", ".github/workflows/ci.yml", + "conftest.py", "app/llm_test.py"], +) +def test_tests_docs_lockfiles_vendored_ci_are_never_scanned(path): + assert cg.SKIP.search(path) + + +def test_allow_list_needs_a_reason_per_entry(tmp_path): + bad = tmp_path / "a.yml" + bad.write_text("allow:\n - repo: x\n paths: ['*']\n") + with pytest.raises(ValueError): + cg.load_allow(bad) + + +@pytest.mark.parametrize( + "repo, path, ok", + [ + ("windy-mind", "app/providers/anthropic.py", True), + ("windy-agent", "agent/providers.py", True), + ("windy-code", "extensions/windy-ai/src/aiProvider.ts", True), + ("windy-code", "web/server/llm.ts", False), # BYOK is the extension only + ("windy-connect", "backend/src/writers/claude_code.py", True), + ("windy-chat", "services/agent-roster/lib/llm.js", False), # audit #2: must be flagged + ("windy-pro", "account-server/src/routes/translations.ts", False), + ], +) +def test_allow_list_entries(repo, path, ok): + assert cg.allowed(repo, path, ALLOW) is ok + + +DIFF = """diff --git a/app/llm.py b/app/llm.py +--- a/app/llm.py ++++ b/app/llm.py +@@ -10,0 +11,2 @@ ++import anthropic ++client = anthropic.Anthropic() +diff --git a/tests/test_llm.py b/tests/test_llm.py +--- /dev/null ++++ b/tests/test_llm.py +@@ -0,0 +1 @@ ++import anthropic +@@ -40 +42 @@ +-x = 1 ++x = 2 +""" + + +def test_only_added_non_test_lines_are_findings(): + fs = cg.parse_added("windy-chat", DIFF, ALLOW) + assert [(f.path, f.line, f.kind) for f in fs] == [("app/llm.py", 11, "provider SDK")] + + +def _repo(tmp_path, files: dict[str, str]) -> tuple[Path, str]: + work = tmp_path / "w" + work.mkdir() + run = lambda *a: subprocess.run(["git", *a], cwd=work, check=True, capture_output=True) # noqa: E731 + run("init", "-q", "-b", "main") + for p, text in files.items(): + (work / p).parent.mkdir(parents=True, exist_ok=True) + (work / p).write_text(text) + run("add", "-A") + run("-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "x") + bare = tmp_path / "r.git" + subprocess.run(["git", "clone", "-q", "--bare", str(work), str(bare)], check=True) + sha = subprocess.run(["git", "--git-dir", str(bare), "rev-parse", "main"], + capture_output=True, text=True, check=True).stdout.strip() + return bare, sha + + +def test_tree_scan_on_a_real_git_repo(tmp_path): + bare, sha = _repo(tmp_path, { + "app/llm.py": "import os\nKEY = os.environ['OPENAI_API_KEY']\n", + "app/ok.py": "MIND = 'https://mind.windyword.ai'\n", + "tests/test_llm.py": "import anthropic\n", + "docs/x.md": "api.anthropic.com\n", + }) + fs = cg.scan_tree("windy-chat", bare, sha, ALLOW) + assert [(f.path, f.line, f.kind) for f in fs] == [("app/llm.py", 2, "provider key")] + + +def test_warn_mode_never_turns_red(monkeypatch): + monkeypatch.setattr(cg, "MODE", "warn") + state, desc, f = cg.status_for([cg.Finding("a.py", 3, "provider host", "api.openai.com")], whole_tree=False) + assert state == "success" and desc.startswith("⚠ WARN (not blocking): 1 direct AI-provider use added") + assert "a.py:3" in desc and f.path == "a.py" + + +def test_block_mode_fails(monkeypatch): + monkeypatch.setattr(cg, "MODE", "block") + state, desc, _ = cg.status_for([cg.Finding("a.py", 3, "provider host", "x")], whole_tree=True) + assert state == "failure" and desc.startswith("BLOCKED") + + +def test_clean_is_ok(): + assert cg.status_for([], whole_tree=True)[:2] == ( + "success", "OK: no direct AI-provider use in tree (Windy Mind is the only door)") + + +@pytest.mark.parametrize( + "text", + [ + " # The ANTHROPIC_OAUTH_TOKEN setting was removed on 2026-09-23 ON PURPOSE", # windy-search + "# ANTHROPIC_API_KEY=", + " // fallback used to call https://api.groq.com directly", + " * @see https://api.openai.com/v1/audio", + "", + ], +) +def test_comments_are_not_calls(text): + assert cg.scan_line("service/app/config.py", text) == [] + + +def test_code_with_a_trailing_comment_still_counts(): + assert cg.scan_line("a.js", "fetch('https://api.openai.com/v1') // TODO move to Mind") + + +def test_windy_pro_desktop_is_byok_but_the_account_server_is_not(): + assert cg.allowed("windy-pro", "src/client/desktop/main.js", ALLOW) + assert not cg.allowed("windy-pro", "account-server/src/routes/translations.ts", ALLOW) diff --git a/api/tests/test_pr_status_bridge.py b/api/tests/test_pr_status_bridge.py index 147fe09..2f8240a 100644 --- a/api/tests/test_pr_status_bridge.py +++ b/api/tests/test_pr_status_bridge.py @@ -10,6 +10,7 @@ from __future__ import annotations import base64 import importlib.util +import sys from pathlib import Path import pytest @@ -340,3 +341,44 @@ def test_lookup_failure_is_non_fatal(monkeypatch): monkeypatch.setattr(bridge.subprocess, "run", boom) assert bridge.queued_jobs("windy-chat", SHA) == [] + + +class _Guard: + def __init__(self, findings): + self.findings = findings + + def check(self, repo, sha, default_branch, is_default_head): + return self.findings + + @staticmethod + def status_for(findings, whole_tree): + if not findings: + return "success", "OK: clean", None + return "success", f"WARN {len(findings)}", findings[0] + + +class _F: + path, line = "app/llm.py", 7 + + +def test_guard_posts_warn_with_a_link_to_the_first_finding(fake, monkeypatch): + f = fake() + monkeypatch.setitem(sys.modules, "compute_guard", _Guard([_F()])) + bridge.post_compute_guard("windy-chat", SHA, "main", False) + assert [(p["context"], p["state"], p["description"]) for p in f.posted] == [ + ("windy-git/compute-guard", "success", "WARN 1")] + assert f.posted[0]["target_url"].endswith(f"/src/commit/{SHA}/app/llm.py#L7") + + +def test_guard_same_status_is_not_reposted(fake, monkeypatch): + f = fake(statuses=[{"context": "windy-git/compute-guard", "state": "success", "description": "WARN 1"}]) + monkeypatch.setitem(sys.modules, "compute_guard", _Guard([_F()])) + bridge.post_compute_guard("windy-chat", SHA, "main", False) + assert f.posted == [] + + +def test_guard_that_cannot_run_posts_nothing(fake, monkeypatch): + f = fake() + monkeypatch.setitem(sys.modules, "compute_guard", _Guard(None)) + bridge.post_compute_guard("windy-chat", SHA, "main", True) + assert f.posted == [] diff --git a/ci/compute-guard-allow.yml b/ci/compute-guard-allow.yml new file mode 100644 index 0000000..7683d94 --- /dev/null +++ b/ci/compute-guard-allow.yml @@ -0,0 +1,40 @@ +# Compute guard allow-list: code that MAY talk to an AI provider directly. +# Windy Mind is the ONLY door to AI compute (Grant, 2026-09-23). Every entry +# here is an exception to that rule and MUST say why. Paths are fnmatch globs +# relative to the repo root. Owner of this file: Windy Git lane (13); changes +# go through the orchestrator. Source of the first entries: COMPUTE_BYPASS_AUDIT.md. +allow: + - repo: windy-mind + paths: ["*"] + reason: "Windy Mind IS the door: provider clients belong here by definition." + + - repo: windy-agent + paths: ["*"] + reason: >- + User BYOK: self-hosted agents call providers on the USER's own keys. + Mind stays opt-in there, or every self-hosted user's inference lands on + Grant's bill (no-cloud-cost-liability rule; audit #7). + + - repo: windy-code + paths: ["extensions/windy-ai/*"] + reason: "User BYOK AI extension: the user's own provider keys; Mind is one opt-in provider (audit #8)." + + - repo: windy-connect + paths: ["*writers/*"] + reason: "Writes client configs that NAME the user's own provider env vars; makes no provider calls (audit #11)." + + - repo: windy-pro + paths: ["src/client/desktop/*"] + reason: >- + User BYOK desktop client: cloud STT/translate keys come from what the USER + enters (renderer localStorage -> electron-store; env var only for dev), and + the CSP line allows exactly those user-keyed hosts (audit #10). The + account-server is NOT covered: server-side calls go through Mind. + + - repo: windy-pro + paths: ["src/client/web/src/pages/panels/MindPanel.jsx"] + reason: "Validates the USER's own OpenRouter key for BYOK (audit #10); spends no house money." + + - repo: windy-git + paths: ["scripts/compute_guard.py", "ci/compute-guard-allow.yml"] + reason: "The guard's own pattern list and this file." diff --git a/scripts/compute_guard.py b/scripts/compute_guard.py new file mode 100644 index 0000000..87a6061 --- /dev/null +++ b/scripts/compute_guard.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Compute guard: Windy Mind is the ONLY door to AI compute (Grant, 2026-09-23). + +Flags code that talks to an AI provider directly instead of through Windy Mind: +a provider API host, a provider SDK import or dependency, or a raw provider key +name. Direct calls skip Mind's metering, caps and live-model routing, and they +spend whichever key happens to be lying around (the audit found Grant's personal +Max OAuth token inside a platform container). + +WARN-ONLY for now: the bridge posts `windy-git/compute-guard` as success with a +"⚠ WARN" description, so nothing turns red. `COMPUTE_GUARD_MODE=block` flips +findings to failure once the repos are clean (orchestrator's call). + +- PR heads: only lines the PR ADDS (vs its merge-base with the default branch). +- Default-branch head: the whole tree (the baseline, and what `report` prints). + +Exceptions live in ONE file, ci/compute-guard-allow.yml, each with a reason. +Tests, docs, lockfiles, vendored code and CI config are never scanned. +Reads the sync's bare GitHub clones on Veron (no docker exec: IO-stall lesson). + + python3 scripts/compute_guard.py report [repo ...] # whole-tree findings on each default branch +""" + +from __future__ import annotations + +import fnmatch +import hashlib +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +ALLOW_FILE = Path(os.environ.get("COMPUTE_GUARD_ALLOW", ROOT / "ci" / "compute-guard-allow.yml")) +WORK = Path(os.environ.get("SYNC_WORK", "/srv/windygit/sync")) +CACHE = Path(os.environ.get("COMPUTE_GUARD_CACHE", "/var/lib/windy-git/compute-guard-cache.json")) +MODE = os.environ.get("COMPUTE_GUARD_MODE", "warn") # warn | block + +HOSTS = [ + "api.anthropic.com", "api.openai.com", "api.groq.com", + "generativelanguage.googleapis.com", "api.mistral.ai", "api.perplexity.ai", + "openrouter.ai", "api.together.xyz", "api.together.ai", "api.cerebras.ai", + "api.sambanova.ai", "api.deepseek.com", "api.x.ai", "api.cohere.ai", + "api.cohere.com", "api.fireworks.ai", "api.replicate.com", + "api-inference.huggingface.co", +] +KEYS = [ + "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", "GROQ_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", + "GOOGLE_AI_API_KEY", "MISTRAL_API_KEY", "PERPLEXITY_API_KEY", "PPLX_API_KEY", + "OPENROUTER_API_KEY", "TOGETHER_API_KEY", "CEREBRAS_API_KEY", "SAMBANOVA_API_KEY", + "DEEPSEEK_API_KEY", "XAI_API_KEY", "COHERE_API_KEY", "FIREWORKS_API_KEY", + "REPLICATE_API_TOKEN", +] +PY_SDKS = r"anthropic|openai|groq|mistralai|cohere|google\.generativeai|google\.genai|together|cerebras|litellm" +JS_SDKS = (r"@anthropic-ai/sdk|openai|groq-sdk|@google/generative-ai|@google/genai|@mistralai/mistralai" + r"|cohere-ai|together-ai|@ai-sdk/(?:anthropic|openai|groq|google|mistral)") + +RULES: list[tuple[str, re.Pattern]] = [ + ("provider host", re.compile("|".join(re.escape(h) for h in HOSTS))), + ("provider key", re.compile(r"\b(?:" + "|".join(KEYS) + r")\b")), + ("provider SDK", re.compile(rf"^\s*(?:from|import)\s+(?:{PY_SDKS})(?:\s|\.|$|,)")), + ("provider SDK", re.compile(rf"""(?:from\s+|require\(\s*|import\(\s*)['"](?:{JS_SDKS})(?:/[^'"]*)?['"]""")), + # dependency manifests: package.json keys, requirements / pyproject lines + ("provider SDK dep", re.compile(rf'''^\s*"(?:{JS_SDKS})"\s*:''')), + ("provider SDK dep", re.compile(rf'''^\s*["']?(?:{PY_SDKS.replace(chr(92) + ".", "-")})(?:\[[^\]]*\])?\s*(?:[<>=~!]=?|["',]|$)''')), +] +DEP_FILES = re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|setup\.cfg|Pipfile)$") + +# Never scanned: tests, docs, lockfiles, vendored/built code, CI config. +SKIP = re.compile( + r"(^|/)(tests?|__tests__|spec|docs?|node_modules|vendor|dist|build|\.github|\.gitea)/" + r"|(^|/)(test_[^/]*|[^/]*_test\.py|conftest\.py|[^/]*\.(test|spec)\.[cm]?[jt]sx?)$" + r"|\.(md|mdx|rst|txt|lock|snap|svg|png|jpg|pdf)$" + r"|(^|/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|uv\.lock|poetry\.lock|Cargo\.lock)$" +) + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + kind: str + match: str + + +def load_allow(path: Path = ALLOW_FILE) -> list[dict]: + data = yaml.safe_load(path.read_text()) or {} + entries = data.get("allow") or [] + for e in entries: # a reason per entry is the whole point of the file + if not (e.get("repo") and e.get("paths") and str(e.get("reason", "")).strip()): + raise ValueError(f"allow entry needs repo, paths and a reason: {e}") + return entries + + +def allowed(repo: str, path: str, allow: list[dict]) -> bool: + for e in allow: + if e["repo"] == repo and any(fnmatch.fnmatch(path, g) for g in e["paths"]): + return True + return False + + +COMMENT = re.compile(r"^\s*(?:#|//|/\*|\*|