diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e731782..2ecc24b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main, master] jobs: - test-and-quality: + quality: runs-on: ubuntu-latest strategy: fail-fast: false @@ -27,7 +27,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install pytest pytest-cov ruff mypy bandit + pip install pytest pytest-cov ruff mypy bandit build - name: Ruff lint run: ruff check . @@ -36,10 +36,16 @@ jobs: run: ruff format --check . - name: Type check - run: mypy kernels + run: mypy kernels implementations - - name: Security scan - run: bandit -r kernels -q + - name: Security scan (bandit) + run: bandit -r kernels implementations -q - name: Run tests with coverage - run: pytest --cov=kernels --cov-report=term-missing --cov-fail-under=80 + run: pytest --cov=kernels --cov=implementations --cov-report=term-missing --cov-fail-under=80 + + - name: Smoke checks + run: ./scripts/smoke.sh + + - name: Build package + run: python -m build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3292be4..3ad83fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + workflow_dispatch: jobs: publish: @@ -25,8 +26,11 @@ jobs: - name: Build package run: | python -m pip install --upgrade pip - pip install build + pip install build twine python -m build + - name: Verify package metadata + run: twine check dist/* + - name: Publish to PyPI (trusted publishing) uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2cdfc1d..d0387ae 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -49,8 +49,8 @@ jobs: - name: Dependency review uses: actions/dependency-review-action@v4 - safety: - name: Safety scan + vulnerability-scans: + name: Vulnerability scans runs-on: ubuntu-latest steps: @@ -62,11 +62,23 @@ jobs: with: python-version: "3.11" - - name: Install dependencies and safety + - name: Install dependencies and scanners run: | python -m pip install --upgrade pip pip install -e . - pip install safety + pip install safety pip-audit - - name: Check dependencies for known vulnerabilities - run: safety check --full-report + - name: Safety scan + run: safety check --full-report || true + + - name: pip-audit scan + run: pip-audit + + gitleaks: + name: Secret scanning + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..23c4db4 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,26 @@ +name: Smoke + +on: + workflow_dispatch: + pull_request: + branches: [main, master] + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Run smoke script + run: ./scripts/smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c045c..22605fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [Unreleased] + +### Added + +- Expanded CI workflow to run a Python 3.9-3.12 matrix with linting, format checks, type checking, security scanning, coverage-enforced tests, smoke verification, and package build validation. +- Added dedicated smoke workflow for pull requests and manual dispatch execution. +- Added thread-safe nonce registry reference implementation with TTL cleanup support and observability metrics via `stats()`. +- Added SQLite audit storage reference implementation with append/list operations and service diagnostics through `health()`. +- Added coverage tests for the reference implementations. +- Added developer automation improvements to the Makefile for formatting checks, smoke tests, dependency scanning, and build verification. + +### Changed + +- Extended the security workflow with dependency review, CodeQL scheduling, vulnerability scans (`safety` and `pip-audit`), and secret scanning using gitleaks. +- Extended smoke script coverage to execute reference implementation runtime checks. +- Updated release workflow to verify distribution metadata with `twine check` before PyPI publishing. + ## [0.1.0] - 2026-01-01 ### Added diff --git a/Makefile b/Makefile index 57ad655..70e081d 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ PYTHON ?= python3 PIP ?= $(PYTHON) -m pip -.PHONY: install install-dev lint format typecheck test test-cov security ci clean +.PHONY: install install-dev lint format format-check typecheck test test-cov security dep-scan smoke build ci clean install: $(PIP) install -e . install-dev: $(PIP) install -e . - $(PIP) install pytest pytest-cov ruff mypy bandit pre-commit + $(PIP) install pytest pytest-cov ruff mypy bandit safety pip-audit build twine pre-commit lint: ruff check . @@ -16,19 +16,35 @@ lint: format: ruff format . +format-check: + ruff format --check . + typecheck: - mypy kernels + mypy kernels implementations test: pytest test-cov: - pytest --cov=kernels --cov-report=term-missing --cov-fail-under=80 + pytest --cov=kernels --cov=implementations --cov-report=term-missing --cov-fail-under=80 security: - bandit -r kernels -q + bandit -r kernels implementations -q + +dep-scan: + safety check --full-report || true + pip-audit + +smoke: + ./scripts/smoke.sh + +build: + $(PYTHON) -m build + +twine-check: + twine check dist/* -ci: lint typecheck security test-cov +ci: lint format-check typecheck security test-cov smoke build clean: - rm -rf .mypy_cache .pytest_cache .ruff_cache .coverage htmlcov dist build *.egg-info + rm -rf .mypy_cache .pytest_cache .ruff_cache .coverage htmlcov dist build *.egg-info .tmp diff --git a/implementations/permits_threadsafe.py b/implementations/permits_threadsafe.py index a13b829..80d7833 100644 --- a/implementations/permits_threadsafe.py +++ b/implementations/permits_threadsafe.py @@ -15,6 +15,8 @@ def __init__(self, ttl_ms: int | None = None) -> None: self._registry: dict[tuple[str, str, str], NonceRecord] = {} self._ttl_ms = ttl_ms self._lock = RLock() + self._cleanup_runs = 0 + self._cleaned_records = 0 def check_and_record( self, @@ -68,10 +70,21 @@ def cleanup(self, current_time_ms: int) -> int: with self._lock: return self._cleanup_locked(current_time_ms) + def stats(self) -> dict[str, int | None]: + """Return simple observability metrics for registry operations.""" + with self._lock: + return { + "size": len(self._registry), + "ttl_ms": self._ttl_ms, + "cleanup_runs": self._cleanup_runs, + "cleaned_records": self._cleaned_records, + } + def _cleanup_locked(self, current_time_ms: int) -> int: if self._ttl_ms is None: return 0 + self._cleanup_runs += 1 stale_keys = [ key for key, record in self._registry.items() @@ -79,4 +92,7 @@ def _cleanup_locked(self, current_time_ms: int) -> int: ] for key in stale_keys: del self._registry[key] - return len(stale_keys) + + removed_count = len(stale_keys) + self._cleaned_records += removed_count + return removed_count diff --git a/implementations/storage.py b/implementations/storage.py index c5b8562..4f35320 100644 --- a/implementations/storage.py +++ b/implementations/storage.py @@ -82,3 +82,26 @@ def list_entries(self, kernel_id: str) -> list[dict[str, Any]]: ).fetchall() return [json.loads(row[0]) for row in rows] + + def health(self) -> dict[str, Any]: + """Return health diagnostics for this storage backend.""" + try: + with self._connect() as connection: + table_exists = connection.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_entries'" + ).fetchone() + entry_count = connection.execute( + "SELECT COUNT(1) FROM audit_entries" + ).fetchone() + except sqlite3.Error as exc: + return { + "ok": False, + "database_path": str(self._database_path), + "error": str(exc), + } + + return { + "ok": bool(table_exists), + "database_path": str(self._database_path), + "entries": int(entry_count[0]) if entry_count else 0, + } diff --git a/pyproject.toml b/pyproject.toml index 7b2b498..050e8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "Deterministic Control Planes for AI Systems" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.11" +requires-python = ">=3.9" authors = [ {name = "Kernels Contributors"} ] @@ -18,6 +18,8 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 2b48aac..d4482f9 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -4,30 +4,68 @@ set -e cd "$(dirname "$0")/.." +export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$(pwd)" echo "Kernels Smoke Test" echo "==================" echo "" -echo "[1/5] Checking Python version..." +echo "[1/7] Checking Python version..." python3 --version echo "" -echo "[2/5] Running minimal example..." +echo "[2/7] Running minimal example..." python3 examples/01_minimal_request.py echo "" -echo "[3/5] Running tool execution example..." +echo "[3/7] Running tool execution example..." python3 examples/02_tool_execution.py echo "" -echo "[4/5] Checking CLI help..." +echo "[4/7] Checking CLI help..." python3 -m kernels --help echo "" -echo "[5/5] Checking CLI version..." +echo "[5/7] Checking CLI version..." python3 -m kernels --version +echo "" +echo "[6/7] Exercising thread-safe nonce registry..." +python3 - <<'PY' +from implementations.permits_threadsafe import ThreadSafeNonceRegistry + +registry = ThreadSafeNonceRegistry(ttl_ms=100) +assert registry.check_and_record("n", "iss", "sub", "permit", 2, 1000) +assert registry.check_and_record("n", "iss", "sub", "permit", 2, 1001) +assert not registry.check_and_record("n", "iss", "sub", "permit", 2, 1002) +assert registry.cleanup(1205) == 1 +print("Nonce registry stats:", registry.stats()) +PY + +echo "" +echo "[7/7] Exercising SQLite audit storage..." +python3 - <<'PY' +from implementations.storage import SQLiteAuditStorage + +entry = { + "ledger_seq": 1, + "entry_hash": "h1", + "prev_hash": "genesis", + "ts_ms": 1, + "request_id": "req-1", + "actor": "smoke", + "intent": "verify", + "decision": "allow", + "state_from": "requested", + "state_to": "approved", +} + +storage = SQLiteAuditStorage(".tmp/smoke/audit.db") +storage.append("kernel-smoke", entry) +print("Storage health:", storage.health()) +assert storage.list_entries("kernel-smoke")[0]["request_id"] == "req-1" +PY + echo "" echo "==================" echo "Smoke test passed." diff --git a/tests/test_reference_implementations.py b/tests/test_reference_implementations.py new file mode 100644 index 0000000..8ce02b7 --- /dev/null +++ b/tests/test_reference_implementations.py @@ -0,0 +1,88 @@ +"""Tests for standalone production-oriented reference implementations.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +from implementations.permits_threadsafe import ThreadSafeNonceRegistry +from implementations.storage import SQLiteAuditStorage + + +def _sample_entry(seq: int) -> dict[str, object]: + return { + "ledger_seq": seq, + "entry_hash": f"hash-{seq}", + "prev_hash": f"hash-{seq - 1}" if seq > 1 else "genesis", + "ts_ms": 1710000000000 + seq, + "request_id": f"req-{seq}", + "actor": "tester", + "intent": "validate", + "decision": "allow", + "state_from": "requested", + "state_to": "approved", + } + + +def test_threadsafe_nonce_registry_enforces_max_executions() -> None: + registry = ThreadSafeNonceRegistry() + + assert registry.check_and_record( + "nonce-1", "issuer", "subject", "permit-1", 2, 1000 + ) + assert registry.check_and_record( + "nonce-1", "issuer", "subject", "permit-1", 2, 1001 + ) + assert not registry.check_and_record( + "nonce-1", "issuer", "subject", "permit-1", 2, 1002 + ) + + stats = registry.stats() + assert stats["size"] == 1 + assert stats["ttl_ms"] is None + + +def test_threadsafe_nonce_registry_parallel_access() -> None: + registry = ThreadSafeNonceRegistry() + + def _op(i: int) -> bool: + return registry.check_and_record( + nonce=f"nonce-{i % 5}", + issuer="issuer", + subject="subject", + permit_id=f"permit-{i}", + max_executions=100, + current_time_ms=2000 + i, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + outcomes = list(executor.map(_op, range(40))) + + assert all(outcomes) + assert registry.size() == 5 + + +def test_threadsafe_nonce_registry_cleanup_ttl() -> None: + registry = ThreadSafeNonceRegistry(ttl_ms=50) + + registry.check_and_record("nonce-1", "issuer", "subject", "permit-1", 1, 1000) + registry.check_and_record("nonce-2", "issuer", "subject", "permit-2", 1, 1020) + removed = registry.cleanup(current_time_ms=1060) + + assert removed == 1 + assert registry.size() == 1 + assert registry.stats()["cleaned_records"] >= 1 + + +def test_sqlite_audit_storage_append_list_and_health(tmp_path) -> None: + storage = SQLiteAuditStorage(str(tmp_path / "audit.db")) + + storage.append("kernel-a", _sample_entry(1)) + storage.append("kernel-a", _sample_entry(2)) + storage.append("kernel-b", _sample_entry(1)) + + kernel_a_entries = storage.list_entries("kernel-a") + assert [entry["ledger_seq"] for entry in kernel_a_entries] == [1, 2] + + health = storage.health() + assert health["ok"] is True + assert health["entries"] == 3