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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions integrations/sentinel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
report.json
4 changes: 2 additions & 2 deletions sentinel/Dockerfile → integrations/sentinel/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ ./src/
COPY sentinel/ ./sentinel/
COPY sample_trace.json .

ENV PYTHONPATH=/app
EXPOSE 8001

CMD ["uvicorn", "src.server:app", "--host", "0.0.0.0", "--port", "8001"]
CMD ["uvicorn", "sentinel.main:app", "--host", "0.0.0.0", "--port", "8001"]
62 changes: 62 additions & 0 deletions integrations/sentinel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Agent Sentinel

Runtime behavioral anomaly detection, collusion detection, and quarantine for
agent fleets. Sentinel scores incoming agent traces, decides whether to admit,
review, or deny an action, and emits an Ed25519-signed TRACE v0.1 record for
every enforcement.

## Features

- Detectors: delegation escalation, tool drift, policy avoidance, identity
drift, and collusion.
- Risk aggregation with a quarantine threshold (0.7).
- Fail-closed enforcement: a detector error forces a DENY rather than an admit.
- Signed evidence: every enforcement emits an Ed25519-signed TRACE Level 0
record, offline-verifiable against the embedded `cnf.jwk`.
- CLI plus a FastAPI service.

## Signing key (required)

Sentinel signs every TRACE claim and refuses to emit unsigned records. Provide a
PEM-encoded Ed25519 private key in `TRACE_PRIVATE_KEY_PEM`. Without it, claim
generation and `/enforce` fail closed.

```bash
export TRACE_PRIVATE_KEY_PEM="$(cat sentinel-ed25519.pem)"
```

## Install and run

```bash
pip install -r requirements.txt
uvicorn sentinel.main:app --host 0.0.0.0 --port 8001 --reload
# open http://localhost:8001
```

The CLI reads a TRACE claim and writes a risk report:

```bash
python -m sentinel.cli claim.jwt --output report.json
```

## Tests

Run from this directory so the `sentinel` package is importable:

```bash
pip install -r requirements-dev.txt
python -m pytest tests -q
```

`tests/test_trace_conformance.py` runs the `agentrust-trace-tests` Level 0 suite
against the generated records.

## Integration with agentrust-io

Sentinel consumes agent traces and produces signed TRACE records that AGT, cMCP,
and other agentrust-io components can verify. It targets TRACE conformance
Level 0 (software-only; no hardware TEE attestation).

## License

MIT
15 changes: 15 additions & 0 deletions integrations/sentinel/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: '3.8'
services:
sentinel:
build: .
ports:
- "8001:8001"
environment:
- QUARANTINE_THRESHOLD=0.7
# Required. Sentinel signs every TRACE claim and will not emit unsigned
# records. Supply a PEM-encoded Ed25519 private key (for example from a
# secret store); enforcement fails closed if this is unset.
- TRACE_PRIVATE_KEY_PEM=${TRACE_PRIVATE_KEY_PEM:?set an Ed25519 private key PEM}
volumes:
- ./data:/app/data
restart: unless-stopped
12 changes: 12 additions & 0 deletions integrations/sentinel/integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Agent Sentinel
vendor: a1k7
integrates_with:
- trace
description: "Runtime enforcement sidecar for agent fleets: detectors for delegation escalation, tool and identity drift, and policy avoidance, emitting Ed25519-signed TRACE Level 0 records."
maintainer:
github: a1k7
email: warik.akhilesh@gmail.com
repository: https://github.com/agentrust-io/integrations/tree/main/integrations/sentinel
license: MIT
tier: community
trace_conformance_level: 0
3 changes: 3 additions & 0 deletions integrations/sentinel/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
agentrust-trace-tests>=0.2.0
jsonschema>=4.0.0
13 changes: 13 additions & 0 deletions integrations/sentinel/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
fastapi>=0.115.0
uvicorn[standard]>=0.34.0
pydantic>=2.10.0
pytest>=8.0.0
httpx>=0.27.0
python-dotenv>=1.0.0
click>=8.0.0
jinja2>=3.1.0
pandas>=2.0.0
numpy>=1.24.0
scikit-learn>=1.3.0
cryptography>=42.0.0
PyJWT>=2.8.0
7 changes: 7 additions & 0 deletions integrations/sentinel/sample_trace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"trace_id": "test-001",
"delegation_chain": ["root", "admin", "finance"],
"policy_version": "v1",
"agent_id": "alice",
"action": "write"
}
11 changes: 11 additions & 0 deletions integrations/sentinel/sentinel/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from .models import SentinelInput, DetectionResult
from .detectors.delegation_escalation import DelegationEscalationDetector
from .trace_claim_generator import TraceClaimGenerator, generate_trace_claim

__all__ = [
"SentinelInput",
"DetectionResult",
"DelegationEscalationDetector",
"TraceClaimGenerator",
"generate_trace_claim",
]
44 changes: 44 additions & 0 deletions integrations/sentinel/sentinel/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import click
import json
from datetime import datetime
from sentinel.risk_engine import RiskEngine
from sentinel.models import SentinelInput


class DateTimeEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)


@click.command()
@click.argument('trace_path', type=click.Path(exists=True))
@click.option('--output', '-o', type=click.Path(), help='Output JSON file for report')
def main(trace_path, output):
with open(trace_path, 'r') as f:
trace_data = json.load(f)

engine = RiskEngine()

