From 5fa1e652ae07c0bae9e5ee69b5178e9e10e2e39e Mon Sep 17 00:00:00 2001 From: Kit OC5 Date: Fri, 2 Oct 2026 18:11:07 -0400 Subject: [PATCH] compute-guard: gatekeeper rules (Mind 10-02) + allow-list exemptions that expire New kinds: voice-ai host/key (Deepgram, ElevenLabs, Cartesia, PlayHT, Resemble, HeyGen, Google Vision/Speech/TTS, AWS Transcribe/Polly), cloudflare workers ai (REST /ai/ + wrangler [ai] binding), talk engine port (:8791/:8788/:8794/:8099). Own kinds so they roll out WARN-first via COMPUTE_GUARD_WARN_KINDS. Allow entries now need a named exemption (local-user-hardware | owner-approved | compute-door | guard-self) and an expires date; expired entries stop excusing code and are reported. ci-hygiene keeps its own (non-strict) format. Co-Authored-By: Claude Sonnet 5.5 --- api/tests/test_ci_hygiene.py | 2 +- api/tests/test_compute_guard.py | 80 +++++++++++++++++++++++++++++++++ ci/compute-guard-allow.yml | 22 ++++++++- scripts/ci_hygiene.py | 4 +- scripts/compute_guard.py | 75 ++++++++++++++++++++++++++++--- 5 files changed, 173 insertions(+), 10 deletions(-) diff --git a/api/tests/test_ci_hygiene.py b/api/tests/test_ci_hygiene.py index b257634..b22ac6c 100644 --- a/api/tests/test_ci_hygiene.py +++ b/api/tests/test_ci_hygiene.py @@ -86,7 +86,7 @@ def test_warn_mode_never_turns_red(monkeypatch): def test_allow_file_is_line_scoped_exceptions_only(): """Every exception is line-scoped (`matches`), so an allowed file can't hide a NEW floating install or docker step. Today: windy-pro's if:false deploy job.""" - allow = hy.cg.load_allow(hy.ALLOW_FILE) + allow = hy.cg.load_allow(hy.ALLOW_FILE, strict=False) assert [(e["repo"], e["paths"]) for e in allow] == [("windy-pro", [".github/workflows/ci.yml"])] assert all(e.get("matches") for e in allow) ok = " run: docker build -f account-server/Dockerfile -t windy-pro:${{ github.sha }} ." diff --git a/api/tests/test_compute_guard.py b/api/tests/test_compute_guard.py index df09b5f..8397a58 100644 --- a/api/tests/test_compute_guard.py +++ b/api/tests/test_compute_guard.py @@ -77,6 +77,86 @@ def test_allow_list_needs_a_reason_per_entry(tmp_path): cg.load_allow(bad) +def _entry(**kw): + base = dict(repo="x", paths=["*"], reason="r", exemption="owner-approved", expires="2099-01-01") + base.update(kw) + lines = ["allow:", " - repo: x", " paths: ['*']", " reason: r"] + for k in ("exemption", "expires"): + if base.get(k) is not None: + lines.append(f" {k}: {base[k]}") + return "\n".join(lines) + "\n" + + +def test_allow_entries_need_a_named_exemption_and_an_expiry(tmp_path): + f = tmp_path / "a.yml" + f.write_text(_entry(exemption=None)) + with pytest.raises(ValueError): + cg.load_allow(f) + f.write_text(_entry(exemption="because-i-said-so")) + with pytest.raises(ValueError): + cg.load_allow(f) + f.write_text(_entry(expires=None)) + with pytest.raises(ValueError): + cg.load_allow(f) + f.write_text(_entry(expires="someday")) + with pytest.raises(ValueError): + cg.load_allow(f) + f.write_text(_entry()) + assert len(cg.load_allow(f)) == 1 + + +def test_expired_exemption_stops_excusing_and_is_reported(tmp_path): + from datetime import date + f = tmp_path / "a.yml" + f.write_text(_entry(expires="2026-10-01")) + assert cg.load_allow(f, today=date(2026, 10, 1)) # the expiry day is still valid + assert cg.load_allow(f, today=date(2026, 10, 2)) == [] # the next day it no longer excuses anything + assert cg.EXPIRED and cg.EXPIRED[0]["repo"] == "x" and cg.EXPIRED[0]["expires"] == "2026-10-01" + + +def test_shipped_allow_file_is_valid_today(): + assert cg.load_allow() and not cg.EXPIRED # nothing in the repo's own file may already be expired + + +@pytest.mark.parametrize("text, kind", [ + ('u = "https://api.deepgram.com/v1/listen"', "voice-ai host"), + ("fetch(`https://api.elevenlabs.io/v1/tts`)", "voice-ai host"), + ('h = "api.cartesia.ai"', "voice-ai host"), + ('h = "app.resemble.ai"', "voice-ai host"), + ('h = "speech.googleapis.com"', "voice-ai host"), + ('h = "transcribe.us-east-1.amazonaws.com"', "voice-ai host"), + ('h = "polly.eu-west-1.amazonaws.com"', "voice-ai host"), + ("DEEPGRAM_API_KEY=abc", "voice-ai key"), + ("ELEVENLABS_API_KEY = x", "voice-ai key"), + ('u = f"https://api.cloudflare.com/client/v4/accounts/{a}/ai/run/@cf/m"', "cloudflare workers ai"), + ('ENGINE = "http://10.0.0.5:8791/v1"', "talk engine port"), + ('ENGINE = "http://h:8788/ws"', "talk engine port"), + ('x = "http://h:8099/health"', "talk engine port"), +]) +def test_gatekeeper_rules_fire(text, kind): + assert kind in [k for k, _ in cg.scan_line("app/x.py", text)] + + +def test_workers_ai_binding_only_in_wrangler_and_near_misses_are_quiet(): + assert [k for k, _ in cg.scan_line("wrangler.toml", "[ai]")] == ["workers ai binding"] + assert [k for k, _ in cg.scan_line("apps/x/wrangler.jsonc", ' "ai": {')] == ["workers ai binding"] + assert cg.scan_line("other.toml", "[ai]") == [] + assert cg.scan_line("app/x.py", "port = 87912") == [] + assert cg.scan_line("app/x.py", "# talk engine was :8791 (removed)") == [] + + +def test_rolling_out_kinds_warn_but_dont_block_and_hard_kinds_still_do(monkeypatch): + monkeypatch.setattr(cg, "MODE", "block") + monkeypatch.setattr(cg, "SOFT_KINDS", {"voice-ai host", "voice-ai key"}) + soft = cg.Finding("a.py", 1, "voice-ai host", "api.deepgram.com") + hard = cg.Finding("a.py", 2, "provider host", "api.openai.com") + state, desc, _ = cg.status_for([soft], whole_tree=True) + assert state == "success" and "rolling out" in desc and len(desc) <= 140 + assert cg.status_for([soft, hard], whole_tree=True)[0] == "failure" + monkeypatch.setattr(cg, "SOFT_KINDS", set()) + assert cg.status_for([soft], whole_tree=True)[0] == "failure" # rollout over: it blocks + + @pytest.mark.parametrize( "repo, path, ok", [ diff --git a/ci/compute-guard-allow.yml b/ci/compute-guard-allow.yml index 29f400a..589258a 100644 --- a/ci/compute-guard-allow.yml +++ b/ci/compute-guard-allow.yml @@ -2,11 +2,15 @@ # 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. +# go through the orchestrator. EVERY entry needs `exemption` (local-user-hardware | owner-approved | +# compute-door | guard-self) and `expires` (YYYY-MM-DD): nothing gets permanent amnesty (Mind 10-02); +# an expired entry stops excusing code on that date and shows in the guard report. 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." + exemption: compute-door + expires: 2027-10-02 - repo: windy-agent paths: ["*"] @@ -14,14 +18,20 @@ allow: 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). + exemption: owner-approved + expires: 2026-12-31 - 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)." + exemption: owner-approved + expires: 2026-12-31 - repo: windy-connect paths: ["*writers/*"] reason: "Writes client configs that NAME the user's own provider env vars; makes no provider calls (audit #11)." + exemption: owner-approved + expires: 2026-12-31 - repo: windy-pro paths: ["src/client/desktop/*"] @@ -30,10 +40,14 @@ allow: 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. + exemption: owner-approved + expires: 2026-12-31 - 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." + exemption: owner-approved + expires: 2026-12-31 - repo: windy-pro paths: ["src/client/web/src/pages/panels/MindKeychain.jsx"] @@ -41,12 +55,18 @@ allow: # /api/v1/chat, i.e. inference) still flags. Orchestrator-approved 09-23. matches: ['openrouter\.ai/auth\?', 'openrouter\.ai/api/v1/auth/keys'] reason: "BYOK key acquisition via OpenRouter OAuth PKCE; no inference; successor of MindPanel allow (ADR-064)." + exemption: owner-approved + expires: 2026-12-31 - repo: windy-git paths: ["scripts/compute_guard.py", "ci/compute-guard-allow.yml"] reason: "The guard's own pattern list and this file." + exemption: guard-self + expires: 2027-10-02 - repo: windy-mind paths: ["*"] matches: [':11434'] reason: "Windy Mind IS the compute door (endpoint + key); it may call Ollama. Only the Ollama port is allowed here, any provider host/SDK in Mind still flags." + exemption: compute-door + expires: 2027-10-02 diff --git a/scripts/ci_hygiene.py b/scripts/ci_hygiene.py index 9a6df68..080b8c4 100644 --- a/scripts/ci_hygiene.py +++ b/scripts/ci_hygiene.py @@ -204,7 +204,7 @@ def _check(repo: str, sha: str, default_branch: str, is_default_head: bool): bare = cg.WORK / f"{repo}.git" if not bare.is_dir() or not cg.fetched(bare, sha): # pushed after the fetch: next cycle return None - allow = cg.load_allow(ALLOW_FILE) + allow = cg.load_allow(ALLOW_FILE, strict=False) rules = hashlib.sha256((PREFILTER + INCLUDE.pattern + EXACT_PY.pattern + EXACT_NPM.pattern).encode()).hexdigest()[:8] fp = cg._fingerprint(allow) + ":" + rules # hashlib, not hash(): hash() is per-process random kw = dict(line_fn=scan_line, path_ok=path_ok) @@ -232,7 +232,7 @@ def status_for(findings, whole_tree: bool, grant=()): def report(repos: list[str]) -> int: - allow = cg.load_allow(ALLOW_FILE) + allow = cg.load_allow(ALLOW_FILE, strict=False) total = 0 for repo in repos: bare = cg.WORK / f"{repo}.git" diff --git a/scripts/compute_guard.py b/scripts/compute_guard.py index 5133591..04a9af0 100644 --- a/scripts/compute_guard.py +++ b/scripts/compute_guard.py @@ -31,6 +31,7 @@ import re import subprocess import sys from dataclasses import dataclass +from datetime import date from pathlib import Path import yaml @@ -49,6 +50,17 @@ HOSTS = [ "api.cohere.com", "api.fireworks.ai", "api.replicate.com", "api-inference.huggingface.co", ] +# Mind's 10-02 gatekeeper list (speech / voice / avatar / vision / cloud ML): own kinds, so a rollout +# can be WARN-first (COMPUTE_GUARD_WARN_KINDS) without softening the original provider rules. +VOICE_HOSTS = [ + "api.deepgram.com", "api.elevenlabs.io", "api.cartesia.ai", "api.play.ht", "api.playht.com", + "app.resemble.ai", "f.cluster.resemble.ai", "api.heygen.com", + "vision.googleapis.com", "speech.googleapis.com", "texttospeech.googleapis.com", +] +VOICE_KEYS = [ + "DEEPGRAM_API_KEY", "ELEVENLABS_API_KEY", "ELEVEN_API_KEY", "CARTESIA_API_KEY", "PLAYHT_API_KEY", + "PLAY_HT_API_KEY", "PLAYHT_USER_ID", "RESEMBLE_API_KEY", "HEYGEN_API_KEY", +] KEYS = [ "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "GROQ_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", @@ -61,11 +73,21 @@ PY_SDKS = r"anthropic|openai|groq|mistralai|cohere|google\.generativeai|google\. 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)") +WRANGLER = re.compile(r"(^|/)wrangler\.(toml|jsonc?)$") +WRANGLER_AI = re.compile(r'^\s*\[ai\]\s*$|^\s*"ai"\s*:\s*\{') + RULES: list[tuple[str, re.Pattern]] = [ ("provider host", re.compile("|".join(re.escape(h) for h in HOSTS))), # Grant via Boss 10-01: compute = Windy Mind. A NEW reference to an Ollama port (Veron's :11434) is a # direct call around Mind's metering/caps. WARN-only, never red, and only for lines a PR ADDS. ("veron ollama", re.compile(r"(?::|%3[aA])11434(?![0-9])")), + ("voice-ai host", re.compile("|".join(re.escape(h) for h in VOICE_HOSTS))), + ("voice-ai key", re.compile(r"\b(?:" + "|".join(VOICE_KEYS) + r")\b")), + ("voice-ai host", re.compile(r"(?:transcribe|polly)\.[a-z0-9-]+\.amazonaws\.com")), + ("provider host", re.compile(r"(?:bedrock-runtime|bedrock)\.[a-z0-9-]+\.amazonaws\.com")), + ("cloudflare workers ai", re.compile(r"api\.cloudflare\.com/client/v4/accounts/[^\s'\"/]+/ai/")), + ("talk engine port", re.compile(r"(?::|%3[aA])(?:8791|8788|8794|8099)(?![0-9])")), + ("workers ai binding", WRANGLER_AI), ("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})(?:/[^'"]*)?['"]""")), @@ -75,6 +97,9 @@ RULES: list[tuple[str, re.Pattern]] = [ ] # Kinds that never block (even in MODE=block) and are only judged on ADDED lines, never the baseline tree. WARN_ONLY_KINDS = {"veron ollama"} +# Rolled out WARN-first: these kinds still show (tree + PRs) but never block, until the env var +# (a systemd drop-in on the sync, like SECRET_GUARD_WARN_KINDS) is removed. +SOFT_KINDS = {k for k in os.environ.get("COMPUTE_GUARD_WARN_KINDS", "").split(",") if k} OLLAMA_MSG = "compute = Windy Mind (endpoint + key); do not call Veron's Ollama directly" DEP_FILES = re.compile(r"(^|/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|setup\.cfg|Pipfile)$") @@ -95,13 +120,36 @@ class Finding: match: str -def load_allow(path: Path = ALLOW_FILE) -> list[dict]: +EXEMPTIONS = {"local-user-hardware", "owner-approved", "compute-door", "guard-self"} +EXPIRED: list[dict] = [] # entries dropped as expired on the last load_allow (reported, never silent) + + +def load_allow(path: Path = ALLOW_FILE, today: date | None = None, strict: bool = True) -> list[dict]: + """Active entries only. Every entry needs repo, paths, a reason, a NAMED exemption and an expiry + date (Mind 10-02: nothing gets permanent amnesty). An expired entry stops excusing code at once. + `strict=False` is for OTHER guards reusing this loader (ci-hygiene) with their own file format.""" + today = today or date.today() 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 + active = [] + EXPIRED.clear() + for e in entries: 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 + if not strict: + active.append(e) + continue + if e.get("exemption") not in EXEMPTIONS: + raise ValueError(f"allow entry needs exemption in {sorted(EXEMPTIONS)}: {e.get('repo')} {e.get('paths')}") + try: + exp = e["expires"] if isinstance(e.get("expires"), date) else date.fromisoformat(str(e.get("expires"))) + except ValueError as err: + raise ValueError(f"allow entry needs expires: YYYY-MM-DD: {e.get('repo')} {e.get('paths')}") from err + if exp < today: + EXPIRED.append({**e, "expires": exp.isoformat()}) + else: + active.append(e) + return active def allowed(repo: str, path: str, allow: list[dict], text: str | None = None) -> bool: @@ -132,6 +180,8 @@ def scan_line(path: str, text: str) -> list[tuple[str, str]]: for kind, rx in RULES: if kind == "provider SDK dep" and not DEP_FILES.search(path): continue + if kind == "workers ai binding" and not WRANGLER.search(path): + continue m = rx.search(text) if m: hits.append((kind, m.group(0).strip()[:60])) @@ -156,9 +206,11 @@ def scan_tree(repo: str, bare: Path, sha: str, allow: list[dict], *, line_fn=Non # Other guards (ci_hygiene) reuse this walker with their own line rules. line_fn = line_fn or scan_line path_ok = path_ok or _default_path_ok - pre = prefilter or "|".join([re.escape(h) for h in HOSTS] + KEYS + [ + pre = prefilter or "|".join([re.escape(h) for h in HOSTS + VOICE_HOSTS] + KEYS + VOICE_KEYS + [ "anthropic", "openai", "groq", "mistral", "generativeai", "genai", "cohere", - "together", "cerebras", "litellm"]) + "together", "cerebras", "litellm", "deepgram", "elevenlabs", "cartesia", "play\\.ht", "resemble", + "heygen", "googleapis\\.com", "amazonaws\\.com", "api\\.cloudflare\\.com", ":8791", ":8788", + ":8794", ":8099", "%3[aA]87", "%3[aA]8099", r"^\s*\[ai\]", '"ai"']) try: out = _git(bare, "grep", "-nIE", "-e", pre, sha, "--", ".") except subprocess.CalledProcessError as e: @@ -215,7 +267,8 @@ def parse_added(repo: str, diff: str, allow: list[dict], *, line_fn=None, path_o # ---- cache: a tree scan runs once per (repo, sha, rules+allow) -------------- def _fingerprint(allow: list[dict]) -> str: return hashlib.sha256( - json.dumps([HOSTS, KEYS, PY_SDKS, JS_SDKS, SKIP.pattern, allow], sort_keys=True).encode() + json.dumps([HOSTS, KEYS, VOICE_HOSTS, VOICE_KEYS, [r.pattern for _, r in RULES], PY_SDKS, JS_SDKS, + SKIP.pattern, allow], sort_keys=True, default=str).encode() ).hexdigest()[:16] @@ -280,6 +333,13 @@ def status_for(findings: list[Finding], whole_tree: bool, if not findings and not grant and soft: f = soft[0] return "success", f"⚠ WARN: new Veron Ollama ref {f.path}:{f.line}. {OLLAMA_MSG}"[:140], f + rolling = [f for f in findings if f.kind in SOFT_KINDS] + if findings and len(rolling) == len(findings) and not grant: + f, n = rolling[0], len(rolling) + return "success", (f"⚠ WARN (rolling out, not blocking): {n} direct AI-provider use{'s' if n > 1 else ''} " + f"{scope}, e.g. {f.path}:{f.line} {f.match}")[:140], f + if rolling: + findings = [f for f in findings if f.kind not in SOFT_KINDS] if not findings and grant: g, n = grant[0], len(grant) desc = (f"⚠ WARN (Grant-owned, not blocking): {n} direct AI-provider use{'s' if n > 1 else ''} " @@ -298,6 +358,9 @@ def status_for(findings: list[Finding], whole_tree: bool, def report(repos: list[str]) -> int: allow = load_allow() + for e in EXPIRED: + print(f"## EXPIRED exemption (no longer excuses anything): {e['repo']} {e['paths']} " + f"[{e['exemption']}] expired {e['expires']}") total = 0 for repo in repos: bare = WORK / f"{repo}.git"