driftshield-sdk 0.2.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.
- driftshield/__init__.py +31 -0
- driftshield/api/__init__.py +0 -0
- driftshield/api/app.py +47 -0
- driftshield/api/auth.py +13 -0
- driftshield/api/dependencies.py +24 -0
- driftshield/api/ingest_workflow.py +102 -0
- driftshield/api/routes/__init__.py +0 -0
- driftshield/api/routes/behaviour.py +160 -0
- driftshield/api/routes/connectors.py +209 -0
- driftshield/api/routes/health.py +13 -0
- driftshield/api/routes/ingest.py +39 -0
- driftshield/api/routes/reports.py +327 -0
- driftshield/api/routes/sessions.py +686 -0
- driftshield/api/schemas.py +350 -0
- driftshield/api/security.py +63 -0
- driftshield/api/server.py +18 -0
- driftshield/cli/__init__.py +0 -0
- driftshield/cli/_batch.py +244 -0
- driftshield/cli/_submit.py +101 -0
- driftshield/cli/commands/__init__.py +0 -0
- driftshield/cli/commands/analyze.py +177 -0
- driftshield/cli/commands/batch.py +175 -0
- driftshield/cli/commands/connectors.py +328 -0
- driftshield/cli/commands/export_validations.py +24 -0
- driftshield/cli/commands/generate_fixtures.py +27 -0
- driftshield/cli/commands/ingest.py +386 -0
- driftshield/cli/commands/inspect.py +167 -0
- driftshield/cli/commands/list.py +74 -0
- driftshield/cli/commands/report.py +55 -0
- driftshield/cli/commands/show_result.py +106 -0
- driftshield/cli/commands/signatures.py +96 -0
- driftshield/cli/commands/submit.py +104 -0
- driftshield/cli/commands/telemetry.py +238 -0
- driftshield/cli/discovery.py +96 -0
- driftshield/cli/main.py +64 -0
- driftshield/cli/output.py +125 -0
- driftshield/connectors/__init__.py +1 -0
- driftshield/connectors/registry.py +193 -0
- driftshield/connectors/watcher.py +272 -0
- driftshield/core/__init__.py +0 -0
- driftshield/core/analysis/__init__.py +0 -0
- driftshield/core/analysis/heuristics.py +434 -0
- driftshield/core/analysis/inflection.py +356 -0
- driftshield/core/analysis/risk.py +113 -0
- driftshield/core/analysis/session.py +119 -0
- driftshield/core/canonical_analysis.py +1208 -0
- driftshield/core/deterministic_matching.py +671 -0
- driftshield/core/graph/__init__.py +0 -0
- driftshield/core/graph/builder.py +229 -0
- driftshield/core/graph/models.py +261 -0
- driftshield/core/integrity.py +177 -0
- driftshield/core/models.py +467 -0
- driftshield/core/normalization.py +375 -0
- driftshield/core/visibility.py +187 -0
- driftshield/db/__init__.py +0 -0
- driftshield/db/behaviour_service.py +192 -0
- driftshield/db/connector_service.py +209 -0
- driftshield/db/engine.py +19 -0
- driftshield/db/hosted_schema_sql.py +230 -0
- driftshield/db/ingest_service.py +66 -0
- driftshield/db/migrations/README +1 -0
- driftshield/db/migrations/env.py +81 -0
- driftshield/db/migrations/script.py.mako +28 -0
- driftshield/db/migrations/versions/1c2f9f4b7d21_add_transcript_provenance_and_dedupe.py +45 -0
- driftshield/db/migrations/versions/20260511_01_add_hosted_submission_schema.py +28 -0
- driftshield/db/migrations/versions/20260512_02_seed_oss_fallback_installation.py +39 -0
- driftshield/db/migrations/versions/20260516_03_json_to_jsonb_for_distinct_and_equality.py +64 -0
- driftshield/db/migrations/versions/2d4f6b9e8c13_add_connectors_table.py +56 -0
- driftshield/db/migrations/versions/7f2d6c4a9b31_add_analyst_validations_table.py +48 -0
- driftshield/db/migrations/versions/8f1a2b3c4d5e_add_connector_last_ingested_at.py +107 -0
- driftshield/db/migrations/versions/9b3d7e1a4c2f_remove_recurrence_tables_from_oss.py +75 -0
- driftshield/db/migrations/versions/b7c1e3d5f6a2_add_forensic_cases_table.py +47 -0
- driftshield/db/migrations/versions/c3d9e4f1a2b5_add_behaviour_event_tables.py +89 -0
- driftshield/db/migrations/versions/e0b85984643e_initial_schema_5_core_tables.py +101 -0
- driftshield/db/models.py +267 -0
- driftshield/db/persistence.py +779 -0
- driftshield/db/validation_service.py +366 -0
- driftshield/fixtures/__init__.py +6 -0
- driftshield/fixtures/transcript_generator.py +132 -0
- driftshield/intake_contract.py +158 -0
- driftshield/parsers/__init__.py +1 -0
- driftshield/parsers/claude_code.py +302 -0
- driftshield/parsers/claude_desktop.py +6 -0
- driftshield/parsers/codex_cli.py +6 -0
- driftshield/parsers/codex_desktop.py +6 -0
- driftshield/parsers/crewai.py +153 -0
- driftshield/parsers/langchain.py +318 -0
- driftshield/parsers/local_chat.py +202 -0
- driftshield/parsers/openclaw.py +231 -0
- driftshield/parsers/openclaw_trajectory.py +454 -0
- driftshield/parsers/protocol.py +22 -0
- driftshield/parsers/registry.py +227 -0
- driftshield/public.py +700 -0
- driftshield/recursive_redactor.py +474 -0
- driftshield/remote_submission.py +215 -0
- driftshield/remote_upload.py +284 -0
- driftshield/reports/__init__.py +0 -0
- driftshield/reports/builder.py +678 -0
- driftshield/reports/json_export.py +99 -0
- driftshield/reports/markdown.py +22 -0
- driftshield/reports/models.py +106 -0
- driftshield/reports/templates/full.md.j2 +70 -0
- driftshield/reports/templates/summary.md.j2 +51 -0
- driftshield/signatures/__init__.py +126 -0
- driftshield/signatures/community.py +237 -0
- driftshield/signatures/distribution.py +303 -0
- driftshield/signatures/packs/community-general.json +213 -0
- driftshield/telemetry/__init__.py +19 -0
- driftshield/telemetry/service.py +258 -0
- driftshield_sdk-0.2.0.dist-info/METADATA +72 -0
- driftshield_sdk-0.2.0.dist-info/RECORD +113 -0
- driftshield_sdk-0.2.0.dist-info/WHEEL +4 -0
- driftshield_sdk-0.2.0.dist-info/entry_points.txt +3 -0
driftshield/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""DriftShield - AI Decision Forensics.
|
|
2
|
+
|
|
3
|
+
The public API is exactly two operations, ``analyse_run`` and ``submit``, with
|
|
4
|
+
their result and error types. Everything else in the package is internal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from driftshield.public import (
|
|
8
|
+
AnalysedRun,
|
|
9
|
+
Finding,
|
|
10
|
+
NoParseableEventsError,
|
|
11
|
+
SignatureHit,
|
|
12
|
+
SubmitError,
|
|
13
|
+
SubmitReceipt,
|
|
14
|
+
UnsupportedFormatError,
|
|
15
|
+
analyse_run,
|
|
16
|
+
submit,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AnalysedRun",
|
|
23
|
+
"Finding",
|
|
24
|
+
"NoParseableEventsError",
|
|
25
|
+
"SignatureHit",
|
|
26
|
+
"SubmitError",
|
|
27
|
+
"SubmitReceipt",
|
|
28
|
+
"UnsupportedFormatError",
|
|
29
|
+
"analyse_run",
|
|
30
|
+
"submit",
|
|
31
|
+
]
|
|
File without changes
|
driftshield/api/app.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.responses import FileResponse
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
|
|
8
|
+
from driftshield import __version__
|
|
9
|
+
from driftshield.api.routes.behaviour import router as behaviour_router
|
|
10
|
+
from driftshield.api.routes.connectors import router as connectors_router
|
|
11
|
+
from driftshield.api.routes.health import router as health_router
|
|
12
|
+
from driftshield.api.routes.ingest import router as ingest_router
|
|
13
|
+
from driftshield.api.routes.reports import router as reports_router
|
|
14
|
+
from driftshield.api.routes.sessions import router as sessions_router
|
|
15
|
+
from driftshield.api.security import RequestSizeLimitMiddleware
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_app() -> FastAPI:
|
|
19
|
+
app = FastAPI(
|
|
20
|
+
title="DriftShield",
|
|
21
|
+
description="AI Decision Forensics API",
|
|
22
|
+
version=__version__,
|
|
23
|
+
)
|
|
24
|
+
app.add_middleware(RequestSizeLimitMiddleware)
|
|
25
|
+
app.include_router(behaviour_router)
|
|
26
|
+
app.include_router(connectors_router)
|
|
27
|
+
app.include_router(health_router)
|
|
28
|
+
app.include_router(ingest_router)
|
|
29
|
+
app.include_router(sessions_router)
|
|
30
|
+
app.include_router(reports_router)
|
|
31
|
+
|
|
32
|
+
# Serve React static files in production
|
|
33
|
+
# When installed as a package, __file__ points to site-packages.
|
|
34
|
+
# Use STATIC_DIR env var (set in Docker) or fall back to relative path for local dev.
|
|
35
|
+
static_dir = Path(os.environ.get("STATIC_DIR", Path(__file__).parent.parent.parent.parent / "static"))
|
|
36
|
+
if static_dir.exists():
|
|
37
|
+
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
|
38
|
+
|
|
39
|
+
@app.get("/{path:path}")
|
|
40
|
+
async def serve_spa(path: str) -> FileResponse:
|
|
41
|
+
"""Serve React SPA. All non-API routes fall through to index.html."""
|
|
42
|
+
file_path = static_dir / path
|
|
43
|
+
if file_path.exists() and file_path.is_file():
|
|
44
|
+
return FileResponse(str(file_path))
|
|
45
|
+
return FileResponse(str(static_dir / "index.html"))
|
|
46
|
+
|
|
47
|
+
return app
|
driftshield/api/auth.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from fastapi import HTTPException, Security
|
|
2
|
+
from fastapi.security import APIKeyHeader
|
|
3
|
+
|
|
4
|
+
from driftshield.api.security import get_expected_api_key
|
|
5
|
+
|
|
6
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def require_api_key(api_key: str | None = Security(api_key_header)) -> str:
|
|
10
|
+
expected = get_expected_api_key()
|
|
11
|
+
if not api_key or api_key != expected:
|
|
12
|
+
raise HTTPException(status_code=401, detail="Missing or invalid API key")
|
|
13
|
+
return api_key
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections.abc import Generator
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.orm import Session as DBSession
|
|
4
|
+
|
|
5
|
+
from driftshield.db.engine import get_engine, get_session_factory
|
|
6
|
+
|
|
7
|
+
_engine = None
|
|
8
|
+
_session_factory = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_db() -> Generator[DBSession, None, None]:
|
|
12
|
+
global _engine, _session_factory
|
|
13
|
+
if _engine is None:
|
|
14
|
+
_engine = get_engine()
|
|
15
|
+
_session_factory = get_session_factory(_engine)
|
|
16
|
+
session = _session_factory()
|
|
17
|
+
try:
|
|
18
|
+
yield session
|
|
19
|
+
session.commit()
|
|
20
|
+
except Exception:
|
|
21
|
+
session.rollback()
|
|
22
|
+
raise
|
|
23
|
+
finally:
|
|
24
|
+
session.close()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from fastapi import HTTPException, UploadFile
|
|
2
|
+
from sqlalchemy.exc import IntegrityError
|
|
3
|
+
from sqlalchemy.orm import Session as DBSession
|
|
4
|
+
|
|
5
|
+
from driftshield.api.security import get_max_request_bytes
|
|
6
|
+
from driftshield.core.analysis.session import AnalysisResult
|
|
7
|
+
from driftshield.db.behaviour_service import BehaviourEventService
|
|
8
|
+
from driftshield.db.ingest_service import TranscriptIngestService, metrics_payload_from_analysis_result
|
|
9
|
+
from driftshield.db.persistence import IngestOutcome, PersistenceService
|
|
10
|
+
from driftshield.public import analyse_run
|
|
11
|
+
from driftshield.telemetry import TelemetryService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ingest_transcript_bytes(
|
|
15
|
+
db: DBSession,
|
|
16
|
+
*,
|
|
17
|
+
raw_bytes: bytes,
|
|
18
|
+
format_name: str,
|
|
19
|
+
filename: str | None,
|
|
20
|
+
commit: bool = True,
|
|
21
|
+
) -> tuple[IngestOutcome, AnalysisResult | None, str]:
|
|
22
|
+
_validate_request_size(raw_bytes)
|
|
23
|
+
try:
|
|
24
|
+
run = analyse_run(
|
|
25
|
+
raw_bytes,
|
|
26
|
+
source=filename,
|
|
27
|
+
format=None if format_name == "auto" else format_name,
|
|
28
|
+
)
|
|
29
|
+
except ValueError as exc:
|
|
30
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
31
|
+
normalised = run.detected_format
|
|
32
|
+
|
|
33
|
+
ingest_service = TranscriptIngestService(db)
|
|
34
|
+
try:
|
|
35
|
+
outcome, analysis_result = ingest_service.ingest_run(run)
|
|
36
|
+
if not outcome.deduplicated and analysis_result is not None and analysis_result.events:
|
|
37
|
+
BehaviourEventService(db).link_new_run_after_pattern_view(session_id=outcome.session_id)
|
|
38
|
+
if commit:
|
|
39
|
+
db.commit()
|
|
40
|
+
if not outcome.deduplicated and analysis_result is not None:
|
|
41
|
+
record_analysis_telemetry(analysis_result)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
44
|
+
except IntegrityError:
|
|
45
|
+
db.rollback()
|
|
46
|
+
outcome = PersistenceService(db).get_ingest_outcome(run.provenance)
|
|
47
|
+
if outcome is None:
|
|
48
|
+
raise
|
|
49
|
+
analysis_result = None
|
|
50
|
+
except HTTPException:
|
|
51
|
+
db.rollback()
|
|
52
|
+
raise
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
db.rollback()
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=422,
|
|
57
|
+
detail=f"Failed to process transcript: {exc}",
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
return outcome, analysis_result, normalised
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_upload_bytes(
|
|
64
|
+
file: UploadFile,
|
|
65
|
+
*,
|
|
66
|
+
chunk_size: int = 1024 * 1024,
|
|
67
|
+
) -> bytes:
|
|
68
|
+
max_request_bytes = get_max_request_bytes()
|
|
69
|
+
total_bytes = 0
|
|
70
|
+
chunks: list[bytes] = []
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
chunk = file.file.read(chunk_size)
|
|
74
|
+
if not chunk:
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
total_bytes += len(chunk)
|
|
78
|
+
if total_bytes > max_request_bytes:
|
|
79
|
+
raise HTTPException(status_code=413, detail=f"Request body exceeds {max_request_bytes} bytes")
|
|
80
|
+
chunks.append(chunk)
|
|
81
|
+
|
|
82
|
+
return b"".join(chunks)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def record_analysis_telemetry(result: AnalysisResult) -> None:
|
|
86
|
+
metrics = metrics_payload_from_analysis_result(result)
|
|
87
|
+
try:
|
|
88
|
+
TelemetryService().record_analysis_event(
|
|
89
|
+
outcome_status=metrics["outcome_status"],
|
|
90
|
+
match_count=metrics["match_count"],
|
|
91
|
+
primary_mechanism_id=metrics["primary_family_id"],
|
|
92
|
+
mixed_mechanism=metrics["mixed_family"],
|
|
93
|
+
not_classifiable_reason=metrics["not_classifiable_reason"],
|
|
94
|
+
)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_request_size(raw_bytes: bytes) -> None:
|
|
100
|
+
max_request_bytes = get_max_request_bytes()
|
|
101
|
+
if len(raw_bytes) > max_request_bytes:
|
|
102
|
+
raise HTTPException(status_code=413, detail=f"Request body exceeds {max_request_bytes} bytes")
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Literal, cast
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
5
|
+
from sqlalchemy.orm import Session as DBSession
|
|
6
|
+
|
|
7
|
+
from driftshield.api.auth import require_api_key
|
|
8
|
+
from driftshield.api.dependencies import get_db
|
|
9
|
+
from driftshield.api.schemas import (
|
|
10
|
+
BehaviourEventCreateRequest,
|
|
11
|
+
BehaviourEventResponse,
|
|
12
|
+
BehaviourSubjectCreateRequest,
|
|
13
|
+
BehaviourSubjectResponse,
|
|
14
|
+
)
|
|
15
|
+
from driftshield.db.behaviour_service import BehaviourEventService, BehaviourSubjectSnapshot
|
|
16
|
+
from driftshield.db.models import SessionModel
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
SubjectType = Literal["trusted_pattern", "report", "linked_run_set"]
|
|
21
|
+
SurfaceType = Literal["api", "ui", "report"]
|
|
22
|
+
EventType = Literal[
|
|
23
|
+
"pattern_viewed",
|
|
24
|
+
"pattern_expanded",
|
|
25
|
+
"pattern_revisited",
|
|
26
|
+
"pattern_linked_runs_viewed",
|
|
27
|
+
"new_run_after_pattern_view",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_ALLOWED_SUBJECT_TYPES = {"trusted_pattern", "report", "linked_run_set"}
|
|
31
|
+
_ALLOWED_SURFACES = {"api", "ui", "report"}
|
|
32
|
+
_ALLOWED_EVENT_TYPES = {
|
|
33
|
+
"pattern_viewed",
|
|
34
|
+
"pattern_expanded",
|
|
35
|
+
"pattern_revisited",
|
|
36
|
+
"pattern_linked_runs_viewed",
|
|
37
|
+
"new_run_after_pattern_view",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.post("/api/behaviour/subjects", response_model=BehaviourSubjectResponse, status_code=201)
|
|
42
|
+
def create_behaviour_subject(
|
|
43
|
+
payload: BehaviourSubjectCreateRequest,
|
|
44
|
+
api_key: str = Depends(require_api_key),
|
|
45
|
+
db: DBSession = Depends(get_db),
|
|
46
|
+
) -> BehaviourSubjectResponse:
|
|
47
|
+
del api_key
|
|
48
|
+
_validate_subject_payload(payload)
|
|
49
|
+
_require_session_exists(db, payload.session_id, detail="Behaviour subject session not found")
|
|
50
|
+
|
|
51
|
+
service = BehaviourEventService(db)
|
|
52
|
+
subject = service.create_subject(
|
|
53
|
+
subject_type=cast(SubjectType, payload.subject_type),
|
|
54
|
+
pattern_reference=payload.pattern_reference,
|
|
55
|
+
trust_band=payload.trust_band,
|
|
56
|
+
surface=cast(SurfaceType, payload.surface),
|
|
57
|
+
session_id=payload.session_id,
|
|
58
|
+
first_exposed_at=payload.first_exposed_at,
|
|
59
|
+
metadata_json=payload.metadata_json,
|
|
60
|
+
)
|
|
61
|
+
snapshot = service.get_subject_snapshot(subject.id)
|
|
62
|
+
assert snapshot is not None
|
|
63
|
+
db.commit()
|
|
64
|
+
return _subject_response(snapshot)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/api/behaviour/subjects/{subject_id}", response_model=BehaviourSubjectResponse)
|
|
68
|
+
def get_behaviour_subject(
|
|
69
|
+
subject_id: uuid.UUID,
|
|
70
|
+
api_key: str = Depends(require_api_key),
|
|
71
|
+
db: DBSession = Depends(get_db),
|
|
72
|
+
) -> BehaviourSubjectResponse:
|
|
73
|
+
del api_key
|
|
74
|
+
snapshot = BehaviourEventService(db).get_subject_snapshot(subject_id)
|
|
75
|
+
if snapshot is None:
|
|
76
|
+
raise HTTPException(status_code=404, detail="Behaviour subject not found")
|
|
77
|
+
return _subject_response(snapshot)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("/api/behaviour/events", response_model=BehaviourEventResponse, status_code=201)
|
|
81
|
+
def create_behaviour_event(
|
|
82
|
+
payload: BehaviourEventCreateRequest,
|
|
83
|
+
api_key: str = Depends(require_api_key),
|
|
84
|
+
db: DBSession = Depends(get_db),
|
|
85
|
+
) -> BehaviourEventResponse:
|
|
86
|
+
del api_key
|
|
87
|
+
if payload.event_type not in _ALLOWED_EVENT_TYPES:
|
|
88
|
+
raise HTTPException(status_code=422, detail="Unsupported behaviour event type")
|
|
89
|
+
_require_session_exists(
|
|
90
|
+
db,
|
|
91
|
+
payload.linked_session_id,
|
|
92
|
+
detail="Linked behaviour event session not found",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
service = BehaviourEventService(db)
|
|
96
|
+
try:
|
|
97
|
+
event = service.record_event(
|
|
98
|
+
subject_id=payload.subject_id,
|
|
99
|
+
event_type=cast(EventType, payload.event_type),
|
|
100
|
+
actor_id=payload.actor_id,
|
|
101
|
+
originating_session_id=payload.originating_session_id,
|
|
102
|
+
linked_session_id=payload.linked_session_id,
|
|
103
|
+
occurred_at=payload.occurred_at,
|
|
104
|
+
metadata_json=payload.metadata_json,
|
|
105
|
+
)
|
|
106
|
+
except LookupError as exc:
|
|
107
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
108
|
+
|
|
109
|
+
db.commit()
|
|
110
|
+
return BehaviourEventResponse(
|
|
111
|
+
id=event.id,
|
|
112
|
+
subject_id=event.subject_id,
|
|
113
|
+
occurred_at=event.occurred_at,
|
|
114
|
+
event_type=event.event_type,
|
|
115
|
+
actor_id=event.actor_id,
|
|
116
|
+
originating_session_id=event.originating_session_id,
|
|
117
|
+
linked_session_id=event.linked_session_id,
|
|
118
|
+
metadata_json=event.metadata_json or {},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validate_subject_payload(payload: BehaviourSubjectCreateRequest) -> None:
|
|
123
|
+
if payload.subject_type not in _ALLOWED_SUBJECT_TYPES:
|
|
124
|
+
raise HTTPException(status_code=422, detail="Unsupported behaviour subject type")
|
|
125
|
+
if payload.surface not in _ALLOWED_SURFACES:
|
|
126
|
+
raise HTTPException(status_code=422, detail="Unsupported behaviour surface")
|
|
127
|
+
if payload.subject_type != "trusted_pattern" and payload.trust_band == "trusted":
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=422,
|
|
130
|
+
detail="Only trusted_pattern subjects can be marked trusted in OSS v1",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _require_session_exists(
|
|
135
|
+
db: DBSession,
|
|
136
|
+
session_id: uuid.UUID | None,
|
|
137
|
+
*,
|
|
138
|
+
detail: str,
|
|
139
|
+
) -> None:
|
|
140
|
+
if session_id is None:
|
|
141
|
+
return
|
|
142
|
+
if db.get(SessionModel, session_id) is None:
|
|
143
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _subject_response(snapshot: BehaviourSubjectSnapshot) -> BehaviourSubjectResponse:
|
|
147
|
+
subject = snapshot.subject
|
|
148
|
+
return BehaviourSubjectResponse(
|
|
149
|
+
id=subject.id,
|
|
150
|
+
session_id=subject.session_id,
|
|
151
|
+
subject_type=subject.subject_type,
|
|
152
|
+
pattern_reference=subject.pattern_reference,
|
|
153
|
+
trust_band=subject.trust_band,
|
|
154
|
+
surface=subject.surface,
|
|
155
|
+
first_exposed_at=subject.first_exposed_at,
|
|
156
|
+
metadata_json=subject.metadata_json or {},
|
|
157
|
+
tracking_status=snapshot.tracking_status,
|
|
158
|
+
follow_up_status=snapshot.follow_up_status,
|
|
159
|
+
event_counts=snapshot.event_counts,
|
|
160
|
+
)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from sqlalchemy.orm import Session as DBSession
|
|
10
|
+
|
|
11
|
+
from driftshield.api.auth import require_api_key
|
|
12
|
+
from driftshield.api.dependencies import get_db
|
|
13
|
+
from driftshield.api.schemas import (
|
|
14
|
+
ConnectorApproveRequest,
|
|
15
|
+
ConnectorDiscoverRequest,
|
|
16
|
+
ConnectorListResponse,
|
|
17
|
+
ConnectorResponse,
|
|
18
|
+
ConnectorScanResponse,
|
|
19
|
+
)
|
|
20
|
+
from driftshield.db.connector_service import ConnectorService
|
|
21
|
+
from driftshield.db.models import ConnectorModel
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _claude_home_from_env() -> Path | None:
|
|
27
|
+
value = os.environ.get("CLAUDE_HOME")
|
|
28
|
+
return Path(value).expanduser() if value else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ensure_utc(value: datetime | None) -> datetime | None:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
if value.tzinfo is None:
|
|
35
|
+
return value.replace(tzinfo=timezone.utc)
|
|
36
|
+
return value.astimezone(timezone.utc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _connector_response(connector: ConnectorModel) -> ConnectorResponse:
|
|
40
|
+
return ConnectorResponse(
|
|
41
|
+
id=connector.id,
|
|
42
|
+
source_type=connector.source_type,
|
|
43
|
+
display_name=connector.display_name,
|
|
44
|
+
root_path=connector.root_path,
|
|
45
|
+
parser_name=connector.parser_name,
|
|
46
|
+
consent_state=connector.consent_state,
|
|
47
|
+
status=connector.status,
|
|
48
|
+
watchable=connector.watchable,
|
|
49
|
+
metadata=connector.metadata_json or {},
|
|
50
|
+
watch_status=connector.watch_status,
|
|
51
|
+
last_scanned_at=_ensure_utc(connector.last_scanned_at),
|
|
52
|
+
last_watch_heartbeat_at=_ensure_utc(connector.last_watch_heartbeat_at),
|
|
53
|
+
last_ingested_at=_ensure_utc(connector.last_ingested_at),
|
|
54
|
+
last_seen_activity_at=_ensure_utc(connector.last_seen_activity_at),
|
|
55
|
+
last_error=connector.last_error,
|
|
56
|
+
last_error_at=_ensure_utc(connector.last_error_at),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/api/connectors", response_model=ConnectorListResponse)
|
|
61
|
+
def list_connectors(
|
|
62
|
+
api_key: str = Depends(require_api_key),
|
|
63
|
+
db: DBSession = Depends(get_db),
|
|
64
|
+
) -> ConnectorListResponse:
|
|
65
|
+
del api_key
|
|
66
|
+
service = ConnectorService(db)
|
|
67
|
+
return ConnectorListResponse(
|
|
68
|
+
items=[_connector_response(connector) for connector in service.list_connectors()]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/api/connectors/discover", response_model=ConnectorListResponse)
|
|
73
|
+
def discover_connectors(
|
|
74
|
+
payload: ConnectorDiscoverRequest,
|
|
75
|
+
api_key: str = Depends(require_api_key),
|
|
76
|
+
db: DBSession = Depends(get_db),
|
|
77
|
+
) -> ConnectorListResponse:
|
|
78
|
+
del api_key
|
|
79
|
+
service = ConnectorService(db)
|
|
80
|
+
project_dir = Path(payload.project_dir).expanduser().resolve() if payload.project_dir else Path.cwd()
|
|
81
|
+
connectors = service.refresh_candidates(
|
|
82
|
+
project_dir=project_dir,
|
|
83
|
+
claude_home=_claude_home_from_env(),
|
|
84
|
+
)
|
|
85
|
+
db.commit()
|
|
86
|
+
return ConnectorListResponse(items=[_connector_response(connector) for connector in connectors])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/api/connectors/{connector_id}", response_model=ConnectorResponse)
|
|
90
|
+
def get_connector(
|
|
91
|
+
connector_id: uuid.UUID,
|
|
92
|
+
api_key: str = Depends(require_api_key),
|
|
93
|
+
db: DBSession = Depends(get_db),
|
|
94
|
+
) -> ConnectorResponse:
|
|
95
|
+
del api_key
|
|
96
|
+
service = ConnectorService(db)
|
|
97
|
+
connector = service.get_connector(connector_id)
|
|
98
|
+
if connector is None:
|
|
99
|
+
raise HTTPException(status_code=404, detail="Connector not found")
|
|
100
|
+
return _connector_response(connector)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.post("/api/connectors/{connector_id}/approve", response_model=ConnectorResponse)
|
|
104
|
+
def approve_connector(
|
|
105
|
+
connector_id: uuid.UUID,
|
|
106
|
+
payload: ConnectorApproveRequest,
|
|
107
|
+
api_key: str = Depends(require_api_key),
|
|
108
|
+
db: DBSession = Depends(get_db),
|
|
109
|
+
) -> ConnectorResponse:
|
|
110
|
+
del api_key
|
|
111
|
+
service = ConnectorService(db)
|
|
112
|
+
try:
|
|
113
|
+
connector = service.approve_connector(connector_id, mode=payload.mode)
|
|
114
|
+
except LookupError as exc:
|
|
115
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
118
|
+
db.commit()
|
|
119
|
+
return _connector_response(connector)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@router.post("/api/connectors/{connector_id}/deny", response_model=ConnectorResponse)
|
|
123
|
+
def deny_connector(
|
|
124
|
+
connector_id: uuid.UUID,
|
|
125
|
+
api_key: str = Depends(require_api_key),
|
|
126
|
+
db: DBSession = Depends(get_db),
|
|
127
|
+
) -> ConnectorResponse:
|
|
128
|
+
del api_key
|
|
129
|
+
service = ConnectorService(db)
|
|
130
|
+
try:
|
|
131
|
+
connector = service.deny_connector(connector_id)
|
|
132
|
+
except LookupError as exc:
|
|
133
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
134
|
+
db.commit()
|
|
135
|
+
return _connector_response(connector)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/api/connectors/{connector_id}/pause", response_model=ConnectorResponse)
|
|
139
|
+
def pause_connector(
|
|
140
|
+
connector_id: uuid.UUID,
|
|
141
|
+
api_key: str = Depends(require_api_key),
|
|
142
|
+
db: DBSession = Depends(get_db),
|
|
143
|
+
) -> ConnectorResponse:
|
|
144
|
+
del api_key
|
|
145
|
+
service = ConnectorService(db)
|
|
146
|
+
try:
|
|
147
|
+
connector = service.pause_connector(connector_id)
|
|
148
|
+
except LookupError as exc:
|
|
149
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
150
|
+
db.commit()
|
|
151
|
+
return _connector_response(connector)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.post("/api/connectors/{connector_id}/resume", response_model=ConnectorResponse)
|
|
155
|
+
def resume_connector(
|
|
156
|
+
connector_id: uuid.UUID,
|
|
157
|
+
api_key: str = Depends(require_api_key),
|
|
158
|
+
db: DBSession = Depends(get_db),
|
|
159
|
+
) -> ConnectorResponse:
|
|
160
|
+
del api_key
|
|
161
|
+
service = ConnectorService(db)
|
|
162
|
+
try:
|
|
163
|
+
connector = service.resume_connector(connector_id)
|
|
164
|
+
except LookupError as exc:
|
|
165
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
168
|
+
db.commit()
|
|
169
|
+
return _connector_response(connector)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.post("/api/connectors/{connector_id}/disconnect", response_model=ConnectorResponse)
|
|
173
|
+
def disconnect_connector(
|
|
174
|
+
connector_id: uuid.UUID,
|
|
175
|
+
api_key: str = Depends(require_api_key),
|
|
176
|
+
db: DBSession = Depends(get_db),
|
|
177
|
+
) -> ConnectorResponse:
|
|
178
|
+
del api_key
|
|
179
|
+
service = ConnectorService(db)
|
|
180
|
+
try:
|
|
181
|
+
connector = service.disconnect_connector(connector_id)
|
|
182
|
+
except LookupError as exc:
|
|
183
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
184
|
+
db.commit()
|
|
185
|
+
return _connector_response(connector)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.post("/api/connectors/{connector_id}/rescan", response_model=ConnectorScanResponse)
|
|
189
|
+
def rescan_connector(
|
|
190
|
+
connector_id: uuid.UUID,
|
|
191
|
+
api_key: str = Depends(require_api_key),
|
|
192
|
+
db: DBSession = Depends(get_db),
|
|
193
|
+
) -> ConnectorScanResponse:
|
|
194
|
+
del api_key
|
|
195
|
+
service = ConnectorService(db)
|
|
196
|
+
try:
|
|
197
|
+
scan = service.rescan_connector(connector_id)
|
|
198
|
+
except LookupError as exc:
|
|
199
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
202
|
+
db.commit()
|
|
203
|
+
return ConnectorScanResponse(
|
|
204
|
+
connector_id=uuid.UUID(scan.connector_id),
|
|
205
|
+
session_count=scan.session_count,
|
|
206
|
+
newest_session_id=scan.newest_session_id,
|
|
207
|
+
newest_session_path=scan.newest_session_path,
|
|
208
|
+
newest_modified_at=_ensure_utc(scan.newest_modified_at),
|
|
209
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, File, Form, Response, UploadFile
|
|
2
|
+
from sqlalchemy.orm import Session as DBSession
|
|
3
|
+
|
|
4
|
+
from driftshield.api.auth import require_api_key
|
|
5
|
+
from driftshield.api.dependencies import get_db
|
|
6
|
+
from driftshield.api.ingest_workflow import ingest_transcript_bytes, read_upload_bytes
|
|
7
|
+
from driftshield.api.schemas import IngestResponse
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.post("/api/ingest", response_model=IngestResponse, status_code=201)
|
|
13
|
+
def ingest_transcript(
|
|
14
|
+
response: Response,
|
|
15
|
+
file: UploadFile = File(...),
|
|
16
|
+
format: str = Form(default="auto"),
|
|
17
|
+
api_key: str = Depends(require_api_key),
|
|
18
|
+
db: DBSession = Depends(get_db),
|
|
19
|
+
):
|
|
20
|
+
del api_key
|
|
21
|
+
raw_bytes = read_upload_bytes(file)
|
|
22
|
+
outcome, _, _ = ingest_transcript_bytes(
|
|
23
|
+
db,
|
|
24
|
+
raw_bytes=raw_bytes,
|
|
25
|
+
format_name=format,
|
|
26
|
+
filename=file.filename,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if outcome.deduplicated:
|
|
30
|
+
response.status_code = 200
|
|
31
|
+
|
|
32
|
+
return IngestResponse(
|
|
33
|
+
session_id=outcome.session_id,
|
|
34
|
+
total_events=outcome.total_events,
|
|
35
|
+
flagged_events=outcome.flagged_events,
|
|
36
|
+
has_inflection=outcome.has_inflection,
|
|
37
|
+
status=outcome.status,
|
|
38
|
+
deduplicated=outcome.deduplicated,
|
|
39
|
+
)
|