From 07d85615210614c5a93cd30ce339732089b728e0 Mon Sep 17 00:00:00 2001 From: Skull-boy Date: Sat, 5 Sep 2026 10:23:45 +0530 Subject: [PATCH 1/3] feat: gateway enforcement layer and bump to v1.1.3 - Implemented BaseGateway, GitHubGateway, and QdrantGateway - Enforced single boundary enforcement for API credentials - Added GatewayError exception wrapper - ContractEnforcer.gate() supports read action type - Bump version to 1.1.3 across package and CHANGELOG --- .github/workflows/lint-gateway.yml | 60 +++ CHANGELOG.md | 16 + .../duplicate-issue-detector/contract.yaml | 10 + .../duplicate_issue_detector/nodes.py | 37 +- pyproject.toml | 2 +- src/scyvera/__init__.py | 8 +- src/scyvera/enforcer.py | 24 +- src/scyvera/exceptions.py | 17 + src/scyvera/gateway.py | 357 ++++++++++++++++++ tests/fixtures/github_contract.yaml | 73 ++++ tests/test_gateway.py | 205 ++++++++++ 11 files changed, 790 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/lint-gateway.yml create mode 100644 src/scyvera/gateway.py create mode 100644 tests/fixtures/github_contract.yaml create mode 100644 tests/test_gateway.py diff --git a/.github/workflows/lint-gateway.yml b/.github/workflows/lint-gateway.yml new file mode 100644 index 0000000..9e74ac6 --- /dev/null +++ b/.github/workflows/lint-gateway.yml @@ -0,0 +1,60 @@ +name: Lint Gateway Enforcement + +on: + push: + branches: + - main + - "feat/**" + pull_request: + branches: + - main + +jobs: + lint-gateway: + name: Ensure no raw client imports outside gateway + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check for raw GitHub imports outside gateway + run: | + echo "Checking for raw Github imports outside src/scyvera/gateway.py (excluding tests/)..." + if grep -r "from github import Github" --include="*.py" src/ --exclude="gateway.py" 2>/dev/null; then + echo "FAIL: raw Github import 'from github import Github' found outside gateway" + exit 1 + fi + if grep -r "import github" --include="*.py" src/ --exclude="gateway.py" 2>/dev/null; then + echo "FAIL: raw 'import github' found outside gateway" + exit 1 + fi + echo "PASS: no raw Github imports outside gateway" + + - name: Check for raw Qdrant imports outside gateway + run: | + echo "Checking for raw QdrantClient imports outside src/scyvera/gateway.py (excluding tests/)..." + if grep -r "from qdrant_client import" --include="*.py" src/ --exclude="gateway.py" 2>/dev/null; then + echo "FAIL: raw QdrantClient import 'from qdrant_client import' found outside gateway" + exit 1 + fi + if grep -r "import qdrant_client" --include="*.py" src/ --exclude="gateway.py" 2>/dev/null; then + echo "FAIL: raw 'import qdrant_client' found outside gateway" + exit 1 + fi + echo "PASS: no raw QdrantClient imports outside gateway" + + - name: Check for direct Github instantiation outside gateway + run: | + echo "Checking for direct Github( instantiation outside gateway..." + if grep -r "Github(" --include="*.py" src/ --exclude="gateway.py" 2>/dev/null; then + echo "FAIL: direct Github( instantiation found outside gateway" + exit 1 + fi + echo "PASS: no direct Github instantiation outside gateway" + + - name: Verify tests are excluded (informational) + run: | + echo "Tests may import clients for mocking — excluded from enforcement" + grep -r "from github import Github" --include="*.py" tests/ 2>/dev/null && echo "tests/ contains mocked Github imports (allowed)" || echo "no test Github imports" + grep -r "from qdrant_client import" --include="*.py" tests/ 2>/dev/null && echo "tests/ contains mocked Qdrant imports (allowed)" || echo "no test Qdrant imports" diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f5d19..bcc0066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [1.1.3] — 2026-09-05 + +### Added +- **Gateway Enforcement Layer (`BaseGateway`, `GitHubGateway`, `QdrantGateway`)**: + - Single enforcement boundary for external API operations and credential management. + - Per-instance `@enforcer.gate` wrapping with default-deny semantics. + - Optional dependency handling for `PyGithub` and `qdrant-client` to keep core package dependency-light. +- **Gateway Exception Wrapping**: + - Introduced `GatewayError` to encapsulate third-party API client exceptions. +- Added GitHub Actions workflow for gateway linting (`.github/workflows/lint-gateway.yml`). + +### Changed +- `ContractEnforcer.gate()` now supports `"read"` as an action type alias for permission gating. + +--- + ## [1.1.2] — 2026-08-30 ### Changed diff --git a/implementations/langgraph/duplicate-issue-detector/contract.yaml b/implementations/langgraph/duplicate-issue-detector/contract.yaml index 84cf400..540704c 100644 --- a/implementations/langgraph/duplicate-issue-detector/contract.yaml +++ b/implementations/langgraph/duplicate-issue-detector/contract.yaml @@ -13,10 +13,20 @@ permissions: - github: issues:read+write # comment only — never close, label, or edit - openai: embeddings:read - qdrant: read+write on one collection + - github.read: read + - qdrant.search: search side_effects: - Posts one comment on the triggering issue, if similarity >= threshold - Writes one vector to the Qdrant collection (always, regardless of duplicate result) + - github.comment + - github.read + - github.close + - github.label + - github.merge + - qdrant.write + - qdrant.delete + - qdrant.search approval_points: [] # intentionally empty — this workflow only ever comments, # never closes or merges, so no approval gate is required diff --git a/implementations/langgraph/duplicate-issue-detector/duplicate_issue_detector/nodes.py b/implementations/langgraph/duplicate-issue-detector/duplicate_issue_detector/nodes.py index 06cdb6e..a9186e0 100644 --- a/implementations/langgraph/duplicate-issue-detector/duplicate_issue_detector/nodes.py +++ b/implementations/langgraph/duplicate-issue-detector/duplicate_issue_detector/nodes.py @@ -1,12 +1,16 @@ from __future__ import annotations import logging +from pathlib import Path from typing import Any import httpx -from github import Github, GithubException from openai import OpenAI +from scyvera import ContractEnforcer +from scyvera.exceptions import GatewayError +from scyvera.gateway import GitHubGateway + from .state import State log = logging.getLogger(__name__) @@ -17,13 +21,27 @@ _SEARCH_LIMIT = 5 +def _get_enforcer() -> ContractEnforcer: + """Load the workflow contract enforcer. + + The contract is resolved relative to this file so the gateway + always enforces the contract that ships with the workflow. + """ + contract_path = Path(__file__).resolve().parent.parent / "contract.yaml" + return ContractEnforcer.load(contract_path) + + def detect(state: State) -> dict[str, Any]: """Fetch issue title + body from GitHub. Read-only, no side effects.""" log.info("DETECT #%d from %s/%s", state["issue_number"], state["repo_owner"], state["repo_name"]) try: - repo = Github(state["github_token"]).get_repo(f"{state['repo_owner']}/{state['repo_name']}") - issue = repo.get_issue(number=state["issue_number"]) - except GithubException as exc: + enforcer = _get_enforcer() + gateway = GitHubGateway(enforcer, token=state["github_token"]) + issue = gateway.read_issue( + f"{state['repo_owner']}/{state['repo_name']}", + state["issue_number"], + ) + except (GatewayError, Exception) as exc: log.error("DETECT error: %s", exc) raise @@ -107,9 +125,14 @@ def act(state: State) -> dict[str, Any]: "This is an automated suggestion — a maintainer will confirm." ) try: - repo = Github(state["github_token"]).get_repo(f"{state['repo_owner']}/{state['repo_name']}") - repo.get_issue(number=state["issue_number"]).create_comment(body) - except GithubException as exc: + enforcer = _get_enforcer() + gateway = GitHubGateway(enforcer, token=state["github_token"]) + gateway.post_comment( + f"{state['repo_owner']}/{state['repo_name']}", + state["issue_number"], + body, + ) + except (GatewayError, Exception) as exc: log.error("ACT error: %s", exc) raise log.info("ACT comment posted") diff --git a/pyproject.toml b/pyproject.toml index 133a533..0fa69c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "scyvera" -version = "1.1.2" +version = "1.1.3" description = "Framework-independent contracts and validation tooling for AI agents and automated workflows." readme = "README.md" requires-python = ">=3.10" diff --git a/src/scyvera/__init__.py b/src/scyvera/__init__.py index bd052a9..5dcf618 100644 --- a/src/scyvera/__init__.py +++ b/src/scyvera/__init__.py @@ -7,7 +7,9 @@ ContractValidationError, ContractVersionError, ContractViolationError, + GatewayError, ) +from .gateway import BaseGateway, GitHubGateway, QdrantGateway from .linter import LintResult, LintWarning, lint_contract from .validator import ( LIFECYCLE_DEFAULTS, @@ -21,13 +23,14 @@ validate_contract, ) -__version__ = "1.1.2" +__version__ = "1.1.3" __all__ = [ "__version__", "AgentIdentity", "ApprovalPendingError", "AuditEntry", + "BaseGateway", "Contract", "ContractEnforcer", "ContractFileNameError", @@ -36,9 +39,12 @@ "ContractVersion", "ContractVersionError", "ContractViolationError", + "GatewayError", + "GitHubGateway", "LIFECYCLE_DEFAULTS", "LintResult", "LintWarning", + "QdrantGateway", "SCHEMA_V1_PATH", "SCHEMA_V1_1_PATH", "SystemIdentity", diff --git a/src/scyvera/enforcer.py b/src/scyvera/enforcer.py index ceb9c00..6d11233 100644 --- a/src/scyvera/enforcer.py +++ b/src/scyvera/enforcer.py @@ -375,46 +375,50 @@ def verify_integrity(self) -> bool: def gate( self, action_name: str, - action_type: Literal["side_effect", "permission"], + action_type: Literal["side_effect", "permission", "read"], ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator factory to gate a function call against declared permissions or side effects. Args: action_name: The identifier of the action (e.g. 'github:issues:write' or 'comment'). - action_type: Either 'side_effect' or 'permission'. + action_type: Either 'side_effect', 'permission', or 'read' (alias for permission). Returns: A decorator that intercepts execution, evaluates the contract, logs the audit entry, and either executes or raises a ContractViolationError / ApprovalPendingError. """ - if action_type not in ("side_effect", "permission"): + if action_type not in ("side_effect", "permission", "read"): raise ValueError( - f"Invalid action_type '{action_type}'. Must be 'side_effect' or 'permission'." + f"Invalid action_type '{action_type}'. Must be 'side_effect', 'permission', or 'read'." ) + # 'read' is an alias for 'permission' — read operations are gated as permissions. + effective_type: Literal["side_effect", "permission"] = ( + "permission" if action_type == "read" else action_type # type: ignore[assignment] + ) def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: if inspect.iscoroutinefunction(fn): @functools.wraps(fn) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: - self._check_gate(action_name, action_type) + self._check_gate(action_name, effective_type) try: result = await fn(*args, **kwargs) - self._log_execution(action_name, action_type, success=True) + self._log_execution(action_name, effective_type, success=True) return result except Exception as e: - self._log_execution(action_name, action_type, success=False, error=str(e)) + self._log_execution(action_name, effective_type, success=False, error=str(e)) raise wrapper = async_wrapper else: @functools.wraps(fn) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: - self._check_gate(action_name, action_type) + self._check_gate(action_name, effective_type) try: result = fn(*args, **kwargs) - self._log_execution(action_name, action_type, success=True) + self._log_execution(action_name, effective_type, success=True) return result except Exception as e: - self._log_execution(action_name, action_type, success=False, error=str(e)) + self._log_execution(action_name, effective_type, success=False, error=str(e)) raise wrapper = sync_wrapper diff --git a/src/scyvera/exceptions.py b/src/scyvera/exceptions.py index 497ffb7..bcb310f 100644 --- a/src/scyvera/exceptions.py +++ b/src/scyvera/exceptions.py @@ -79,3 +79,20 @@ class ContractValidationError(Exception): def __init__(self, message: str, errors: tuple[Any, ...] = ()) -> None: self.errors = errors super().__init__(message) + + +class GatewayError(Exception): + """Raised when an underlying gateway client operation fails. + + Wraps the original third-party exception to prevent raw client + exceptions (PyGithub, Qdrant) from leaking outside the gateway + boundary. The gateway is the sole owner of credentials and clients. + """ + + def __init__(self, action: str, original_exception: Exception) -> None: + self.action: str = action + self.original_exception: Exception = original_exception + self.original: Exception = original_exception + super().__init__( + f"Gateway error for action '{action}': {original_exception}" + ) diff --git a/src/scyvera/gateway.py b/src/scyvera/gateway.py new file mode 100644 index 0000000..f8ab988 --- /dev/null +++ b/src/scyvera/gateway.py @@ -0,0 +1,357 @@ +"""Gateway module — single enforcement boundary for external API calls. + +All credentials and all external API calls live exclusively inside gated +gateway classes. No other module holds a token or imports a raw API client. +Bypass is structurally impossible, not just discouraged. + +Design: +- :class:`BaseGateway` — abstract base that stores the injected enforcer. +- :class:`GitHubGateway` — gated GitHub operations via PyGithub. +- :class:`QdrantGateway` — gated Qdrant operations via qdrant-client. +- Every public method is gated with ``@enforcer.gate(...)`` using the + injected enforcer instance. Gating is applied per-instance in ``__init__`` + so the injected enforcer is used, not a global singleton. +- All raw client exceptions are caught and re-raised as + :class:`scyvera.exceptions.GatewayError`. +""" + +from __future__ import annotations + +import abc +import warnings +from typing import Any + +try: + from github import Github + try: + from github.Auth import Token as GithubToken # type: ignore[import-not-found] + except Exception: + GithubToken = None # type: ignore[assignment] +except ImportError: + Github = None # type: ignore[assignment] + GithubToken = None # type: ignore[assignment] + +try: + from qdrant_client import QdrantClient +except ImportError: + QdrantClient = None # type: ignore[assignment] + +from .enforcer import ContractEnforcer +from .exceptions import GatewayError + + +class BaseGateway(abc.ABC): + """Abstract base for all gateway implementations. + + The enforcer is injected — the gateway never creates its own enforcer. + The enforcer is the single source of truth for contract decisions. + """ + + def __init__(self, enforcer: ContractEnforcer) -> None: + """Initialize the gateway with an injected enforcer. + + Args: + enforcer: The :class:`ContractEnforcer` that governs all + gateway operations. Must be provided by the caller. + """ + self._enforcer: ContractEnforcer = enforcer + + +class GitHubGateway(BaseGateway): + """Gated gateway for GitHub API operations. + + All credentials are held privately. The raw PyGithub client is never + exposed outside this class. Every method is gated via the injected + enforcer, ensuring contract enforcement cannot be bypassed. + """ + + def __init__(self, enforcer: ContractEnforcer, token: str) -> None: + """Initialize the GitHub gateway. + + Args: + enforcer: Contract enforcer that governs all operations. + token: GitHub personal access token. Stored privately as + ``self._token`` and never exposed outside the class. + """ + super().__init__(enforcer) + if Github is None: + raise ImportError( + "PyGithub is required for GitHubGateway. Install it with 'pip install PyGithub' or 'pip install scyvera[github]'." + ) + self._token: str = token + if GithubToken is not None: + self._client: Github = Github(auth=GithubToken(token)) + else: + self._client: Github = Github(token) + + # Apply per-instance gating using the injected enforcer. + # Each method is wrapped so ``enforcer.assert_gated`` succeeds + # on the bound instance methods and contract decisions are + # evaluated against the injected contract. + self.post_comment = enforcer.gate("github.comment", "side_effect")(self.post_comment) # type: ignore[method-assign] + self.close_issue = enforcer.gate("github.close", "side_effect")(self.close_issue) # type: ignore[method-assign] + self.merge_pr = enforcer.gate("github.merge", "side_effect")(self.merge_pr) # type: ignore[method-assign] + self.create_label = enforcer.gate("github.label", "side_effect")(self.create_label) # type: ignore[method-assign] + self.read_issue = enforcer.gate("github.read", "read")(self.read_issue) # type: ignore[method-assign] + self.list_issues = enforcer.gate("github.read", "read")(self.list_issues) # type: ignore[method-assign] + self.read_pr_files = enforcer.gate("github.read", "read")(self.read_pr_files) # type: ignore[method-assign] + + def post_comment(self, repo_name: str, issue_number: int, body: str) -> Any: + """Post a comment on a GitHub issue. + + Args: + repo_name: Repository in ``owner/name`` form. + issue_number: Issue number to comment on. + body: Markdown body of the comment. + + Returns: + The created comment object from PyGithub. + + Raises: + GatewayError: If the underlying GitHub API call fails. + ContractViolationError: If ``github.comment`` is not declared. + ApprovalPendingError: If ``github.comment`` requires approval. + """ + try: + repo = self._client.get_repo(repo_name) + issue = repo.get_issue(number=issue_number) + return issue.create_comment(body) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.comment", exc) from exc + + def close_issue(self, repo_name: str, issue_number: int) -> Any: + """Close a GitHub issue. + + Args: + repo_name: Repository in ``owner/name`` form. + issue_number: Issue number to close. + + Returns: + The updated issue object. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + issue = repo.get_issue(number=issue_number) + return issue.edit(state="closed") + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.close", exc) from exc + + def merge_pr(self, repo_name: str, pr_number: int) -> Any: + """Merge a GitHub pull request. + + Args: + repo_name: Repository in ``owner/name`` form. + pr_number: Pull request number to merge. + + Returns: + The merge result from PyGithub. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + pr = repo.get_pull(number=pr_number) + return pr.merge() + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.merge", exc) from exc + + def create_label(self, repo_name: str, issue_number: int, label: str) -> Any: + """Add a label to a GitHub issue. + + Args: + repo_name: Repository in ``owner/name`` form. + issue_number: Issue number to label. + label: Label name to add. + + Returns: + The result of the label addition. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + issue = repo.get_issue(number=issue_number) + return issue.add_to_labels(label) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.label", exc) from exc + + def read_issue(self, repo_name: str, issue_number: int) -> Any: + """Read a GitHub issue. + + Args: + repo_name: Repository in ``owner/name`` form. + issue_number: Issue number to read. + + Returns: + The issue object from PyGithub. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + return repo.get_issue(number=issue_number) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.read", exc) from exc + + def list_issues(self, repo_name: str, state: str = "open") -> Any: + """List issues in a GitHub repository. + + Args: + repo_name: Repository in ``owner/name`` form. + state: Issue state filter (e.g. ``"open"``, ``"closed"``, ``"all"``). + + Returns: + A paginated list of issues from PyGithub. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + return repo.get_issues(state=state) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.read", exc) from exc + + def read_pr_files(self, repo_name: str, pr_number: int) -> Any: + """Read files changed in a GitHub pull request. + + Args: + repo_name: Repository in ``owner/name`` form. + pr_number: Pull request number. + + Returns: + A paginated list of files from PyGithub. + + Raises: + GatewayError: If the underlying GitHub API call fails. + """ + try: + repo = self._client.get_repo(repo_name) + pr = repo.get_pull(number=pr_number) + return pr.get_files() + except GatewayError: + raise + except Exception as exc: + raise GatewayError("github.read", exc) from exc + + +class QdrantGateway(BaseGateway): + """Gated gateway for Qdrant vector database operations. + + All credentials are held privately. The raw Qdrant client is never + exposed outside this class. Every method is gated via the injected + enforcer. + """ + + def __init__(self, enforcer: ContractEnforcer, url: str, api_key: str) -> None: + """Initialize the Qdrant gateway. + + Args: + enforcer: Contract enforcer that governs all operations. + url: Qdrant instance URL. + api_key: Qdrant API key. Stored privately and never exposed. + """ + super().__init__(enforcer) + if QdrantClient is None: + raise ImportError( + "qdrant-client is required for QdrantGateway. Install it with 'pip install qdrant-client' or 'pip install scyvera[qdrant]'." + ) + self._url: str = url + self._api_key: str = api_key + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + self._client: QdrantClient = QdrantClient(url=url, api_key=api_key) + + # Apply per-instance gating using the injected enforcer. + self.search = enforcer.gate("qdrant.search", "read")(self.search) # type: ignore[method-assign] + self.upsert = enforcer.gate("qdrant.write", "side_effect")(self.upsert) # type: ignore[method-assign] + self.delete = enforcer.gate("qdrant.delete", "side_effect")(self.delete) # type: ignore[method-assign] + + def search(self, collection: str, vector: list[float], limit: int = 10) -> Any: + """Search for nearest vectors in a Qdrant collection. + + Args: + collection: Collection name to search in. + vector: Query vector. + limit: Maximum number of results to return. + + Returns: + Query results from Qdrant. + + Raises: + GatewayError: If the underlying Qdrant API call fails. + """ + try: + # Prefer query_points (new API) with fallback to search if available. + if hasattr(self._client, "query_points"): + return self._client.query_points( + collection_name=collection, query=vector, limit=limit + ) + # Fallback for older clients that expose search + return self._client.search( # type: ignore[attr-defined] + collection_name=collection, query_vector=vector, limit=limit + ) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("qdrant.search", exc) from exc + + def upsert(self, collection: str, points: list[Any]) -> Any: + """Upsert points into a Qdrant collection. + + Args: + collection: Collection name to upsert into. + points: Points to upsert. + + Returns: + The update result from Qdrant. + + Raises: + GatewayError: If the underlying Qdrant API call fails. + """ + try: + return self._client.upsert(collection_name=collection, points=points) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("qdrant.write", exc) from exc + + def delete(self, collection: str, ids: list[Any]) -> Any: + """Delete points from a Qdrant collection. + + Args: + collection: Collection name to delete from. + ids: Point IDs to delete. + + Returns: + The update result from Qdrant. + + Raises: + GatewayError: If the underlying Qdrant API call fails. + """ + try: + return self._client.delete( + collection_name=collection, points_selector=ids + ) + except GatewayError: + raise + except Exception as exc: + raise GatewayError("qdrant.delete", exc) from exc diff --git a/tests/fixtures/github_contract.yaml b/tests/fixtures/github_contract.yaml new file mode 100644 index 0000000..4f0676a --- /dev/null +++ b/tests/fixtures/github_contract.yaml @@ -0,0 +1,73 @@ +version: 1.1 +system: + name: governed-github-agent + purpose: Governed GitHub operations via gateway — fixture for gateway enforcement tests + version: 1.0.0 +lifecycle: + mode: request-response + initiation: human-only + resumability: stateless +capabilities: + - name: github_read + description: Read GitHub issues and PRs + - name: github_merge + description: Merge pull requests with approval +resources: + - name: github_repo + type: api + access: read + - name: qdrant_collection + type: database + access: read +inputs: + - name: repo_name + type: string + required: true + - name: issue_number + type: integer + required: true +outputs: + - name: result + type: string +permissions: + - resource: github.read + actions: [read] + - resource: qdrant.search + actions: [search] +side_effects: + - type: merge + resource: github + description: github.merge + - type: read + resource: github + description: github.read + - type: search + resource: qdrant + description: qdrant.search + - type: write + resource: qdrant + description: qdrant.write + - type: delete + resource: qdrant + description: qdrant.delete +approvals: + - action: github.merge + required: true + approver: human +dependencies: + - name: github_api + type: api + required: true + - name: qdrant_api + type: api + required: false +state: + persistence: none +recovery: + strategy: retry +replay: + mode: idempotent +observability: + level: basic +risk: + level: low diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..6361167 --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,205 @@ +"""Tests for the gateway enforcement layer. + +All external clients (PyGithub, qdrant-client) are mocked — no real API calls. +Enforcer is real, loaded from the fixture contract, not mocked. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from scyvera import ( + ApprovalPendingError, + ContractEnforcer, + ContractViolationError, + GatewayError, + GitHubGateway, + QdrantGateway, +) + +FIXTURE = "tests/fixtures/github_contract.yaml" + + +# ============================================================================= +# Structural tests +# ============================================================================= + + +def test_github_gateway_all_methods_are_gated(): + """Every GitHubGateway method is decorated with @enforcer.gate.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + enforcer.assert_gated(gw.post_comment) + enforcer.assert_gated(gw.close_issue) + enforcer.assert_gated(gw.merge_pr) + enforcer.assert_gated(gw.create_label) + enforcer.assert_gated(gw.read_issue) + enforcer.assert_gated(gw.list_issues) + enforcer.assert_gated(gw.read_pr_files) + + +def test_qdrant_gateway_all_methods_are_gated(): + """Every QdrantGateway method is decorated with @enforcer.gate.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = QdrantGateway(enforcer, url="http://localhost:6333", api_key="fake") + enforcer.assert_gated(gw.search) + enforcer.assert_gated(gw.upsert) + enforcer.assert_gated(gw.delete) + + +# ============================================================================= +# Enforcement tests +# ============================================================================= + + +def test_undeclared_action_raises_violation(): + """Contract declares only github.read — post_comment must raise ContractViolationError.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + + with pytest.raises(ContractViolationError) as exc_info: + gw.post_comment("owner/repo", 1, "hello") + + assert exc_info.value.action_name == "github.comment" + + +def test_approval_required_action_raises_pending(): + """github.merge requires approval — merge_pr without approval raises ApprovalPendingError.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + + with pytest.raises(ApprovalPendingError) as exc_info: + gw.merge_pr("owner/repo", 99) + + assert exc_info.value.action_name == "github.merge" + + +def test_approved_action_executes(): + """After approval, merge_pr executes and audit log shows ALLOWED.""" + enforcer = ContractEnforcer.load(FIXTURE) + with patch("scyvera.gateway.Github") as MockGithub: + mock_repo = MagicMock() + mock_pr = MagicMock() + mock_pr.merge.return_value = {"merged": True} + mock_repo.get_pull.return_value = mock_pr + mock_client = MagicMock() + mock_client.get_repo.return_value = mock_repo + MockGithub.return_value = mock_client + + gw = GitHubGateway(enforcer, token="fake") + enforcer.approve("github.merge", token="test-token") + result = gw.merge_pr("owner/repo", 42) + + assert result == {"merged": True} + audit = enforcer.get_audit_log() + # Last entry should be ALLOWED for github.merge + allowed = [e for e in audit if e.action_name == "github.merge" and e.decision == "ALLOWED"] + assert len(allowed) >= 1 + + +def test_read_action_executes_without_approval(): + """read_issue executes with no approval needed, audit shows ALLOWED.""" + enforcer = ContractEnforcer.load(FIXTURE) + with patch("scyvera.gateway.Github") as MockGithub: + mock_repo = MagicMock() + mock_issue = MagicMock() + mock_issue.title = "Test" + mock_repo.get_issue.return_value = mock_issue + mock_client = MagicMock() + mock_client.get_repo.return_value = mock_repo + MockGithub.return_value = mock_client + + gw = GitHubGateway(enforcer, token="fake") + result = gw.read_issue("owner/repo", 1) + + assert result == mock_issue + audit = enforcer.get_audit_log() + allowed = [e for e in audit if e.action_name == "github.read" and e.decision == "ALLOWED"] + assert len(allowed) >= 1 + + +def test_token_not_accessible_outside_gateway(): + """GitHubGateway has no public attribute that returns the token.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="super-secret-token") + + assert not hasattr(gw, "token") + assert not hasattr(gw, "api_key") + # Private attributes should exist but not public + assert hasattr(gw, "_token") + # Ensure dir does not expose public token + public_attrs = [a for a in dir(gw) if not a.startswith("_")] + assert "token" not in public_attrs + assert "api_key" not in public_attrs + + +def test_client_not_accessible_outside_gateway(): + """No public attribute returns the raw client.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + public_attrs = [a for a in dir(gw) if not a.startswith("_")] + assert "client" not in public_attrs + assert "_client" in dir(gw) + assert hasattr(gw, "_client") + assert not hasattr(gw, "client") + + qgw = QdrantGateway(enforcer, url="http://localhost:6333", api_key="fake") + public_q = [a for a in dir(qgw) if not a.startswith("_")] + assert "client" not in public_q + + +def test_gateway_error_wraps_client_exception(): + """Mock PyGithub to raise GithubException — GatewayError is raised, not GithubException.""" + from github.GithubException import GithubException + + enforcer = ContractEnforcer.load(FIXTURE) + with patch("scyvera.gateway.Github") as MockGithub: + mock_repo = MagicMock() + mock_repo.get_issue.side_effect = GithubException(500, "boom", headers=None) + mock_client = MagicMock() + mock_client.get_repo.return_value = mock_repo + MockGithub.return_value = mock_client + + # Need to approve read? read_issue does not require approval, so no approve needed. + # But choose an allowed action; read_issue is allowed. + gw = GitHubGateway(enforcer, token="fake") + + with pytest.raises(GatewayError) as exc_info: + gw.read_issue("owner/repo", 1) + + assert isinstance(exc_info.value, GatewayError) + assert exc_info.value.action == "github.read" + assert isinstance(exc_info.value.original_exception, GithubException) + assert "boom" in str(exc_info.value.original_exception) + + +def test_audit_log_records_denied(): + """Undeclared action attempt → audit log has DENIED entry with action name.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + + try: + gw.post_comment("owner/repo", 1, "hi") + except ContractViolationError: + pass + + audit = enforcer.get_audit_log() + denied = [e for e in audit if e.decision == "DENIED"] + assert len(denied) >= 1 + assert any(e.action_name == "github.comment" for e in denied) + + +def test_audit_log_records_pending(): + """Approval-required action → audit log has PENDING entry.""" + enforcer = ContractEnforcer.load(FIXTURE) + gw = GitHubGateway(enforcer, token="fake") + + try: + gw.merge_pr("owner/repo", 1) + except ApprovalPendingError: + pass + + audit = enforcer.get_audit_log() + pending = [e for e in audit if e.decision == "PENDING"] + assert len(pending) >= 1 + assert any(e.action_name == "github.merge" for e in pending) From b875a38ef5b77e5ec0c6ccd76e4bc612b1fe33ac Mon Sep 17 00:00:00 2001 From: Skull-boy Date: Sat, 5 Sep 2026 10:49:00 +0530 Subject: [PATCH 2/3] docs & ci: update README with gateway architecture, configure optional deps, and add CI test dependencies --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/validate-contracts.yml | 2 +- README.md | 77 ++++++++++++++++++++---- pyproject.toml | 12 ++++ tests/test_gateway.py | 34 ++++++++++- 5 files changed, 113 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ac98a0f..73e29ac 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -28,7 +28,7 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install build pytest PyYAML jsonschema + python -m pip install build pytest PyYAML jsonschema PyGithub qdrant-client - name: Verify Version matches Tag run: | diff --git a/.github/workflows/validate-contracts.yml b/.github/workflows/validate-contracts.yml index 530186d..8a69b8d 100644 --- a/.github/workflows/validate-contracts.yml +++ b/.github/workflows/validate-contracts.yml @@ -26,7 +26,7 @@ jobs: - name: Install build dependencies run: | python -m pip install --upgrade pip - python -m pip install build pytest PyYAML jsonschema + python -m pip install build pytest PyYAML jsonschema PyGithub qdrant-client - name: Build wheel and source distribution run: python -m build diff --git a/README.md b/README.md index 0f03043..751afcc 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,15 @@ A framework-independent and domain-independent specification layer describing sy --- -## 🛡️ Validation vs. Runtime Enforcement +## 🛡️ Governance & Enforcement Layers -Scyvera provides two complementary governance layers: +Scyvera provides three complementary governance layers: | Layer | Component | Responsibility | Trust Level | |---|---|---|---| | **Tier 1 & 2: Static Verification** | `validate_contract()`, `lint_contract()` | Validates that a `contract.yaml` is structurally and semantically well-formed against specification schemas. | Declaration conformance | | **Runtime Enforcement** | `ContractEnforcer` | Gates real-world Python calls against declared permissions and side effects using **Default-Deny** rules. Halts execution on approval points. | Execution boundary enforcement | +| **Gateway Enforcement** | `BaseGateway`, `GitHubGateway`, `QdrantGateway` | Single enforcement boundary: holds credentials privately and gates every third-party API call, structurally preventing gate bypass. | Credential & API isolation | --- @@ -48,12 +49,26 @@ Scyvera provides two complementary governance layers: ### 1. Installation -Install locally or in your project virtualenv: +Install the core package from PyPI: ```bash pip install scyvera -# Or for local development: -pip install -e . +``` + +Or install with optional gateway dependencies: + +```bash +# With PyGithub for GitHubGateway +pip install "scyvera[github]" + +# With qdrant-client for QdrantGateway +pip install "scyvera[qdrant]" + +# With all gateway adapters +pip install "scyvera[all]" + +# For local development: +pip install -e ".[dev]" ``` ### 2. Command-Line Interface (CLI) @@ -126,11 +141,40 @@ enforcer.verify_integrity() # Raises ContractTamperError if contract.yaml was m --- +## 🚪 Gateway Enforcement Architecture (v1.1.3) + +In automated agent workflows, security boundaries fail if agent nodes can bypass enforcers by importing and calling API clients directly. Scyvera solves this with **Gateways** (`BaseGateway`, `GitHubGateway`, `QdrantGateway`): + +- **Single Enforcement Boundary**: API credentials and raw SDK clients (e.g. `PyGithub`, `qdrant-client`) are encapsulated privately within the gateway. No outside node holds credentials or raw clients. +- **Structural Gating**: Every public method is decorated per-instance with `@enforcer.gate(...)`. Any undeclared or unapproved action halts execution immediately via `ContractViolationError` or `ApprovalPendingError`. +- **Exception Normalization**: Client exceptions are caught and wrapped into `GatewayError` to ensure deterministic fault isolation. + +```python +from scyvera import ContractEnforcer, GitHubGateway, GatewayError, ContractViolationError + +# 1. Load the contract enforcer +enforcer = ContractEnforcer.load("contract.yaml") + +# 2. Instantiate gateway with injected enforcer and private credentials +gateway = GitHubGateway(enforcer, token="ghp_...") + +# 3. Gated read operation (succeeds if declared in contract) +issue = gateway.read_issue("owner/repo", issue_number=42) + +# 4. Gated mutating operation (enforces Default-Deny & approvals) +try: + gateway.close_issue("owner/repo", issue_number=42) +except ContractViolationError as e: + print(f"Blocked by Scyvera: {e}") +``` + +--- + ## ⚠️ What Scyvera Does Not Guarantee -Scyvera provides structural verification and runtime gating, but security is an end-to-end discipline. Integrators must understand the following technical boundaries: +Scyvera provides structural verification, runtime gating, and gateway isolation, but security is an end-to-end discipline. Integrators must understand the following technical boundaries: -1. **Gate Bypass (Threat T4)**: Scyvera enforces boundaries at the `@enforcer.gate(...)` decorator. In Python, the runtime cannot physically prevent code from directly calling an un-decorated internal function. Integrators should use `ContractEnforcer.assert_gated(fn)` within their integration test suites to verify that all external-facing tool call sites are wrapped. +1. **Gate Bypass (Threat T4)**: Scyvera enforces boundaries at the `@enforcer.gate(...)` decorator. To eliminate the risk of ungated bypass, applications should use Scyvera **Gateways** (`BaseGateway`, `GitHubGateway`, `QdrantGateway`), where credentials and raw SDK clients are kept strictly private inside the gateway. If invoking ungated custom functions directly, integrators should use `ContractEnforcer.assert_gated(fn)` in test suites. 2. **Internal Third-Party Behavior / String Spoofing (Threat T6)**: Scyvera verifies that a declared intent (e.g. `github:issues:write`) matches an allowed permission in the contract. It does not perform dynamic bytecode analysis or network-packet inspection to guarantee that a decorated library function does not execute unauthorized background calls. The integrity of third-party dependencies remains the responsibility of dependency scanning and peer review. 3. **Pre-Load File Tampering (Threat T5)**: The SHA-256 integrity check in `verify_integrity()` protects against file replacement AFTER load. It does not protect against a tampered `contract.yaml` being present BEFORE `ContractEnforcer.load()` is called. The deployment environment is responsible for protecting the contract file prior to load. @@ -228,14 +272,25 @@ agent-contracts/ │ ├── __init__.py │ ├── builder.py # Programmatic Contract Builder API │ ├── validator.py # Multi-Version Validator Engine -│ ├── cli.py # CLI Application (validate, init) +│ ├── enforcer.py # Runtime Contract Enforcer +│ ├── gateway.py # Gateway Enforcement Layer (GitHub, Qdrant) +│ ├── exceptions.py # Exception Hierarchy +│ ├── linter.py # Semantic Contract Linter +│ ├── cli.py # CLI Application (validate, init, lint) │ └── schemas/ # Bundled Package Schemas ├── tests/ │ ├── fixtures/ # Test Fixture Files -│ ├── test_validator.py # v1 Validator Unit Tests -│ ├── test_validator_v1_1.py # v1.1 Validator Unit Tests │ ├── test_builder.py # Programmatic Builder Unit Tests -│ └── test_cli.py # CLI Unit Tests +│ ├── test_cli.py # CLI Unit Tests +│ ├── test_duplicate_issue_detector_workflow.py # Workflow Embedding Tests +│ ├── test_enforcer.py # Runtime Enforcer Tests (T1-T10) +│ ├── test_filename_enforcement.py # Filename Rule Tests (.yaml only) +│ ├── test_gateway.py # Gateway Enforcement Tests +│ ├── test_lifecycle.py # Lifecycle Validation Tests +│ ├── test_linter.py # Semantic Linter Tests +│ ├── test_schemas_sync.py # Schema Synchronization Tests +│ ├── test_validator.py # v1 Validator Unit Tests +│ └── test_validator_v1_1.py # v1.1 Validator Unit Tests └── implementations/ # Multi-Framework Reference Implementations ├── n8n/ └── langgraph/ diff --git a/pyproject.toml b/pyproject.toml index 0fa69c3..eaabd00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,21 @@ dependencies = [ ] [project.optional-dependencies] +github = [ + "PyGithub>=2.0.0", +] +qdrant = [ + "qdrant-client>=1.7.0", +] +all = [ + "PyGithub>=2.0.0", + "qdrant-client>=1.7.0", +] dev = [ "pytest>=8.0", "build>=1.0", + "PyGithub>=2.0.0", + "qdrant-client>=1.7.0", ] [project.scripts] diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 6361167..de27bd3 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -150,7 +150,14 @@ def test_client_not_accessible_outside_gateway(): def test_gateway_error_wraps_client_exception(): """Mock PyGithub to raise GithubException — GatewayError is raised, not GithubException.""" - from github.GithubException import GithubException + try: + from github.GithubException import GithubException + except ImportError: + class GithubException(Exception): # type: ignore[no-redef] + def __init__(self, status=500, data="boom", headers=None): + super().__init__(data) + self.status = status + self.data = data enforcer = ContractEnforcer.load(FIXTURE) with patch("scyvera.gateway.Github") as MockGithub: @@ -203,3 +210,28 @@ def test_audit_log_records_pending(): pending = [e for e in audit if e.decision == "PENDING"] assert len(pending) >= 1 assert any(e.action_name == "github.merge" for e in pending) + + +# ============================================================================= +# Optional dependency fallback tests +# ============================================================================= + + +def test_github_gateway_missing_dependency_raises_import_error(): + """When PyGithub is not installed, initializing GitHubGateway raises ImportError.""" + enforcer = ContractEnforcer.load(FIXTURE) + with patch("scyvera.gateway.Github", None): + with pytest.raises(ImportError) as exc_info: + GitHubGateway(enforcer, token="fake") + assert "PyGithub is required" in str(exc_info.value) + assert "scyvera[github]" in str(exc_info.value) + + +def test_qdrant_gateway_missing_dependency_raises_import_error(): + """When qdrant-client is not installed, initializing QdrantGateway raises ImportError.""" + enforcer = ContractEnforcer.load(FIXTURE) + with patch("scyvera.gateway.QdrantClient", None): + with pytest.raises(ImportError) as exc_info: + QdrantGateway(enforcer, url="http://localhost:6333", api_key="fake") + assert "qdrant-client is required" in str(exc_info.value) + assert "scyvera[qdrant]" in str(exc_info.value) From d1f21a81c74ad5d2f789f7cbdbb44d8d98734e04 Mon Sep 17 00:00:00 2001 From: Skull-boy Date: Sat, 5 Sep 2026 11:12:26 +0530 Subject: [PATCH 3/3] fix(test): resolve FIXTURE path relative to file root for isolated runner environments --- tests/test_gateway.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index de27bd3..0ab44df 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -4,6 +4,7 @@ Enforcer is real, loaded from the fixture contract, not mocked. """ +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -17,7 +18,8 @@ QdrantGateway, ) -FIXTURE = "tests/fixtures/github_contract.yaml" +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "github_contract.yaml" # =============================================================================