Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# GovernsAI Precheck
# GovernsAI Precheck — The Enforcement Engine

[![npm](https://img.shields.io/npm/v/%40governs-ai%2Fsdk?label=npm%20%40governs-ai%2Fsdk)](https://www.npmjs.com/package/@governs-ai/sdk)
[![PyPI](https://img.shields.io/pypi/v/governs-ai-sdk?label=PyPI%20governs-ai-sdk)](https://pypi.org/project/governs-ai-sdk/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

**Fully Open Source (MIT)** - PII detection and policy evaluation service for AI applications.
**GovernsAI is the AI governance layer where policy actually enforces.** This service is the enforcement engine: it reads the per-org policy from the dashboard, runs PII detection (Presidio + regex), applies network-scope and tool-deny rules, and returns an `allow`, `transform`, or `deny` decision on every prompt, tool call, and response — without a redeploy.

This service provides real-time policy evaluation and PII detection/redaction for AI tool usage. You can use it, modify it, and even offer it as a hosted service - no restrictions.
**Fully Open Source (MIT)** — use it, modify it, host it. No restrictions.

## Features

Expand Down
35 changes: 34 additions & 1 deletion app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from datetime import datetime
from typing import List, Optional, Tuple

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session

from .auth import AuthContext, require_api_key
Expand Down Expand Up @@ -155,6 +155,37 @@ async def health():
return {"ok": True, "service": "governsai-precheck", "version": "0.1.0"}


@router.post("/v1/internal/policy/invalidate")
async def invalidate_policy_cache(request: Request, payload: dict):
"""Drop the cached active policy for a single org.

Called by the dashboard after a policy create/update/delete (ADR-005).
Authenticated via HMAC over `org_id` with `KEY_HMAC_SECRET` — the same
shared secret used by api-key sync, so no new secret material to manage.

Body: {"org_id": "<id>"}
Header: X-Govs-Invalidate-HMAC: hex(hmac_sha256(KEY_HMAC_SECRET, org_id))
"""
import hashlib as _hashlib
import hmac as _hmac

from .policy_source import invalidate
from .settings import settings as _settings

org_id = (payload or {}).get("org_id")
if not isinstance(org_id, str) or not org_id:
raise HTTPException(status_code=400, detail="org_id required")

secret = _settings.key_hmac_secret.encode()
expected = _hmac.new(secret, org_id.encode(), _hashlib.sha256).hexdigest()
provided = request.headers.get("x-govs-invalidate-hmac", "")
if not _hmac.compare_digest(expected, provided):
raise HTTPException(status_code=401, detail="invalid signature")

invalidate(org_id)
return {"invalidated": org_id}


@router.get("/v1/ready")
async def ready():
"""
Expand Down Expand Up @@ -343,6 +374,7 @@ async def precheck(
tool_config=tool_config,
user_id=user_id,
budget_context=budget_context,
org_id=org_id,
)

# Add budget info to result if not already present
Expand Down Expand Up @@ -496,6 +528,7 @@ async def postcheck(
tool_config=tool_config,
user_id=user_id,
budget_context=budget_context,
org_id=org_id,
)

# Add budget info to result if not already present
Expand Down
30 changes: 25 additions & 5 deletions app/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,15 +910,35 @@ def evaluate_with_payload_policy(
tool_config: Optional[Dict] = None,
user_id: Optional[str] = None,
budget_context: Optional[Dict] = None,
org_id: Optional[str] = None,
) -> Dict:
"""
Evaluate policy using payload-provided configuration
Falls back to the loaded static YAML policy if no policy_config is provided.
Evaluate policy with the following precedence (ADR-005):
1. Payload `policy_config` if provided (backwards-compatible)
2. Dashboard-managed policy fetched by `org_id` from the shared DB
3. Static YAML policy (legacy fallback)
"""

resolved_policy_config = (
deepcopy(policy_config) if policy_config else deepcopy(get_policy())
)
if policy_config:
resolved_policy_config = deepcopy(policy_config)
else:
org_policy = None
if org_id:
try:
from .policy_source import get_active_policy

org_policy = get_active_policy(org_id)
except Exception as exc: # never break the request path on a fetcher bug
org_policy = None
# Use module logger; import is local to avoid circular load risk.
import logging as _logging

_logging.getLogger(__name__).warning(
"policy_source fetch failed for org=%s err=%s", org_id, exc
)
resolved_policy_config = (
deepcopy(org_policy) if org_policy else deepcopy(get_policy())
)
resolved_policy_config["tool"] = tool
resolved_policy_config["scope"] = scope or ""

Expand Down
187 changes: 187 additions & 0 deletions app/policy_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""Org-scoped policy source — reads from the dashboard's `Policy` table.

See ADR-005 (knowledge/ADR/005-policy-source-of-truth.md) for the design.

Public surface:
get_active_policy(org_id: str) -> dict | None
invalidate(org_id: str) -> None
cache_stats() -> dict # for diagnostics / metrics

The returned policy dict has the shape that `evaluate_with_payload_policy`
already understands (precheck/app/policies.py), so the evaluator does not
need to learn a new format — we just translate at the edge.
"""
from __future__ import annotations

import logging
import os
import threading
import time
from dataclasses import dataclass
from typing import Optional

from sqlalchemy.orm import Session

from .storage import DashboardPolicy, SessionLocal

logger = logging.getLogger(__name__)


def _ttl_seconds() -> int:
raw = os.environ.get("POLICY_CACHE_TTL_S", "60")
try:
return max(1, int(raw))
except ValueError:
return 60


@dataclass(frozen=True)
class _CacheEntry:
policy: Optional[dict]
fetched_at: float


_cache: dict[str, _CacheEntry] = {}
_cache_lock = threading.RLock()
_metrics = {"hit": 0, "miss": 0, "invalidate": 0}


# ──────────────────────────────────────────────────────────────────────
# Shape translation: dashboard Policy row → precheck PolicyConfig dict
# ──────────────────────────────────────────────────────────────────────
# The dashboard stores `defaults` as a free-form JSON. The convention we
# enforce in v1 is `{"pii": "<action>"}` where action ∈
# {"redact", "block", "tokenize", "allow"}. The evaluator wants the precheck
# shape `defaults[direction]["action"]` with action ∈
# {"deny", "redact", "tokenize", "pass_through"}.
_PII_ACTION_MAP = {
"redact": "redact",
"block": "deny",
"deny": "deny",
"tokenize": "tokenize",
"pass": "pass_through",
"pass_through": "pass_through",
"allow": "pass_through",
}


def _map_row_to_policy_config(row: DashboardPolicy) -> dict:
"""Translate a DashboardPolicy row into a precheck PolicyConfig dict.

The output shape is consumed verbatim by `evaluate_with_payload_policy`
(precheck/app/policies.py). Unknown keys in `row.defaults` are preserved
verbatim so future extensions don't need to touch this function.
"""
raw_defaults = row.defaults or {}

# Translate the v1 convention; default to "redact" if the field is absent.
pii_action_in = str(raw_defaults.get("pii", "redact")).lower()
pii_action = _PII_ACTION_MAP.get(pii_action_in, "redact")

return {
"version": row.version or "v1",
"defaults": {
"ingress": {"action": pii_action},
"egress": {"action": pii_action},
# Forward any non-pii defaults verbatim for forward-compat.
**{k: v for k, v in raw_defaults.items() if k != "pii"},
},
"tool_access": row.tool_access or {},
"deny_tools": row.deny_tools or [],
"allow_tools": row.allow_tools or [],
"network_scopes": row.network_scopes or [],
"network_tools": row.network_tools or [],
"on_error": row.on_error or "block",
# Provenance — useful in logs and audit, ignored by the evaluator.
"_policy_id": row.id,
"_policy_name": row.name,
"_priority": row.priority,
}


# ──────────────────────────────────────────────────────────────────────
# DB read
# ──────────────────────────────────────────────────────────────────────
def _fetch_from_db(org_id: str) -> Optional[dict]:
db: Session = SessionLocal()
try:
row = (
db.query(DashboardPolicy)
.filter(
DashboardPolicy.org_id == org_id,
DashboardPolicy.is_active.is_(True),
)
.order_by(DashboardPolicy.priority.desc(), DashboardPolicy.updated_at.desc())
.first()
)
if row is None:
return None
return _map_row_to_policy_config(row)
except Exception as exc:
# Don't blow up the request path on a DB hiccup — let the caller fall
# back to the YAML policy. Logged loudly so it's visible in audits.
logger.warning(
"policy_source: db fetch failed for org=%s err=%s", org_id, exc
)
return None
finally:
db.close()


# ──────────────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────────────
def get_active_policy(org_id: Optional[str]) -> Optional[dict]:
"""Return the highest-priority active policy for `org_id`, or None.

None means "no row in DB" — the caller should fall back to the YAML
default policy (preserving today's behavior for orgs without a
dashboard-managed policy).
"""
if not org_id:
return None

now = time.monotonic()
ttl = _ttl_seconds()

with _cache_lock:
entry = _cache.get(org_id)
if entry is not None and (now - entry.fetched_at) < ttl:
_metrics["hit"] += 1
return entry.policy

# Cache miss — fetch outside the lock to avoid holding it across IO.
fetched = _fetch_from_db(org_id)

with _cache_lock:
_cache[org_id] = _CacheEntry(policy=fetched, fetched_at=time.monotonic())
_metrics["miss"] += 1

return fetched


def invalidate(org_id: Optional[str]) -> None:
"""Drop the cached entry for org_id. No-op if missing or org_id is empty.

Called from the dashboard's invalidation webhook on policy writes
(see ADR-005 §Write/invalidate path).
"""
if not org_id:
return
with _cache_lock:
_cache.pop(org_id, None)
_metrics["invalidate"] += 1


def cache_stats() -> dict:
"""Return cache hit/miss/invalidate counts and current entry count."""
with _cache_lock:
return {**_metrics, "entries": len(_cache)}


def _clear_for_tests() -> None:
"""Test-only helper — clears the cache and resets counters."""
with _cache_lock:
_cache.clear()
for k in _metrics:
_metrics[k] = 0
47 changes: 45 additions & 2 deletions app/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Optional

from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Expand Down Expand Up @@ -42,6 +43,10 @@ class APIKey(Base):


class Policy(Base):
"""Legacy precheck-local policy table — kept for backward compat with the
YAML-import flow (Phase 2.3). Org-scoped policies live in DashboardPolicy
below, which mirrors the dashboard's Prisma Policy model. See ADR-005."""

__tablename__ = "policies"

id = Column(String, primary_key=True)
Expand All @@ -51,6 +56,37 @@ class Policy(Base):
is_active = Column(Boolean, default=True)


class DashboardPolicy(Base):
"""Read-only mirror of the dashboard's `Policy` table (Prisma model).

Precheck reads this table directly via the shared Postgres connection;
the dashboard owns writes. See ADR-005 (policy source of truth).

The table name is `Policy` (Prisma default — model name unchanged).
Column names use the Prisma @map snake_case form where applicable.
"""

__tablename__ = "Policy"

id = Column(String, primary_key=True)
org_id = Column("org_id", String, nullable=False, index=True)
user_id = Column("user_id", String, nullable=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
version = Column(String, nullable=False, default="v1")
defaults = Column(JSON, nullable=False)
tool_access = Column("tool_access", JSON, nullable=False, default=dict)
deny_tools = Column("deny_tools", JSON, nullable=False, default=list)
allow_tools = Column("allow_tools", JSON, nullable=False, default=list)
network_scopes = Column("network_scopes", JSON, nullable=False, default=list)
network_tools = Column("network_tools", JSON, nullable=False, default=list)
on_error = Column("on_error", String, nullable=False, default="block")
is_active = Column("isActive", Boolean, nullable=False, default=True)
priority = Column(Integer, nullable=False, default=0)
created_at = Column("createdAt", DateTime, default=datetime.utcnow)
updated_at = Column("updatedAt", DateTime, default=datetime.utcnow)


class UsageEvent(Base):
__tablename__ = "usage_events"

Expand Down Expand Up @@ -108,8 +144,15 @@ class BudgetTransaction(Base):


def create_tables():
"""Create all tables"""
Base.metadata.create_all(bind=engine)
"""Create all precheck-owned tables.

`DashboardPolicy` is intentionally excluded — that table is owned and
migrated by the dashboard (Prisma). Precheck only reads it. See ADR-005.
"""
owned_tables = [
t for t in Base.metadata.sorted_tables if t.name != "Policy"
]
Base.metadata.create_all(bind=engine, tables=owned_tables)


def get_db():
Expand Down
Loading
Loading