7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
285 lines
8.3 KiB
Python
Executable File
285 lines
8.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Pre-commit hook to validate hardcoded settings key strings against the
|
|
allow/block lists defined in settings_routes.py.
|
|
|
|
Catches new settings keys that would be rejected by the runtime namespace
|
|
gate before they reach the release server.
|
|
"""
|
|
|
|
import ast
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SETTINGS_ROUTES = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "src"
|
|
/ "local_deep_research"
|
|
/ "web"
|
|
/ "routes"
|
|
/ "settings_routes.py"
|
|
)
|
|
|
|
WRITE_FUNC_NAMES = {"set_setting"}
|
|
|
|
# Regex patterns for JS files
|
|
JS_SAVE_SETTING_RE = re.compile(r"saveSetting\s*\(\s*['\"]([^'\"]+)['\"]")
|
|
JS_SETTINGS_API_URL_RE = re.compile(
|
|
r"['\"]\/settings\/api\/([a-z][a-z0-9_]*\.[a-z0-9_.]+)['\"]"
|
|
)
|
|
JS_INLINE_COMMENT_RE = re.compile(r"/\*.*?\*/")
|
|
|
|
# Excluded paths/directories
|
|
SKIP_PATH_SEGMENTS = {
|
|
"tests",
|
|
"test",
|
|
".pre-commit-hooks",
|
|
"migrations",
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"vendor",
|
|
"defaults", # JSON schema files define keys — not write call sites
|
|
}
|
|
|
|
SKIP_NAME_PREFIXES = ("test_", "conftest")
|
|
SKIP_NAME_SUFFIXES = ("_test.py", "_test.js", ".min.js")
|
|
SKIP_EXACT_NAMES = {"settings_routes.py"}
|
|
|
|
|
|
def load_prefixes():
|
|
"""Parse ALLOWED_SETTING_PREFIXES and BLOCKED_SETTING_PREFIXES
|
|
from settings_routes.py using AST."""
|
|
try:
|
|
source = SETTINGS_ROUTES.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
print(
|
|
f"FATAL: Cannot find settings_routes.py at {SETTINGS_ROUTES}\n"
|
|
"The hook cannot validate settings keys without it."
|
|
)
|
|
sys.exit(1)
|
|
|
|
tree = ast.parse(source, filename=str(SETTINGS_ROUTES))
|
|
allowed = None
|
|
blocked = None
|
|
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
for target in node.targets:
|
|
if not isinstance(target, ast.Name):
|
|
continue
|
|
if target.id == "ALLOWED_SETTING_PREFIXES":
|
|
allowed = _extract_frozenset_strings(node.value)
|
|
elif target.id == "BLOCKED_SETTING_PREFIXES":
|
|
blocked = _extract_frozenset_strings(node.value)
|
|
|
|
if allowed is None or blocked is None:
|
|
print(
|
|
"FATAL: Could not parse ALLOWED/BLOCKED_SETTING_PREFIXES "
|
|
f"from {SETTINGS_ROUTES}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
return allowed, blocked
|
|
|
|
|
|
def _extract_frozenset_strings(node):
|
|
"""Extract string values from frozenset({...}) or frozenset((...,))."""
|
|
if not isinstance(node, ast.Call):
|
|
return None
|
|
if not (isinstance(node.func, ast.Name) and node.func.id == "frozenset"):
|
|
return None
|
|
if not node.args:
|
|
return None
|
|
|
|
collection = node.args[0]
|
|
strings = set()
|
|
if isinstance(collection, (ast.Set, ast.Tuple, ast.List)):
|
|
for elt in collection.elts:
|
|
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
strings.add(elt.value)
|
|
return strings if strings else None
|
|
|
|
|
|
def is_key_allowed(key, allowed, blocked):
|
|
"""Mirrors _is_allowed_new_setting_key from settings_routes.py."""
|
|
if not isinstance(key, str) or not key or ".." in key:
|
|
return False
|
|
key_lower = key.lower()
|
|
for prefix in blocked:
|
|
if key_lower.startswith(prefix):
|
|
return False
|
|
for prefix in allowed:
|
|
if key_lower.startswith(prefix):
|
|
return True
|
|
return False
|
|
|
|
|
|
def should_skip(filepath):
|
|
"""Check if a file should be skipped."""
|
|
p = Path(filepath)
|
|
name = p.name
|
|
|
|
if name in SKIP_EXACT_NAMES:
|
|
return True
|
|
if name.startswith(SKIP_NAME_PREFIXES):
|
|
return True
|
|
if name.endswith(SKIP_NAME_SUFFIXES):
|
|
return True
|
|
if SKIP_PATH_SEGMENTS.intersection(p.parts):
|
|
return True
|
|
return False
|
|
|
|
|
|
class SettingsKeyChecker(ast.NodeVisitor):
|
|
"""AST visitor for Python files — detects set_setting("key", ...) calls."""
|
|
|
|
def __init__(self, filename, allowed, blocked):
|
|
self.filename = filename
|
|
self.allowed = allowed
|
|
self.blocked = blocked
|
|
self.errors = []
|
|
|
|
def visit_Call(self, node):
|
|
key = self._extract_write_key(node)
|
|
if key is not None and not is_key_allowed(
|
|
key, self.allowed, self.blocked
|
|
):
|
|
self.errors.append((node.lineno, key))
|
|
self.generic_visit(node)
|
|
|
|
def _extract_write_key(self, node):
|
|
"""Extract a hardcoded settings key from a set_setting call.
|
|
|
|
Returns the key string if found, or None if the call is not
|
|
applicable or uses a dynamic key.
|
|
"""
|
|
# Pattern 1: set_setting("key", ...)
|
|
if isinstance(node.func, ast.Name) and node.func.id in WRITE_FUNC_NAMES:
|
|
return self._first_string_arg(node)
|
|
|
|
# Pattern 2: obj.set_setting("key", ...)
|
|
if (
|
|
isinstance(node.func, ast.Attribute)
|
|
and node.func.attr in WRITE_FUNC_NAMES
|
|
):
|
|
return self._first_string_arg(node)
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _first_string_arg(node):
|
|
"""Return the first positional arg if it's a settings-key-like string.
|
|
|
|
A settings key contains at least one dot (e.g. "llm.provider") or
|
|
underscore (e.g. "local_search_embedding_model"). Bare words like
|
|
"value" or "config" are not settings keys and are skipped to avoid
|
|
false positives on non-settings call sites.
|
|
"""
|
|
if node.args and isinstance(node.args[0], ast.Constant):
|
|
val = node.args[0].value
|
|
if (
|
|
isinstance(val, str)
|
|
and len(val) > 2
|
|
and ("." in val or "_" in val)
|
|
):
|
|
return val
|
|
return None
|
|
|
|
|
|
def check_python_file(filepath, allowed, blocked):
|
|
"""Check a Python file for settings key namespace violations."""
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
except Exception:
|
|
return []
|
|
|
|
try:
|
|
tree = ast.parse(content, filename=filepath)
|
|
except SyntaxError:
|
|
return []
|
|
|
|
checker = SettingsKeyChecker(filepath, allowed, blocked)
|
|
checker.visit(tree)
|
|
return checker.errors
|
|
|
|
|
|
def check_js_file(filepath, allowed, blocked):
|
|
"""Check a JS file for settings key namespace violations."""
|
|
errors = []
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
lines = f.readlines()
|
|
except Exception:
|
|
return []
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
stripped = line.strip()
|
|
if stripped.startswith("//") or stripped.startswith("*"):
|
|
continue
|
|
|
|
# Strip inline /* ... */ comments to avoid false positives
|
|
line = JS_INLINE_COMMENT_RE.sub("", line)
|
|
|
|
# saveSetting('key', ...)
|
|
for m in JS_SAVE_SETTING_RE.finditer(line):
|
|
key = m.group(1)
|
|
if not is_key_allowed(key, allowed, blocked):
|
|
errors.append((i, key))
|
|
|
|
# '/settings/api/key' literal URLs
|
|
for m in JS_SETTINGS_API_URL_RE.finditer(line):
|
|
key = m.group(1)
|
|
if not is_key_allowed(key, allowed, blocked):
|
|
errors.append((i, key))
|
|
|
|
return errors
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: check-settings-key-namespace.py <file1> <file2> ...")
|
|
sys.exit(1)
|
|
|
|
allowed, blocked = load_prefixes()
|
|
all_errors = []
|
|
|
|
for filepath in sys.argv[1:]:
|
|
if should_skip(filepath):
|
|
continue
|
|
|
|
if filepath.endswith(".py"):
|
|
errors = check_python_file(filepath, allowed, blocked)
|
|
elif filepath.endswith((".js", ".mjs")):
|
|
errors = check_js_file(filepath, allowed, blocked)
|
|
else:
|
|
continue
|
|
|
|
if errors:
|
|
all_errors.append((filepath, errors))
|
|
|
|
if all_errors:
|
|
allowed_sorted = sorted(allowed)
|
|
print("\nSettings key namespace violations found:\n")
|
|
for filepath, errors in all_errors:
|
|
print(f"{filepath}:")
|
|
for line_num, key in errors:
|
|
print(
|
|
f" Line {line_num}: '{key}' does not match any allowed prefix"
|
|
)
|
|
print(
|
|
f"\nAllowed prefixes: {', '.join(allowed_sorted)}\n"
|
|
"Fix: Add the prefix to ALLOWED_SETTING_PREFIXES in "
|
|
"settings_routes.py, or rename the key."
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|