aiperture 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. aiperture/__init__.py +3 -0
  2. aiperture/api/__init__.py +5 -0
  3. aiperture/api/app.py +44 -0
  4. aiperture/api/auth.py +46 -0
  5. aiperture/api/routes/__init__.py +0 -0
  6. aiperture/api/routes/artifacts.py +181 -0
  7. aiperture/api/routes/audit.py +137 -0
  8. aiperture/api/routes/config.py +36 -0
  9. aiperture/api/routes/health.py +57 -0
  10. aiperture/api/routes/intelligence.py +41 -0
  11. aiperture/api/routes/metrics.py +13 -0
  12. aiperture/api/routes/permissions.py +240 -0
  13. aiperture/cli.py +179 -0
  14. aiperture/config.py +220 -0
  15. aiperture/db/__init__.py +5 -0
  16. aiperture/db/engine.py +75 -0
  17. aiperture/mcp_server.py +767 -0
  18. aiperture/metrics.py +80 -0
  19. aiperture/models/__init__.py +45 -0
  20. aiperture/models/artifact.py +68 -0
  21. aiperture/models/audit.py +38 -0
  22. aiperture/models/intelligence.py +24 -0
  23. aiperture/models/permission.py +83 -0
  24. aiperture/models/verdict.py +175 -0
  25. aiperture/permissions/__init__.py +47 -0
  26. aiperture/permissions/challenge.py +214 -0
  27. aiperture/permissions/crowd.py +162 -0
  28. aiperture/permissions/engine.py +915 -0
  29. aiperture/permissions/explainer.py +114 -0
  30. aiperture/permissions/intelligence.py +242 -0
  31. aiperture/permissions/learning.py +292 -0
  32. aiperture/permissions/presets.py +167 -0
  33. aiperture/permissions/resource.py +105 -0
  34. aiperture/permissions/risk.py +572 -0
  35. aiperture/permissions/scope_normalize.py +232 -0
  36. aiperture/permissions/similarity.py +281 -0
  37. aiperture/plugins.py +157 -0
  38. aiperture/stores/__init__.py +6 -0
  39. aiperture/stores/artifact_store.py +209 -0
  40. aiperture/stores/audit_store.py +233 -0
  41. aiperture-0.3.0.dist-info/METADATA +570 -0
  42. aiperture-0.3.0.dist-info/RECORD +45 -0
  43. aiperture-0.3.0.dist-info/WHEEL +4 -0
  44. aiperture-0.3.0.dist-info/entry_points.txt +2 -0
  45. aiperture-0.3.0.dist-info/licenses/LICENSE +190 -0
