agentcop 0.1.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.
- agentcop-0.1.0.dist-info/METADATA +210 -0
- agentcop-0.1.0.dist-info/RECORD +11 -0
- agentcop-0.1.0.dist-info/WHEEL +5 -0
- agentcop-0.1.0.dist-info/top_level.txt +1 -0
- sentinel_core/__init__.py +29 -0
- sentinel_core/adapters/__init__.py +3 -0
- sentinel_core/adapters/base.py +37 -0
- sentinel_core/event.py +57 -0
- sentinel_core/otel.py +143 -0
- sentinel_core/sentinel.py +80 -0
- sentinel_core/violations.py +82 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentcop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal forensic auditor for agent systems — OTel-aligned event schema, pluggable violation detectors
|
|
5
|
+
Author: Shay Mizuno, Hind Tagmouti
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/trusthandoff/agentcop
|
|
8
|
+
Project-URL: Repository, https://github.com/trusthandoff/agentcop
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
17
|
+
Provides-Extra: otel
|
|
18
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == "otel"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# sentinel-core
|
|
23
|
+
|
|
24
|
+
**The cop for agent fleets.**
|
|
25
|
+
|
|
26
|
+
Every agent fleet needs a cop. Agents delegate, handoff, and execute — and without forensic oversight, violations are invisible until they're incidents. `sentinel-core` is a universal auditor: ingest events from any agent system, run violation detectors, get structured findings.
|
|
27
|
+
|
|
28
|
+
OTel-aligned schema. Pluggable detectors. Adapter bridge to your stack. Zero required infrastructure.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
pip install sentinel-core
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
your agent system
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
SentinelAdapter ← translate domain events to universal schema
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
Sentinel.ingest() ← load SentinelEvents into the auditor
|
|
46
|
+
│
|
|
47
|
+
▼
|
|
48
|
+
detect_violations() ← run detectors, get ViolationRecords
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
report() / your sink ← stdout, OTel, alerting, whatever
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sentinel_core import Sentinel, SentinelEvent
|
|
60
|
+
|
|
61
|
+
sentinel = Sentinel()
|
|
62
|
+
|
|
63
|
+
# Feed it events (any source, any schema — adapt first)
|
|
64
|
+
sentinel.ingest([
|
|
65
|
+
SentinelEvent(
|
|
66
|
+
event_id="evt-001",
|
|
67
|
+
event_type="packet_rejected",
|
|
68
|
+
timestamp="2026-03-31T12:00:00Z",
|
|
69
|
+
severity="ERROR",
|
|
70
|
+
body="packet rejected — TTL expired",
|
|
71
|
+
source_system="my-agent",
|
|
72
|
+
attributes={"packet_id": "pkt-abc", "reason": "ttl_expired"},
|
|
73
|
+
)
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
violations = sentinel.detect_violations()
|
|
77
|
+
# [ViolationRecord(violation_type='rejected_packet', severity='ERROR', ...)]
|
|
78
|
+
|
|
79
|
+
sentinel.report()
|
|
80
|
+
# [ERROR] rejected_packet — packet rejected — TTL expired
|
|
81
|
+
# packet_id: pkt-abc
|
|
82
|
+
# reason: ttl_expired
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Built-in detectors fire on four event types out of the box:
|
|
86
|
+
|
|
87
|
+
| `event_type` | Detector | Severity |
|
|
88
|
+
|-------------------------|-------------------------------|----------|
|
|
89
|
+
| `packet_rejected` | `detect_rejected_packet` | ERROR |
|
|
90
|
+
| `capability_stale` | `detect_stale_capability` | ERROR |
|
|
91
|
+
| `token_overlap_used` | `detect_overlap_window` | WARN |
|
|
92
|
+
| `ai_generated_payload` | `detect_ai_generated_payload` | WARN |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Custom detectors
|
|
97
|
+
|
|
98
|
+
Detectors are plain functions. Register as many as you need.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from sentinel_core import Sentinel, SentinelEvent, ViolationRecord
|
|
102
|
+
from typing import Optional
|
|
103
|
+
|
|
104
|
+
def detect_unauthorized_tool(event: SentinelEvent) -> Optional[ViolationRecord]:
|
|
105
|
+
if event.event_type != "tool_call":
|
|
106
|
+
return None
|
|
107
|
+
if event.attributes.get("tool") in {"shell", "fs_write"}:
|
|
108
|
+
return ViolationRecord(
|
|
109
|
+
violation_type="unauthorized_tool",
|
|
110
|
+
severity="CRITICAL",
|
|
111
|
+
source_event_id=event.event_id,
|
|
112
|
+
trace_id=event.trace_id,
|
|
113
|
+
detail={"tool": event.attributes["tool"]},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
sentinel = Sentinel()
|
|
117
|
+
sentinel.register_detector(detect_unauthorized_tool)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## TrustHandoff adapter
|
|
123
|
+
|
|
124
|
+
[TrustHandoff](https://github.com/trusthandoff/trusthandoff) ships a first-class adapter. If you're using `trusthandoff` for cryptographic delegation, plug it in directly:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from trusthandoff.sentinel_adapter import TrustHandoffSentinelAdapter
|
|
128
|
+
from sentinel_core import Sentinel
|
|
129
|
+
|
|
130
|
+
adapter = TrustHandoffSentinelAdapter()
|
|
131
|
+
sentinel = Sentinel()
|
|
132
|
+
|
|
133
|
+
# raw_events: list of dicts from trusthandoff's forensic log
|
|
134
|
+
sentinel.ingest(adapter.to_sentinel_event(e) for e in raw_events)
|
|
135
|
+
|
|
136
|
+
violations = sentinel.detect_violations()
|
|
137
|
+
sentinel.report()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The adapter maps trusthandoff's event fields — `packet_id`, `correlation_id`, `reason`, `event_type` — to the universal `SentinelEvent` schema. Severity is inferred from event type. Everything else lands in `attributes`.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Write your own adapter
|
|
145
|
+
|
|
146
|
+
Implement the `SentinelAdapter` protocol to bridge any system:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from sentinel_core import SentinelAdapter, SentinelEvent
|
|
150
|
+
from typing import Dict, Any
|
|
151
|
+
|
|
152
|
+
class MySystemAdapter:
|
|
153
|
+
source_system = "my-system"
|
|
154
|
+
|
|
155
|
+
def to_sentinel_event(self, raw: Dict[str, Any]) -> SentinelEvent:
|
|
156
|
+
return SentinelEvent(
|
|
157
|
+
event_id=raw["id"],
|
|
158
|
+
event_type=raw["type"],
|
|
159
|
+
timestamp=raw["ts"],
|
|
160
|
+
severity=raw.get("level", "INFO"),
|
|
161
|
+
body=raw.get("message", ""),
|
|
162
|
+
source_system=self.source_system,
|
|
163
|
+
trace_id=raw.get("trace_id"),
|
|
164
|
+
attributes=raw.get("metadata", {}),
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## LangGraph integration *(coming soon)*
|
|
171
|
+
|
|
172
|
+
Native LangGraph adapter that hooks into graph execution events — node calls, edge transitions, tool invocations — and surfaces violations without any manual instrumentation.
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# coming soon
|
|
176
|
+
from sentinel_core.adapters.langgraph import LangGraphSentinelAdapter
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## OpenTelemetry export *(optional)*
|
|
182
|
+
|
|
183
|
+
`sentinel-core` events use an OTel-aligned schema out of the box (`trace_id`, `span_id`, severity levels). To export events as OTel log records:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
pip install sentinel-core[otel]
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from sentinel_core.otel import OtelSentinelExporter
|
|
191
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
192
|
+
|
|
193
|
+
exporter = OtelSentinelExporter(logger_provider=LoggerProvider())
|
|
194
|
+
exporter.export(events)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Attributes are emitted under the `sentinel.*` namespace. `trace_id` and `span_id` are mapped to OTel trace context.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Requirements
|
|
202
|
+
|
|
203
|
+
- Python 3.11+
|
|
204
|
+
- `pydantic>=2.7`
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sentinel_core/__init__.py,sha256=p2IbFHjCD3guHav_Mj0kzVxbFTXuMta0P6wBfMQlKw4,682
|
|
2
|
+
sentinel_core/event.py,sha256=-spz7zu6ahs1c8n-cuI_L-bXW-ttogS0RTAQkPOIcsY,1874
|
|
3
|
+
sentinel_core/otel.py,sha256=SfoWWa99CsgSqzAFFwObZgaabBZKk784rRo27sJoqcI,4740
|
|
4
|
+
sentinel_core/sentinel.py,sha256=i1um_G7d33OIXhCRljbbO3et4ihN1_1c_Y2C9BrzQuE,2702
|
|
5
|
+
sentinel_core/violations.py,sha256=L0H4A3-5kONZQ3zLXJ7Mw8rUaIslV_tinT09_AncOKg,2438
|
|
6
|
+
sentinel_core/adapters/__init__.py,sha256=XQzd5D2Jvf0eL1sAtapFJfCR47-CA5rl8AEkGlmg_C8,65
|
|
7
|
+
sentinel_core/adapters/base.py,sha256=sUbRid0p9sWb_au8UPYWM4WFk2vhtLVYBifOLfn0FT8,941
|
|
8
|
+
agentcop-0.1.0.dist-info/METADATA,sha256=qm3Bnv7VSOcpMFr6sP0eGmN9S9EjJpw-J0Iiy4mGwQI,6115
|
|
9
|
+
agentcop-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
agentcop-0.1.0.dist-info/top_level.txt,sha256=CgoprkX69GcboqQHPuoetUJznlJK47tJz7CaOsWdtzY,14
|
|
11
|
+
agentcop-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sentinel_core
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .event import SentinelEvent, ViolationRecord
|
|
2
|
+
from .sentinel import Sentinel, ViolationDetector
|
|
3
|
+
from .violations import (
|
|
4
|
+
DEFAULT_DETECTORS,
|
|
5
|
+
detect_ai_generated_payload,
|
|
6
|
+
detect_overlap_window,
|
|
7
|
+
detect_rejected_packet,
|
|
8
|
+
detect_stale_capability,
|
|
9
|
+
)
|
|
10
|
+
from .adapters import SentinelAdapter
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
# Core schema
|
|
16
|
+
"SentinelEvent",
|
|
17
|
+
"ViolationRecord",
|
|
18
|
+
# Auditor
|
|
19
|
+
"Sentinel",
|
|
20
|
+
"ViolationDetector",
|
|
21
|
+
# Built-in detectors
|
|
22
|
+
"DEFAULT_DETECTORS",
|
|
23
|
+
"detect_rejected_packet",
|
|
24
|
+
"detect_stale_capability",
|
|
25
|
+
"detect_overlap_window",
|
|
26
|
+
"detect_ai_generated_payload",
|
|
27
|
+
# Adapter protocol
|
|
28
|
+
"SentinelAdapter",
|
|
29
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SentinelAdapter protocol.
|
|
3
|
+
|
|
4
|
+
Implement this to bridge any system's raw event dicts into SentinelEvents.
|
|
5
|
+
The TrustHandoff adapter lives in trusthandoff.sentinel_adapter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from sentinel_core.event import SentinelEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class SentinelAdapter(Protocol):
|
|
15
|
+
"""
|
|
16
|
+
Protocol for system-specific event adapters.
|
|
17
|
+
|
|
18
|
+
Implementors translate raw event dicts (or any domain object) into
|
|
19
|
+
the universal SentinelEvent schema.
|
|
20
|
+
|
|
21
|
+
Example::
|
|
22
|
+
|
|
23
|
+
class MySystemAdapter:
|
|
24
|
+
source_system = "my-system"
|
|
25
|
+
|
|
26
|
+
def to_sentinel_event(self, raw: dict) -> SentinelEvent:
|
|
27
|
+
return SentinelEvent(
|
|
28
|
+
event_id=raw["id"],
|
|
29
|
+
event_type=raw["type"],
|
|
30
|
+
...
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
source_system: str
|
|
35
|
+
|
|
36
|
+
def to_sentinel_event(self, raw: Dict[str, Any]) -> SentinelEvent:
|
|
37
|
+
...
|
sentinel_core/event.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Dict, Literal, Optional
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SentinelEvent(BaseModel):
|
|
9
|
+
"""
|
|
10
|
+
Universal forensic event — OTel Log Data Model aligned.
|
|
11
|
+
|
|
12
|
+
OTel field mapping:
|
|
13
|
+
TraceId → trace_id
|
|
14
|
+
SpanId → span_id
|
|
15
|
+
Timestamp → timestamp
|
|
16
|
+
ObservedTimestamp → observed_at
|
|
17
|
+
SeverityText → severity
|
|
18
|
+
Body → body
|
|
19
|
+
Attributes → attributes
|
|
20
|
+
|
|
21
|
+
Domain-specific fields (packet_id, capability_id, etc.) live in
|
|
22
|
+
`attributes` — the same pattern OTel uses for instrumentation libraries.
|
|
23
|
+
`source_system` identifies the adapter that produced the event.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
event_id: str
|
|
27
|
+
event_type: str
|
|
28
|
+
timestamp: datetime
|
|
29
|
+
observed_at: datetime = Field(
|
|
30
|
+
default_factory=lambda: datetime.now(timezone.utc)
|
|
31
|
+
)
|
|
32
|
+
severity: Literal["INFO", "WARN", "ERROR", "CRITICAL"]
|
|
33
|
+
producer_id: Optional[str] = None
|
|
34
|
+
trace_id: Optional[str] = None # OTel TraceId / correlation_id
|
|
35
|
+
span_id: Optional[str] = None # OTel SpanId, optional
|
|
36
|
+
body: str
|
|
37
|
+
attributes: Dict[str, Any] = Field(default_factory=dict)
|
|
38
|
+
source_system: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ViolationRecord(BaseModel):
|
|
42
|
+
"""
|
|
43
|
+
Structured output of a violation detector.
|
|
44
|
+
|
|
45
|
+
`source_event_id` links back to the SentinelEvent that triggered detection.
|
|
46
|
+
`detail` carries violation-specific fields (reason, capability_id, model, …).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
violation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
50
|
+
violation_type: str
|
|
51
|
+
severity: Literal["WARN", "ERROR", "CRITICAL"]
|
|
52
|
+
detected_at: datetime = Field(
|
|
53
|
+
default_factory=lambda: datetime.now(timezone.utc)
|
|
54
|
+
)
|
|
55
|
+
source_event_id: str
|
|
56
|
+
trace_id: Optional[str] = None
|
|
57
|
+
detail: Dict[str, Any] = Field(default_factory=dict)
|
sentinel_core/otel.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry integration for sentinel-core.
|
|
3
|
+
|
|
4
|
+
Install the optional dependency to use this module:
|
|
5
|
+
|
|
6
|
+
pip install sentinel-core[otel]
|
|
7
|
+
|
|
8
|
+
This module provides:
|
|
9
|
+
- to_otel_log_record() — convert a SentinelEvent to an OTel LogRecord
|
|
10
|
+
- OtelSentinelExporter — emit SentinelEvents through a LoggerProvider
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
15
|
+
from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
|
|
16
|
+
from opentelemetry.sdk._logs.export.in_memory_span_exporter import InMemoryLogExporter
|
|
17
|
+
from sentinel_core.otel import OtelSentinelExporter
|
|
18
|
+
|
|
19
|
+
provider = LoggerProvider()
|
|
20
|
+
exporter = InMemoryLogExporter()
|
|
21
|
+
provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
|
|
22
|
+
|
|
23
|
+
sentinel_exporter = OtelSentinelExporter(logger_provider=provider)
|
|
24
|
+
sentinel_exporter.export(sentinel.detect_violations_as_events())
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from datetime import timezone
|
|
30
|
+
from typing import TYPE_CHECKING, List, Sequence
|
|
31
|
+
|
|
32
|
+
from .event import SentinelEvent
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
_SEVERITY_NUMBER_MAP = {
|
|
38
|
+
"INFO": 9, # OTel SeverityNumber.INFO
|
|
39
|
+
"WARN": 13, # OTel SeverityNumber.WARN
|
|
40
|
+
"ERROR": 17, # OTel SeverityNumber.ERROR
|
|
41
|
+
"CRITICAL": 21, # OTel SeverityNumber.FATAL
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_otel() -> None:
|
|
46
|
+
try:
|
|
47
|
+
import opentelemetry # noqa: F401
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"OpenTelemetry integration requires 'opentelemetry-sdk'. "
|
|
51
|
+
"Install it with: pip install sentinel-core[otel]"
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_otel_attributes(event: SentinelEvent) -> dict:
|
|
56
|
+
"""
|
|
57
|
+
Flatten a SentinelEvent into a flat OTel-compatible attribute dict.
|
|
58
|
+
|
|
59
|
+
All `attributes` keys are namespaced under `sentinel.*` to avoid
|
|
60
|
+
collisions with standard OTel resource/span attributes.
|
|
61
|
+
"""
|
|
62
|
+
attrs: dict = {
|
|
63
|
+
"sentinel.event_id": event.event_id,
|
|
64
|
+
"sentinel.event_type": event.event_type,
|
|
65
|
+
"sentinel.source_system": event.source_system,
|
|
66
|
+
}
|
|
67
|
+
if event.producer_id is not None:
|
|
68
|
+
attrs["sentinel.producer_id"] = event.producer_id
|
|
69
|
+
if event.trace_id is not None:
|
|
70
|
+
attrs["sentinel.trace_id"] = event.trace_id
|
|
71
|
+
if event.span_id is not None:
|
|
72
|
+
attrs["sentinel.span_id"] = event.span_id
|
|
73
|
+
for k, v in event.attributes.items():
|
|
74
|
+
if v is not None:
|
|
75
|
+
attrs[f"sentinel.{k}"] = str(v)
|
|
76
|
+
return attrs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def to_otel_log_record(event: SentinelEvent):
|
|
80
|
+
"""
|
|
81
|
+
Convert a SentinelEvent to an opentelemetry.sdk._logs.LogRecord.
|
|
82
|
+
|
|
83
|
+
Requires ``opentelemetry-sdk`` to be installed.
|
|
84
|
+
"""
|
|
85
|
+
_require_otel()
|
|
86
|
+
from opentelemetry.sdk._logs import LogRecord # type: ignore[import]
|
|
87
|
+
from opentelemetry._logs.severity import SeverityNumber # type: ignore[import]
|
|
88
|
+
|
|
89
|
+
severity_number_value = _SEVERITY_NUMBER_MAP.get(event.severity, 9)
|
|
90
|
+
severity_number = SeverityNumber(severity_number_value)
|
|
91
|
+
|
|
92
|
+
timestamp_ns = int(
|
|
93
|
+
event.timestamp.astimezone(timezone.utc).timestamp() * 1_000_000_000
|
|
94
|
+
)
|
|
95
|
+
observed_ns = int(
|
|
96
|
+
event.observed_at.astimezone(timezone.utc).timestamp() * 1_000_000_000
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return LogRecord(
|
|
100
|
+
timestamp=timestamp_ns,
|
|
101
|
+
observed_timestamp=observed_ns,
|
|
102
|
+
severity_text=event.severity,
|
|
103
|
+
severity_number=severity_number,
|
|
104
|
+
body=event.body,
|
|
105
|
+
attributes=to_otel_attributes(event),
|
|
106
|
+
trace_id=int(event.trace_id, 16) if event.trace_id and len(event.trace_id) == 32 else 0,
|
|
107
|
+
span_id=int(event.span_id, 16) if event.span_id and len(event.span_id) == 16 else 0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class OtelSentinelExporter:
|
|
112
|
+
"""
|
|
113
|
+
Emit SentinelEvents as OTel log records through a LoggerProvider.
|
|
114
|
+
|
|
115
|
+
Requires ``opentelemetry-sdk`` to be installed.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
logger_provider:
|
|
120
|
+
An ``opentelemetry.sdk._logs.LoggerProvider`` instance.
|
|
121
|
+
If None, uses the global provider.
|
|
122
|
+
instrumentation_name:
|
|
123
|
+
Logger name used when acquiring the OTel logger (appears as
|
|
124
|
+
``otel.scope.name`` in most exporters).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
logger_provider=None,
|
|
130
|
+
instrumentation_name: str = "sentinel-core",
|
|
131
|
+
):
|
|
132
|
+
_require_otel()
|
|
133
|
+
from opentelemetry.sdk._logs import LoggerProvider as _LP # type: ignore[import]
|
|
134
|
+
from opentelemetry._logs import get_logger_provider # type: ignore[import]
|
|
135
|
+
|
|
136
|
+
self._provider = logger_provider or get_logger_provider()
|
|
137
|
+
self._logger = self._provider.get_logger(instrumentation_name)
|
|
138
|
+
|
|
139
|
+
def export(self, events: Sequence[SentinelEvent]) -> None:
|
|
140
|
+
"""Emit each event as an OTel log record."""
|
|
141
|
+
for event in events:
|
|
142
|
+
record = to_otel_log_record(event)
|
|
143
|
+
self._logger.emit(record)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Callable, Iterable, List, Optional
|
|
3
|
+
|
|
4
|
+
from .event import SentinelEvent, ViolationRecord
|
|
5
|
+
from .violations import DEFAULT_DETECTORS
|
|
6
|
+
|
|
7
|
+
ViolationDetector = Callable[[SentinelEvent], Optional[ViolationRecord]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Sentinel:
|
|
11
|
+
"""
|
|
12
|
+
Universal forensic auditor.
|
|
13
|
+
|
|
14
|
+
Ingests SentinelEvents, runs violation detectors, returns typed ViolationRecords.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
sentinel = Sentinel()
|
|
19
|
+
sentinel.ingest(adapter.to_sentinel_event(e) for e in raw_events)
|
|
20
|
+
violations = sentinel.detect_violations()
|
|
21
|
+
|
|
22
|
+
Custom detectors::
|
|
23
|
+
|
|
24
|
+
def my_detector(event: SentinelEvent) -> ViolationRecord | None:
|
|
25
|
+
if event.event_type == "custom_alert":
|
|
26
|
+
return ViolationRecord(
|
|
27
|
+
violation_type="custom_alert",
|
|
28
|
+
severity="WARN",
|
|
29
|
+
source_event_id=event.event_id,
|
|
30
|
+
trace_id=event.trace_id,
|
|
31
|
+
detail={"msg": event.body},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
sentinel = Sentinel()
|
|
35
|
+
sentinel.register_detector(my_detector)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, detectors: Optional[List[ViolationDetector]] = None):
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._events: List[SentinelEvent] = []
|
|
41
|
+
self._detectors: List[ViolationDetector] = (
|
|
42
|
+
list(detectors) if detectors is not None else list(DEFAULT_DETECTORS)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def register_detector(self, fn: ViolationDetector) -> None:
|
|
46
|
+
"""Append a custom detector. Runs after all built-in detectors."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._detectors.append(fn)
|
|
49
|
+
|
|
50
|
+
def ingest(self, events: Iterable[SentinelEvent]) -> None:
|
|
51
|
+
"""Replace the internal event buffer with the provided events."""
|
|
52
|
+
ingested = list(events)
|
|
53
|
+
with self._lock:
|
|
54
|
+
self._events = ingested
|
|
55
|
+
|
|
56
|
+
def detect_violations(self) -> List[ViolationRecord]:
|
|
57
|
+
with self._lock:
|
|
58
|
+
events = list(self._events)
|
|
59
|
+
detectors = list(self._detectors)
|
|
60
|
+
|
|
61
|
+
violations: List[ViolationRecord] = []
|
|
62
|
+
for event in events:
|
|
63
|
+
for detector in detectors:
|
|
64
|
+
result = detector(event)
|
|
65
|
+
if result is not None:
|
|
66
|
+
violations.append(result)
|
|
67
|
+
return violations
|
|
68
|
+
|
|
69
|
+
def report(self) -> None:
|
|
70
|
+
violations = self.detect_violations()
|
|
71
|
+
if not violations:
|
|
72
|
+
print("No violations detected")
|
|
73
|
+
return
|
|
74
|
+
print("=== SENTINEL REPORT ===")
|
|
75
|
+
for v in violations:
|
|
76
|
+
print(
|
|
77
|
+
f"[{v.severity}] {v.violation_type}"
|
|
78
|
+
+ (f" trace={v.trace_id}" if v.trace_id else "")
|
|
79
|
+
+ (f" {v.detail}" if v.detail else "")
|
|
80
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in violation detectors for sentinel-core.
|
|
3
|
+
|
|
4
|
+
Each detector is a plain function:
|
|
5
|
+
(SentinelEvent) -> ViolationRecord | None
|
|
6
|
+
|
|
7
|
+
Return a ViolationRecord if the event represents a violation, None otherwise.
|
|
8
|
+
Register custom detectors via Sentinel.register_detector().
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .event import SentinelEvent, ViolationRecord
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def detect_rejected_packet(event: SentinelEvent) -> Optional[ViolationRecord]:
|
|
17
|
+
if event.event_type != "packet_rejected":
|
|
18
|
+
return None
|
|
19
|
+
return ViolationRecord(
|
|
20
|
+
violation_type="rejected_packet",
|
|
21
|
+
severity="ERROR",
|
|
22
|
+
source_event_id=event.event_id,
|
|
23
|
+
trace_id=event.trace_id,
|
|
24
|
+
detail={
|
|
25
|
+
"packet_id": event.attributes.get("packet_id"),
|
|
26
|
+
"reason": event.attributes.get("reason"),
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_stale_capability(event: SentinelEvent) -> Optional[ViolationRecord]:
|
|
32
|
+
if event.event_type != "capability_stale":
|
|
33
|
+
return None
|
|
34
|
+
return ViolationRecord(
|
|
35
|
+
violation_type="stale_capability",
|
|
36
|
+
severity="ERROR",
|
|
37
|
+
source_event_id=event.event_id,
|
|
38
|
+
trace_id=event.trace_id,
|
|
39
|
+
detail={
|
|
40
|
+
"capability_id": event.attributes.get("capability_id"),
|
|
41
|
+
"reason": event.attributes.get("reason"),
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_overlap_window(event: SentinelEvent) -> Optional[ViolationRecord]:
|
|
47
|
+
if event.event_type != "token_overlap_used":
|
|
48
|
+
return None
|
|
49
|
+
return ViolationRecord(
|
|
50
|
+
violation_type="overlap_window_used",
|
|
51
|
+
severity="WARN",
|
|
52
|
+
source_event_id=event.event_id,
|
|
53
|
+
trace_id=event.trace_id,
|
|
54
|
+
detail={
|
|
55
|
+
"packet_id": event.attributes.get("packet_id"),
|
|
56
|
+
"reason": event.attributes.get("reason"),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_ai_generated_payload(event: SentinelEvent) -> Optional[ViolationRecord]:
|
|
62
|
+
if event.event_type != "ai_generated_payload":
|
|
63
|
+
return None
|
|
64
|
+
return ViolationRecord(
|
|
65
|
+
violation_type="ai_generated_payload",
|
|
66
|
+
severity="WARN",
|
|
67
|
+
source_event_id=event.event_id,
|
|
68
|
+
trace_id=event.trace_id,
|
|
69
|
+
detail={
|
|
70
|
+
"packet_id": event.attributes.get("packet_id"),
|
|
71
|
+
"source": event.attributes.get("source"),
|
|
72
|
+
"model": event.attributes.get("model"),
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
DEFAULT_DETECTORS = [
|
|
78
|
+
detect_rejected_packet,
|
|
79
|
+
detect_stale_capability,
|
|
80
|
+
detect_overlap_window,
|
|
81
|
+
detect_ai_generated_payload,
|
|
82
|
+
]
|