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.
Files changed (113) hide show
  1. driftshield/__init__.py +31 -0
  2. driftshield/api/__init__.py +0 -0
  3. driftshield/api/app.py +47 -0
  4. driftshield/api/auth.py +13 -0
  5. driftshield/api/dependencies.py +24 -0
  6. driftshield/api/ingest_workflow.py +102 -0
  7. driftshield/api/routes/__init__.py +0 -0
  8. driftshield/api/routes/behaviour.py +160 -0
  9. driftshield/api/routes/connectors.py +209 -0
  10. driftshield/api/routes/health.py +13 -0
  11. driftshield/api/routes/ingest.py +39 -0
  12. driftshield/api/routes/reports.py +327 -0
  13. driftshield/api/routes/sessions.py +686 -0
  14. driftshield/api/schemas.py +350 -0
  15. driftshield/api/security.py +63 -0
  16. driftshield/api/server.py +18 -0
  17. driftshield/cli/__init__.py +0 -0
  18. driftshield/cli/_batch.py +244 -0
  19. driftshield/cli/_submit.py +101 -0
  20. driftshield/cli/commands/__init__.py +0 -0
  21. driftshield/cli/commands/analyze.py +177 -0
  22. driftshield/cli/commands/batch.py +175 -0
  23. driftshield/cli/commands/connectors.py +328 -0
  24. driftshield/cli/commands/export_validations.py +24 -0
  25. driftshield/cli/commands/generate_fixtures.py +27 -0
  26. driftshield/cli/commands/ingest.py +386 -0
  27. driftshield/cli/commands/inspect.py +167 -0
  28. driftshield/cli/commands/list.py +74 -0
  29. driftshield/cli/commands/report.py +55 -0
  30. driftshield/cli/commands/show_result.py +106 -0
  31. driftshield/cli/commands/signatures.py +96 -0
  32. driftshield/cli/commands/submit.py +104 -0
  33. driftshield/cli/commands/telemetry.py +238 -0
  34. driftshield/cli/discovery.py +96 -0
  35. driftshield/cli/main.py +64 -0
  36. driftshield/cli/output.py +125 -0
  37. driftshield/connectors/__init__.py +1 -0
  38. driftshield/connectors/registry.py +193 -0
  39. driftshield/connectors/watcher.py +272 -0
  40. driftshield/core/__init__.py +0 -0
  41. driftshield/core/analysis/__init__.py +0 -0
  42. driftshield/core/analysis/heuristics.py +434 -0
  43. driftshield/core/analysis/inflection.py +356 -0
  44. driftshield/core/analysis/risk.py +113 -0
  45. driftshield/core/analysis/session.py +119 -0
  46. driftshield/core/canonical_analysis.py +1208 -0
  47. driftshield/core/deterministic_matching.py +671 -0
  48. driftshield/core/graph/__init__.py +0 -0
  49. driftshield/core/graph/builder.py +229 -0
  50. driftshield/core/graph/models.py +261 -0
  51. driftshield/core/integrity.py +177 -0
  52. driftshield/core/models.py +467 -0
  53. driftshield/core/normalization.py +375 -0
  54. driftshield/core/visibility.py +187 -0
  55. driftshield/db/__init__.py +0 -0
  56. driftshield/db/behaviour_service.py +192 -0
  57. driftshield/db/connector_service.py +209 -0
  58. driftshield/db/engine.py +19 -0
  59. driftshield/db/hosted_schema_sql.py +230 -0
  60. driftshield/db/ingest_service.py +66 -0
  61. driftshield/db/migrations/README +1 -0
  62. driftshield/db/migrations/env.py +81 -0
  63. driftshield/db/migrations/script.py.mako +28 -0
  64. driftshield/db/migrations/versions/1c2f9f4b7d21_add_transcript_provenance_and_dedupe.py +45 -0
  65. driftshield/db/migrations/versions/20260511_01_add_hosted_submission_schema.py +28 -0
  66. driftshield/db/migrations/versions/20260512_02_seed_oss_fallback_installation.py +39 -0
  67. driftshield/db/migrations/versions/20260516_03_json_to_jsonb_for_distinct_and_equality.py +64 -0
  68. driftshield/db/migrations/versions/2d4f6b9e8c13_add_connectors_table.py +56 -0
  69. driftshield/db/migrations/versions/7f2d6c4a9b31_add_analyst_validations_table.py +48 -0
  70. driftshield/db/migrations/versions/8f1a2b3c4d5e_add_connector_last_ingested_at.py +107 -0
  71. driftshield/db/migrations/versions/9b3d7e1a4c2f_remove_recurrence_tables_from_oss.py +75 -0
  72. driftshield/db/migrations/versions/b7c1e3d5f6a2_add_forensic_cases_table.py +47 -0
  73. driftshield/db/migrations/versions/c3d9e4f1a2b5_add_behaviour_event_tables.py +89 -0
  74. driftshield/db/migrations/versions/e0b85984643e_initial_schema_5_core_tables.py +101 -0
  75. driftshield/db/models.py +267 -0
  76. driftshield/db/persistence.py +779 -0
  77. driftshield/db/validation_service.py +366 -0
  78. driftshield/fixtures/__init__.py +6 -0
  79. driftshield/fixtures/transcript_generator.py +132 -0
  80. driftshield/intake_contract.py +158 -0
  81. driftshield/parsers/__init__.py +1 -0
  82. driftshield/parsers/claude_code.py +302 -0
  83. driftshield/parsers/claude_desktop.py +6 -0
  84. driftshield/parsers/codex_cli.py +6 -0
  85. driftshield/parsers/codex_desktop.py +6 -0
  86. driftshield/parsers/crewai.py +153 -0
  87. driftshield/parsers/langchain.py +318 -0
  88. driftshield/parsers/local_chat.py +202 -0
  89. driftshield/parsers/openclaw.py +231 -0
  90. driftshield/parsers/openclaw_trajectory.py +454 -0
  91. driftshield/parsers/protocol.py +22 -0
  92. driftshield/parsers/registry.py +227 -0
  93. driftshield/public.py +700 -0
  94. driftshield/recursive_redactor.py +474 -0
  95. driftshield/remote_submission.py +215 -0
  96. driftshield/remote_upload.py +284 -0
  97. driftshield/reports/__init__.py +0 -0
  98. driftshield/reports/builder.py +678 -0
  99. driftshield/reports/json_export.py +99 -0
  100. driftshield/reports/markdown.py +22 -0
  101. driftshield/reports/models.py +106 -0
  102. driftshield/reports/templates/full.md.j2 +70 -0
  103. driftshield/reports/templates/summary.md.j2 +51 -0
  104. driftshield/signatures/__init__.py +126 -0
  105. driftshield/signatures/community.py +237 -0
  106. driftshield/signatures/distribution.py +303 -0
  107. driftshield/signatures/packs/community-general.json +213 -0
  108. driftshield/telemetry/__init__.py +19 -0
  109. driftshield/telemetry/service.py +258 -0
  110. driftshield_sdk-0.2.0.dist-info/METADATA +72 -0
  111. driftshield_sdk-0.2.0.dist-info/RECORD +113 -0
  112. driftshield_sdk-0.2.0.dist-info/WHEEL +4 -0
  113. driftshield_sdk-0.2.0.dist-info/entry_points.txt +3 -0
@@ -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
@@ -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,13 @@
1
+ from fastapi import APIRouter
2
+
3
+ from driftshield import __version__
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/api/health")
9
+ def health_check():
10
+ return {
11
+ "status": "ok",
12
+ "version": __version__,
13
+ }
@@ -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
+ )