input_data = SentinelInput(
trace_id=trace_data.get('trace_id', 'unknown'),
delegation_chain=trace_data.get('delegation_chain', []),
policy_version=trace_data.get('policy_version', 'v1'),
agent_id=trace_data.get('agent_id', 'unknown'),
action=trace_data.get('action', 'unknown')
)

result = engine.evaluate(input_data)
report = result.model_dump(mode='json')

if output:
with open(output, 'w') as f:
json.dump(report, f, indent=2, cls=DateTimeEncoder)
click.echo(f"Report written to {output}")
else:
click.echo(json.dumps(report, indent=2, cls=DateTimeEncoder))


if __name__ == "__main__":
main()
8 changes: 8 additions & 0 deletions integrations/sentinel/sentinel/detectors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from .delegation_escalation import DelegationEscalationDetector
from .base import BaseDetector

# Only expose the working detector
__all__ = [
"DelegationEscalationDetector",
"BaseDetector",
]
57 changes: 57 additions & 0 deletions integrations/sentinel/sentinel/detectors/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from abc import ABC, abstractmethod
from datetime import datetime
from sentinel.models import SentinelInput, DetectionResult, RiskLevel, Action
from typing import Optional, Dict, Any

class BaseDetector(ABC):
"""Base class for all anomaly detectors."""

@abstractmethod
def detect(self, input_data: SentinelInput) -> DetectionResult:
"""
Run detection logic and return a DetectionResult.
Subclasses must implement this method.
"""
pass

@abstractmethod
def name(self) -> str:
"""Return the detector's name."""
pass

def _create_result(
self,
detection_type: str,
risk_score: float,
reason: str,
action: Action = Action.MONITOR,
risk_level: RiskLevel = None,
evidence: Optional[Dict[str, Any]] = None,
detected: bool = False
) -> DetectionResult:
"""
Helper method to create a consistent DetectionResult with action field.
"""
if risk_level is None:
risk_level = self._risk_level_from_score(risk_score)

return DetectionResult(
detection_type=detection_type,
risk_score=risk_score,
risk_level=risk_level,
reason=reason,
action=action,
timestamp=datetime.now().isoformat(),
evidence=evidence or {},
detected=detected
)

def _risk_level_from_score(self, score: float) -> RiskLevel:
"""Convert numeric risk score to RiskLevel enum."""
if score < 0.3:
return RiskLevel.LOW
if score < 0.6:
return RiskLevel.MEDIUM
if score < 0.8:
return RiskLevel.HIGH
return RiskLevel.CRITICAL
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel, CollusionPattern
from src.detectors.base import BaseDetector
from sentinel.models import SentinelInput, DetectionResult, DetectionType, RiskLevel, CollusionPattern
from sentinel.detectors.base import BaseDetector
from typing import List
class CollusionDetector(BaseDetector):
"""
Expand Down Expand Up @@ -64,4 +64,8 @@ def _risk_level(self, score: float) -> RiskLevel:
if score < 0.3: return RiskLevel.LOW
if score < 0.6: return RiskLevel.MEDIUM
if score < 0.8: return RiskLevel.HIGH
return RiskLevel.CRITICAL
return RiskLevel.CRITICAL
if __name__ == "__main__":
# Quick test
from ..models import DetectionResult, RiskLevel
print("CollusionDetector loaded successfully.")
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Optional
from sentinel.models import SentinelInput, DetectionResult, Action, RiskLevel
from sentinel.detectors.base import BaseDetector


class DelegationEscalationDetector(BaseDetector):
def __init__(self, max_depth: int = 2, risk_threshold: float = 0.8):
self.max_depth = max_depth
self.risk_threshold = risk_threshold

def name(self) -> str:
return "delegation_escalation"

def detect(self, input_data: SentinelInput) -> DetectionResult:
depth = len(input_data.delegation_chain)
risk = 0.0
reason = "Delegation chain within limits"

if depth > self.max_depth:
risk = min(1.0, 0.5 + 0.2 * (depth - self.max_depth))
reason = f"Delegation depth {depth} exceeds threshold {self.max_depth}"
elif depth == 2 and set(input_data.delegation_chain) == {"root", "admin"}:
risk = 0.7
reason = "Moderate risk delegation pattern (root + admin)"
elif depth == 1 and "root" in input_data.delegation_chain:
risk = 0.3
reason = "Low risk delegation (root only)"
elif depth == 0:
risk = 0.0
reason = "No delegation chain"

# Compute detected flag
detected = risk >= self.risk_threshold

action = Action.QUARANTINE if risk > 0.7 else Action.ESCALATE if risk > 0.4 else Action.MONITOR
risk_level = self._risk_level_from_score(risk)

return self._create_result(
detection_type="delegation_escalation",
risk_score=risk,
reason=reason,
action=action,
risk_level=risk_level,
evidence={"delegation_chain": input_data.delegation_chain},
detected=detected, # <-- now defined
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from src.detectors.base import BaseDetector
from sentinel.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from sentinel.detectors.base import BaseDetector

class IdentityDriftDetector(BaseDetector):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from src.detectors.base import BaseDetector
from sentinel.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from sentinel.detectors.base import BaseDetector

class PolicyAvoidanceDetector(BaseDetector):
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from src.detectors.base import BaseDetector
from sentinel.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
from sentinel.detectors.base import BaseDetector

class ToolDriftDetector(BaseDetector):
"""
Expand Down
Loading