aiperture/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AIperture — The permission layer for AI agents."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """AIperture API — FastAPI application."""
2
+
3
+ from aiperture.api.app import create_app
4
+
5
+ __all__ = ["create_app"]
aiperture/api/app.py ADDED
@@ -0,0 +1,44 @@
1
+ """FastAPI application factory."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ from fastapi import Depends, FastAPI
7
+
8
+ from aiperture import plugins
9
+ from aiperture.api.auth import require_api_key
10
+ from aiperture.api.routes import artifacts, audit, config, health, intelligence, metrics, permissions
11
+ from aiperture.db import init_db
12
+
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
16
+ plugins.load_all()
17
+ init_db()
18
+ yield
19
+
20
+
21
+ def create_app() -> FastAPI:
22
+ app = FastAPI(
23
+ title="AIperture",
24
+ description="The permission layer for AI agents. Controls what passes through.",
25
+ version="0.3.0",
26
+ lifespan=lifespan,
27
+ dependencies=[Depends(require_api_key)],
28
+ )
29
+
30
+ app.include_router(permissions.router, prefix="/permissions", tags=["permissions"])
31
+ app.include_router(artifacts.router, prefix="/artifacts", tags=["artifacts"])
32
+ app.include_router(audit.router, prefix="/audit", tags=["audit"])
33
+ app.include_router(intelligence.router, prefix="/intelligence", tags=["intelligence"])
34
+ app.include_router(config.router, prefix="/config", tags=["config"])
35
+ app.include_router(health.router, tags=["health"])
36
+ app.include_router(metrics.router, tags=["metrics"])
37
+
38
+ # Register plugin routers if any
39
+ plugin_router = plugins.get("router")
40
+ if plugin_router is not None:
41
+ for router_info in plugin_router.get_routers():
42
+ app.include_router(router_info)
43
+
44
+ return app
aiperture/api/auth.py ADDED
@@ -0,0 +1,46 @@
1
+ """Bearer token authentication for the AIperture HTTP API.
2
+
3
+ If AIPERTURE_API_KEY is empty or unset, all requests are allowed (local dev mode).
4
+ If set, every request must include an ``Authorization: Bearer <key>`` header
5
+ whose value matches the configured key exactly.
6
+
7
+ A plugin ``auth_backend`` can replace or extend the default bearer-token check.
8
+
9
+ The MCP server (stdio transport) is unaffected — this dependency is only wired
10
+ into the FastAPI application.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from fastapi import Depends, HTTPException, Request, status
16
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
17
+
18
+ import aiperture.config
19
+ from aiperture import plugins
20
+
21
+ # optional=True so the scheme does not return 403 when the header is absent;
22
+ # we handle the missing-header case ourselves with a clear 401.
23
+ _bearer_scheme = HTTPBearer(auto_error=False)
24
+
25
+
26
+ async def require_api_key(
27
+ request: Request,
28
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
29
+ ) -> None:
30
+ """FastAPI dependency that enforces Bearer token auth when an API key is configured."""
31
+ # Delegate to plugin auth backend if present
32
+ auth_backend = plugins.get("auth_backend")
33
+ if auth_backend is not None:
34
+ await auth_backend.authenticate(request)
35
+ return
36
+
37
+ configured_key = aiperture.config.settings.api_key
38
+ if not configured_key:
39
+ # No key configured — open access (local dev mode).
40
+ return
41
+
42
+ if credentials is None or credentials.credentials != configured_key:
43
+ raise HTTPException(
44
+ status_code=status.HTTP_401_UNAUTHORIZED,
45
+ detail="Invalid or missing API key",
46
+ )
File without changes
@@ -0,0 +1,181 @@
1
+ """Artifact API — store, retrieve, verify, and track costs.
2
+
3
+ External runtimes call these endpoints to:
4
+ 1. Store every output as a verified artifact
5
+ 2. Retrieve artifacts for audit or review
6
+ 3. Re-verify artifact integrity
7
+ 4. Get cost summaries across runtimes/models
8
+ """
9
+
10
+
11
+ from fastapi import APIRouter, HTTPException
12
+ from pydantic import BaseModel
13
+
14
+ from aiperture.stores.artifact_store import ArtifactStore
15
+
16
+ router = APIRouter()
17
+ store = ArtifactStore()
18
+
19
+
20
+ # --- Request/Response schemas ---
21
+
22
+
23
+ class StoreRequest(BaseModel):
24
+ content: str
25
+ artifact_type: str = "custom"
26
+ organization_id: str = "default"
27
+ session_id: str = ""
28
+ task_id: str = ""
29
+ runtime_id: str = ""
30
+ tool_name: str = ""
31
+ tool_args: dict | None = None
32
+ summary: str = ""
33
+ extra: dict | None = None
34
+ tokens_input: int = 0
35
+ tokens_output: int = 0
36
+ cost_usd: float = 0.0
37
+ model_used: str = ""
38
+ provider_used: str = ""
39
+
40
+
41
+ class ArtifactResponse(BaseModel):
42
+ artifact_id: str
43
+ type: str
44
+ content_hash: str
45
+ verification_status: str
46
+ tool_name: str
47
+ summary: str
48
+ tokens_input: int
49
+ tokens_output: int
50
+ cost_usd: float
51
+ model_used: str
52
+ provider_used: str
53
+ created_at: str
54
+
55
+
56
+ # --- Endpoints ---
57
+
58
+
59
+ @router.post("/store", response_model=ArtifactResponse)
60
+ def store_artifact(req: StoreRequest):
61
+ """Store an artifact with automatic SHA-256 verification.
62
+
63
+ Every tool call, every LLM response, every output from any
64
+ external runtime gets persisted here as the canonical record.
65
+ """
66
+ artifact = store.store(
67
+ content=req.content,
68
+ artifact_type=req.artifact_type,
69
+ organization_id=req.organization_id,
70
+ session_id=req.session_id,
71
+ task_id=req.task_id,
72
+ runtime_id=req.runtime_id,
73
+ tool_name=req.tool_name,
74
+ tool_args=req.tool_args,
75
+ summary=req.summary,
76
+ extra=req.extra,
77
+ tokens_input=req.tokens_input,
78
+ tokens_output=req.tokens_output,
79
+ cost_usd=req.cost_usd,
80
+ model_used=req.model_used,
81
+ provider_used=req.provider_used,
82
+ )
83
+ return ArtifactResponse(
84
+ artifact_id=artifact.artifact_id,
85
+ type=artifact.type,
86
+ content_hash=artifact.content_hash,
87
+ verification_status=artifact.verification_status,
88
+ tool_name=artifact.tool_name,
89
+ summary=artifact.summary,
90
+ tokens_input=artifact.tokens_input,
91
+ tokens_output=artifact.tokens_output,
92
+ cost_usd=artifact.cost_usd,
93
+ model_used=artifact.model_used,
94
+ provider_used=artifact.provider_used,
95
+ created_at=artifact.created_at.isoformat(),
96
+ )
97
+
98
+
99
+ @router.get("/costs/summary")
100
+ def cost_summary(
101
+ organization_id: str = "default",
102
+ task_id: str | None = None,
103
+ runtime_id: str | None = None,
104
+ ):
105
+ """Get cost summary across artifacts.
106
+
107
+ Breaks down by provider and model. Shows total tokens and cost.
108
+ """
109
+ return store.get_cost_summary(
110
+ organization_id=organization_id,
111
+ task_id=task_id,
112
+ runtime_id=runtime_id,
113
+ )
114
+
115
+
116
+ @router.get("/task/{task_id}")
117
+ def list_by_task(task_id: str, organization_id: str = "default"):
118
+ """List all artifacts for a task."""
119
+ artifacts = store.list_by_task(task_id, organization_id)
120
+ return {
121
+ "task_id": task_id,
122
+ "count": len(artifacts),
123
+ "artifacts": [
124
+ {
125
+ "artifact_id": a.artifact_id,
126
+ "type": a.type,
127
+ "content_hash": a.content_hash,
128
+ "verification_status": a.verification_status,
129
+ "tool_name": a.tool_name,
130
+ "summary": a.summary,
131
+ "cost_usd": a.cost_usd,
132
+ "created_at": a.created_at.isoformat(),
133
+ }
134
+ for a in artifacts
135
+ ],
136
+ }
137
+
138
+
139
+ @router.get("/{artifact_id}")
140
+ def get_artifact(artifact_id: str):
141
+ """Retrieve a stored artifact."""
142
+ artifact = store.get(artifact_id)
143
+ if not artifact:
144
+ raise HTTPException(status_code=404, detail="Artifact not found")
145
+ return {
146
+ "artifact_id": artifact.artifact_id,
147
+ "type": artifact.type,
148
+ "content_hash": artifact.content_hash,
149
+ "verification_status": artifact.verification_status,
150
+ "content": artifact.content if artifact.storage_backend == "inline" else None,
151
+ "tool_name": artifact.tool_name,
152
+ "tool_args": artifact.tool_args,
153
+ "summary": artifact.summary,
154
+ "extra": artifact.extra,
155
+ "tokens_input": artifact.tokens_input,
156
+ "tokens_output": artifact.tokens_output,
157
+ "cost_usd": artifact.cost_usd,
158
+ "model_used": artifact.model_used,
159
+ "provider_used": artifact.provider_used,
160
+ "runtime_id": artifact.runtime_id,
161
+ "created_at": artifact.created_at.isoformat(),
162
+ }
163
+
164
+
165
+ @router.post("/{artifact_id}/verify")
166
+ def verify_artifact(artifact_id: str):
167
+ """Re-verify an artifact's integrity.
168
+
169
+ Recomputes SHA-256 hash and compares to stored hash.
170
+ Returns current verification status.
171
+ """
172
+ try:
173
+ artifact = store.verify(artifact_id)
174
+ except ValueError as e:
175
+ raise HTTPException(status_code=404, detail=str(e)) from e
176
+ return {
177
+ "artifact_id": artifact.artifact_id,
178
+ "verification_status": artifact.verification_status,
179
+ "content_hash": artifact.content_hash,
180
+ "verified_at": artifact.verified_at.isoformat() if artifact.verified_at else None,
181
+ }
@@ -0,0 +1,137 @@
1
+ """Audit API — immutable event trail.
2
+
3
+ Everything that happens in Aperture is logged here.
4
+ This is the compliance backbone.
5
+ """
6
+
7
+ from datetime import datetime
8
+
9
+ from fastapi import APIRouter, HTTPException
10
+
11
+ from aiperture.stores.audit_store import AuditStore
12
+
13
+ router = APIRouter()
14
+ store = AuditStore()
15
+
16
+
17
+ @router.get("/events")
18
+ def list_events(
19
+ organization_id: str = "default",
20
+ event_type: str | None = None,
21
+ entity_type: str | None = None,
22
+ entity_id: str | None = None,
23
+ actor_id: str | None = None,
24
+ runtime_id: str | None = None,
25
+ since: str | None = None,
26
+ until: str | None = None,
27
+ limit: int = 100,
28
+ offset: int = 0,
29
+ ):
30
+ """Query audit events with filters."""
31
+ limit = min(limit, 1000)
32
+ try:
33
+ since_dt = datetime.fromisoformat(since) if since else None
34
+ except ValueError:
35
+ raise HTTPException(status_code=422, detail=f"Invalid 'since' date format: {since!r}")
36
+ try:
37
+ until_dt = datetime.fromisoformat(until) if until else None
38
+ except ValueError:
39
+ raise HTTPException(status_code=422, detail=f"Invalid 'until' date format: {until!r}")
40
+
41
+ events = store.list_events(
42
+ organization_id=organization_id,
43
+ event_type=event_type,
44
+ entity_type=entity_type,
45
+ entity_id=entity_id,
46
+ actor_id=actor_id,
47
+ runtime_id=runtime_id,
48
+ since=since_dt,
49
+ until=until_dt,
50
+ limit=limit,
51
+ offset=offset,
52
+ )
53
+ return {
54
+ "count": len(events),
55
+ "events": [
56
+ {
57
+ "event_id": e.event_id,
58
+ "event_type": e.event_type,
59
+ "entity_type": e.entity_type,
60
+ "entity_id": e.entity_id,
61
+ "summary": e.summary,
62
+ "actor_id": e.actor_id,
63
+ "actor_type": e.actor_type,
64
+ "runtime_id": e.runtime_id,
65
+ "details": e.details,
66
+ "created_at": e.created_at.isoformat(),
67
+ }
68
+ for e in events
69
+ ],
70
+ }
71
+
72
+
73
+ @router.get("/events/{event_id}")
74
+ def get_event(event_id: str):
75
+ """Get a single audit event with full details."""
76
+ event = store.get_event(event_id)
77
+ if not event:
78
+ raise HTTPException(status_code=404, detail="Audit event not found")
79
+ return {
80
+ "event_id": event.event_id,
81
+ "event_type": event.event_type,
82
+ "entity_type": event.entity_type,
83
+ "entity_id": event.entity_id,
84
+ "summary": event.summary,
85
+ "actor_id": event.actor_id,
86
+ "actor_type": event.actor_type,
87
+ "runtime_id": event.runtime_id,
88
+ "details": event.details,
89
+ "previous_state": event.previous_state,
90
+ "new_state": event.new_state,
91
+ "created_at": event.created_at.isoformat(),
92
+ }
93
+
94
+
95
+ @router.get("/entity/{entity_type}/{entity_id}")
96
+ def entity_history(
97
+ entity_type: str,
98
+ entity_id: str,
99
+ organization_id: str = "default",
100
+ limit: int = 50,
101
+ ):
102
+ """Full audit history of a specific entity."""
103
+ limit = min(limit, 1000)
104
+ events = store.get_entity_history(entity_type, entity_id, organization_id, limit)
105
+ return {
106
+ "entity_type": entity_type,
107
+ "entity_id": entity_id,
108
+ "count": len(events),
109
+ "events": [
110
+ {
111
+ "event_id": e.event_id,
112
+ "event_type": e.event_type,
113
+ "summary": e.summary,
114
+ "actor_id": e.actor_id,
115
+ "details": e.details,
116
+ "created_at": e.created_at.isoformat(),
117
+ }
118
+ for e in events
119
+ ],
120
+ }
121
+
122
+
123
+ @router.get("/count")
124
+ def event_count(organization_id: str = "default"):
125
+ """Total audit event count."""
126
+ return {"count": store.count(organization_id)}
127
+
128
+
129
+ @router.get("/verify-chain")
130
+ def verify_chain(organization_id: str = "default"):
131
+ """Verify the hash chain integrity of the audit trail.
132
+
133
+ Walks the entire chain and checks that each event's hash matches
134
+ its computed value and links to the previous event's hash.
135
+ Any deletion, reordering, or tampering breaks the chain.
136
+ """
137
+ return store.verify_chain(organization_id)
@@ -0,0 +1,36 @@
1
+ """Configuration API — GET /config and PATCH /config for tunable settings."""
2
+
3
+ from typing import Any
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ import aiperture.config
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class ConfigPatchRequest(BaseModel):
14
+ settings: dict[str, Any]
15
+
16
+
17
+ @router.get("")
18
+ def get_config():
19
+ """Return current tunable settings and their descriptions."""
20
+ return {
21
+ "settings": aiperture.config.get_tunable_config(),
22
+ "descriptions": dict(aiperture.config.Settings.TUNABLE_DESCRIPTIONS),
23
+ }
24
+
25
+
26
+ @router.patch("")
27
+ def patch_config(body: ConfigPatchRequest):
28
+ """Update tunable settings at runtime. Persists to .aiperture.env."""
29
+ try:
30
+ aiperture.config.update_settings(body.settings)
31
+ except ValueError as e:
32
+ raise HTTPException(status_code=400, detail=str(e))
33
+ return {
34
+ "updated": True,
35
+ "settings": aiperture.config.get_tunable_config(),
36
+ }
@@ -0,0 +1,57 @@
1
+ """Health check endpoint — database connectivity probe."""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter
6
+ from sqlalchemy import text
7
+ from sqlmodel import Session
8
+
9
+ from aiperture import plugins
10
+ from aiperture.db import get_engine
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.get("/health")
18
+ def health_check():
19
+ """Check service health including database connectivity.
20
+
21
+ Returns:
22
+ 200 with {"status": "healthy", "database": "connected"} when DB is reachable.
23
+ 200 with {"status": "degraded", "database": "error", "detail": "..."} when DB is unreachable.
24
+
25
+ Always returns 200 so load balancers can distinguish between
26
+ "service is up but degraded" vs "service is down".
27
+ """
28
+ result = {
29
+ "service": "aiperture",
30
+ "version": "0.2.0",
31
+ }
32
+
33
+ try:
34
+ with Session(get_engine()) as session:
35
+ session.execute(text("SELECT 1"))
36
+ result["status"] = "healthy"
37
+ result["database"] = "connected"
38
+ except Exception as exc:
39
+ detail = str(exc)
40
+ logger.warning("Health check: database unreachable — %s", detail)
41
+ result["status"] = "degraded"
42
+ result["database"] = "error"
43
+ result["detail"] = detail
44
+
45
+ # Run plugin health checkers
46
+ health_checker = plugins.get("health_checker")
47
+ if health_checker is not None:
48
+ try:
49
+ plugin_result = health_checker.check()
50
+ result[health_checker.name] = plugin_result
51
+ if plugin_result.get("status") != "healthy":
52
+ result["status"] = "degraded"
53
+ except Exception as exc:
54
+ result[health_checker.name] = {"status": "error", "detail": str(exc)}
55
+ result["status"] = "degraded"
56
+
57
+ return result
@@ -0,0 +1,41 @@
1
+ """Intelligence API — cross-org anonymized permission signals."""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from aiperture.permissions.intelligence import IntelligenceEngine
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/global-signal")
11
+ def get_global_signal(
12
+ tool: str,
13
+ action: str,
14
+ scope: str,
15
+ ):
16
+ """Get cross-organization permission signal for a pattern.
17
+
18
+ Returns DP-protected aggregate statistics. Only available when
19
+ enough organizations have contributed data (min_orgs threshold).
20
+ """
21
+ import aiperture.config
22
+
23
+ intel = IntelligenceEngine(
24
+ min_orgs=aiperture.config.settings.intelligence_min_orgs,
25
+ default_epsilon=aiperture.config.settings.intelligence_epsilon,
26
+ )
27
+ signal = intel.get_global_signal(tool, action, scope)
28
+
29
+ if signal is None:
30
+ return {"available": False, "reason": "Insufficient cross-org data"}
31
+
32
+ return {
33
+ "available": True,
34
+ "total_orgs": signal.total_orgs,
35
+ "estimated_allow_rate": round(signal.estimated_allow_rate, 3),
36
+ "confidence_interval": [
37
+ round(signal.confidence_interval[0], 3),
38
+ round(signal.confidence_interval[1], 3),
39
+ ],
40
+ "sample_size": signal.sample_size,
41
+ }
@@ -0,0 +1,13 @@
1
+ """Prometheus metrics endpoint."""
2
+
3
+ from fastapi import APIRouter
4
+ from fastapi.responses import PlainTextResponse
5
+ from prometheus_client import generate_latest
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/metrics", response_class=PlainTextResponse)
11
+ def metrics():
12
+ """Prometheus-compatible metrics endpoint."""
13
+ return generate_latest()