diff --git a/api/tests/test_compute_guard.py b/api/tests/test_compute_guard.py index 2a28699..f95c6bf 100644 --- a/api/tests/test_compute_guard.py +++ b/api/tests/test_compute_guard.py @@ -191,3 +191,32 @@ def test_a_commit_not_fetched_yet_is_skipped_not_an_error(tmp_path, monkeypatch) (tmp_path / "windy-chat.git").symlink_to(bare) assert cg.check("windy-chat", "f" * 40, "main", True) is None # pushed after the fetch assert [f.kind for f in cg.check("windy-chat", sha, "main", True)] == ["provider SDK"] + + +KEYCHAIN = "src/client/web/src/pages/panels/MindKeychain.jsx" + + +def test_scoped_allow_admits_only_the_oauth_endpoints(): + ok = [ + " window.location.href = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(callback)}`", + " const res = await fetch('https://openrouter.ai/api/v1/auth/keys', {", + ] + for line in ok: + assert cg.allowed("windy-pro", KEYCHAIN, ALLOW, line) + # an inference call smuggled into the same file still flags + assert not cg.allowed("windy-pro", KEYCHAIN, ALLOW, + " await fetch('https://openrouter.ai/api/v1/chat/completions', {") + # a scoped entry never allows a line it can't see + assert not cg.allowed("windy-pro", KEYCHAIN, ALLOW) + + +def test_scoped_allow_in_a_real_diff(): + diff = f"""--- /dev/null ++++ b/{KEYCHAIN} +@@ -0,0 +1,3 @@ ++ window.location.href = `https://openrouter.ai/auth?callback_url=x` ++ const res = await fetch('https://openrouter.ai/api/v1/auth/keys', {{ ++ await fetch('https://openrouter.ai/api/v1/chat/completions', {{ +""" + fs = cg.parse_added("windy-pro", diff, ALLOW) + assert [(f.line, f.match) for f in fs] == [(3, "openrouter.ai")] diff --git a/ci/compute-guard-allow.yml b/ci/compute-guard-allow.yml index 7683d94..c71832f 100644 --- a/ci/compute-guard-allow.yml +++ b/ci/compute-guard-allow.yml @@ -35,6 +35,13 @@ allow: 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-pro + paths: ["src/client/web/src/pages/panels/MindKeychain.jsx"] + # ONLY these two endpoints: any other openrouter.ai call in this file (e.g. + # /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)." + - 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 index b5ef67a..389a216 100644 --- a/scripts/compute_guard.py +++ b/scripts/compute_guard.py @@ -98,9 +98,18 @@ def load_allow(path: Path = ALLOW_FILE) -> list[dict]: return entries -def allowed(repo: str, path: str, allow: list[dict]) -> bool: +def allowed(repo: str, path: str, allow: list[dict], text: str | None = None) -> bool: + """An entry may carry `matches:` (regexes): then only lines matching one of + them are allowed, so an allowed file can't smuggle in a NEW call (e.g. an + OAuth sign-in endpoint is allowed, an inference endpoint in the same file + still flags). Entries without `matches` cover the whole path.""" for e in allow: - if e["repo"] == repo and any(fnmatch.fnmatch(path, g) for g in e["paths"]): + if e["repo"] != repo or not any(fnmatch.fnmatch(path, g) for g in e["paths"]): + continue + pats = e.get("matches") + if not pats: + return True + if text is not None and any(re.search(rx, text) for rx in pats): return True return False @@ -157,7 +166,7 @@ def scan_tree(repo: str, bare: Path, sha: str, allow: list[dict], *, line_fn=Non _, path, line, text = raw.split(":", 3) except ValueError: continue - if not path_ok(path) or allowed(repo, path, allow): + if not path_ok(path) or allowed(repo, path, allow, text): continue for kind, match in line_fn(path, text): found.append(Finding(path, int(line), kind, match)) @@ -190,7 +199,7 @@ def parse_added(repo: str, diff: str, allow: list[dict], *, line_fn=None, path_o if path is None or raw.startswith("--- "): continue if raw.startswith("+"): - if path_ok(path) and not allowed(repo, path, allow): + if path_ok(path) and not allowed(repo, path, allow, raw[1:]): for kind, match in line_fn(path, raw[1:]): found.append(Finding(path, line, kind, match)) line += 1