diff --git a/integrations/sentinel/.gitignore b/integrations/sentinel/.gitignore
new file mode 100644
index 0000000..1e28a4a
--- /dev/null
+++ b/integrations/sentinel/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+report.json
diff --git a/sentinel/Dockerfile b/integrations/sentinel/Dockerfile
similarity index 62%
rename from sentinel/Dockerfile
rename to integrations/sentinel/Dockerfile
index 432af7a..ed448f1 100644
--- a/sentinel/Dockerfile
+++ b/integrations/sentinel/Dockerfile
@@ -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"]
\ No newline at end of file
+CMD ["uvicorn", "sentinel.main:app", "--host", "0.0.0.0", "--port", "8001"]
diff --git a/integrations/sentinel/README.md b/integrations/sentinel/README.md
new file mode 100644
index 0000000..c8173ea
--- /dev/null
+++ b/integrations/sentinel/README.md
@@ -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
diff --git a/integrations/sentinel/docker-compose.yml b/integrations/sentinel/docker-compose.yml
new file mode 100644
index 0000000..8267b99
--- /dev/null
+++ b/integrations/sentinel/docker-compose.yml
@@ -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
diff --git a/integrations/sentinel/integration.yaml b/integrations/sentinel/integration.yaml
new file mode 100644
index 0000000..6f08554
--- /dev/null
+++ b/integrations/sentinel/integration.yaml
@@ -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
diff --git a/integrations/sentinel/requirements-dev.txt b/integrations/sentinel/requirements-dev.txt
new file mode 100644
index 0000000..efcb6f5
--- /dev/null
+++ b/integrations/sentinel/requirements-dev.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+agentrust-trace-tests>=0.2.0
+jsonschema>=4.0.0
diff --git a/integrations/sentinel/requirements.txt b/integrations/sentinel/requirements.txt
new file mode 100644
index 0000000..52c433b
--- /dev/null
+++ b/integrations/sentinel/requirements.txt
@@ -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
\ No newline at end of file
diff --git a/integrations/sentinel/sample_trace.json b/integrations/sentinel/sample_trace.json
new file mode 100644
index 0000000..9cbf4ae
--- /dev/null
+++ b/integrations/sentinel/sample_trace.json
@@ -0,0 +1,7 @@
+{
+ "trace_id": "test-001",
+ "delegation_chain": ["root", "admin", "finance"],
+ "policy_version": "v1",
+ "agent_id": "alice",
+ "action": "write"
+}
\ No newline at end of file
diff --git a/integrations/sentinel/sentinel/__init__.py b/integrations/sentinel/sentinel/__init__.py
new file mode 100644
index 0000000..7f07d47
--- /dev/null
+++ b/integrations/sentinel/sentinel/__init__.py
@@ -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",
+]
\ No newline at end of file
diff --git a/integrations/sentinel/sentinel/cli.py b/integrations/sentinel/sentinel/cli.py
new file mode 100644
index 0000000..e9e86d4
--- /dev/null
+++ b/integrations/sentinel/sentinel/cli.py
@@ -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()
\ No newline at end of file
diff --git a/integrations/sentinel/sentinel/detectors/__init__.py b/integrations/sentinel/sentinel/detectors/__init__.py
new file mode 100644
index 0000000..f76ad75
--- /dev/null
+++ b/integrations/sentinel/sentinel/detectors/__init__.py
@@ -0,0 +1,8 @@
+from .delegation_escalation import DelegationEscalationDetector
+from .base import BaseDetector
+
+# Only expose the working detector
+__all__ = [
+ "DelegationEscalationDetector",
+ "BaseDetector",
+]
\ No newline at end of file
diff --git a/integrations/sentinel/sentinel/detectors/base.py b/integrations/sentinel/sentinel/detectors/base.py
new file mode 100644
index 0000000..7ac7031
--- /dev/null
+++ b/integrations/sentinel/sentinel/detectors/base.py
@@ -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
\ No newline at end of file
diff --git a/sentinel/src/detectors/collusion_detector.py b/integrations/sentinel/sentinel/detectors/collusion_detector.py
similarity index 89%
rename from sentinel/src/detectors/collusion_detector.py
rename to integrations/sentinel/sentinel/detectors/collusion_detector.py
index 46f72a4..572630a 100644
--- a/sentinel/src/detectors/collusion_detector.py
+++ b/integrations/sentinel/sentinel/detectors/collusion_detector.py
@@ -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):
"""
@@ -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
\ No newline at end of file
+ return RiskLevel.CRITICAL
+if __name__ == "__main__":
+ # Quick test
+ from ..models import DetectionResult, RiskLevel
+ print("CollusionDetector loaded successfully.")
\ No newline at end of file
diff --git a/integrations/sentinel/sentinel/detectors/delegation_escalation.py b/integrations/sentinel/sentinel/detectors/delegation_escalation.py
new file mode 100644
index 0000000..f84631d
--- /dev/null
+++ b/integrations/sentinel/sentinel/detectors/delegation_escalation.py
@@ -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
+ )
\ No newline at end of file
diff --git a/sentinel/src/detectors/identity_drift.py b/integrations/sentinel/sentinel/detectors/identity_drift.py
similarity index 87%
rename from sentinel/src/detectors/identity_drift.py
rename to integrations/sentinel/sentinel/detectors/identity_drift.py
index 8bbf39a..b06e84b 100644
--- a/sentinel/src/detectors/identity_drift.py
+++ b/integrations/sentinel/sentinel/detectors/identity_drift.py
@@ -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):
"""
diff --git a/sentinel/src/detectors/policy_avoidance.py b/integrations/sentinel/sentinel/detectors/policy_avoidance.py
similarity index 89%
rename from sentinel/src/detectors/policy_avoidance.py
rename to integrations/sentinel/sentinel/detectors/policy_avoidance.py
index 93d236e..284231d 100644
--- a/sentinel/src/detectors/policy_avoidance.py
+++ b/integrations/sentinel/sentinel/detectors/policy_avoidance.py
@@ -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):
"""
diff --git a/sentinel/src/detectors/tool_drift.py b/integrations/sentinel/sentinel/detectors/tool_drift.py
similarity index 90%
rename from sentinel/src/detectors/tool_drift.py
rename to integrations/sentinel/sentinel/detectors/tool_drift.py
index 2396a19..ad929d3 100644
--- a/sentinel/src/detectors/tool_drift.py
+++ b/integrations/sentinel/sentinel/detectors/tool_drift.py
@@ -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):
"""
diff --git a/integrations/sentinel/sentinel/main.py b/integrations/sentinel/sentinel/main.py
new file mode 100644
index 0000000..ca5af7c
--- /dev/null
+++ b/integrations/sentinel/sentinel/main.py
@@ -0,0 +1,43 @@
+from fastapi import FastAPI
+
+from sentinel.detectors.delegation_escalation import DelegationEscalationDetector
+from sentinel.models import DetectionResult, SentinelInput
+from sentinel.trace_claim_generator import TraceClaimGenerator
+
+app = FastAPI(title="Agent Sentinel", version="1.0.0")
+detector = DelegationEscalationDetector()
+claim_gen = TraceClaimGenerator()
+
+
+@app.get("/health")
+async def health_check():
+ return {"status": "ok", "service": "sentinel", "version": "1.0.0"}
+
+
+@app.post("/detect", response_model=DetectionResult)
+async def detect(input_data: SentinelInput):
+ return detector.detect(input_data)
+
+
+@app.post("/enforce")
+async def enforce(input_data: SentinelInput):
+ detection = detector.detect(input_data)
+ if detection.detected:
+ # Signing is mandatory: if no key is configured this raises and the
+ # request fails closed rather than returning an unsigned decision.
+ claim = claim_gen.generate_claim(
+ {
+ "event_id": f"enforce-{input_data.trace_id}",
+ "event_type": "ENFORCEMENT",
+ "detection": detection.model_dump(),
+ "input": input_data.model_dump(),
+ },
+ agent_id=input_data.agent_id,
+ decision="DENY",
+ )
+ return {
+ "status": "DENY",
+ "reason": detection.reason or "Detection triggered enforcement.",
+ "claim": claim.to_json(),
+ }
+ return {"status": "ADMIT", "reason": "No violation detected.", "claim": None}
diff --git a/sentinel/src/models.py b/integrations/sentinel/sentinel/models.py
similarity index 96%
rename from sentinel/src/models.py
rename to integrations/sentinel/sentinel/models.py
index e9c5582..0503914 100644
--- a/sentinel/src/models.py
+++ b/integrations/sentinel/sentinel/models.py
@@ -95,9 +95,10 @@ class DetectionResult(BaseModel):
risk_score: float
risk_level: RiskLevel
reason: str
- evidence: Dict[str, Any]
+ evidence: Optional[Dict[str, Any]] = None
timestamp: datetime = datetime.now()
- action: Action = Action.MONITOR
+ action: Optional[Action] = None
+ detected: bool = False
class CollusionPattern(BaseModel):
pattern_type: str
@@ -166,6 +167,5 @@ class IncidentReport(BaseModel):
evidence_export: Dict[str, Any]
receipt: Optional[Receipt] = None
signature: Optional[str] = None
- signature_status: str = "unsigned" # "signed" or "unsigned" (fail closed)
claim_hash: Optional[str] = None
incident_hash: Optional[str] = None
\ No newline at end of file
diff --git a/sentinel/src/quarantine.py b/integrations/sentinel/sentinel/quarantine.py
similarity index 95%
rename from sentinel/src/quarantine.py
rename to integrations/sentinel/sentinel/quarantine.py
index bc1707b..01369a3 100644
--- a/sentinel/src/quarantine.py
+++ b/integrations/sentinel/sentinel/quarantine.py
@@ -1,4 +1,4 @@
-from src.models import QuarantineAction, SentinelOutput
+from sentinel.models import QuarantineAction, SentinelOutput
def generate_quarantine(output: SentinelOutput, agent_id: str) -> SentinelOutput:
"""If risk exceeds threshold, generate a quarantine action."""
diff --git a/sentinel/src/replay_engine.py b/integrations/sentinel/sentinel/replay_engine.py
similarity index 95%
rename from sentinel/src/replay_engine.py
rename to integrations/sentinel/sentinel/replay_engine.py
index 6b08d5e..35a968e 100644
--- a/sentinel/src/replay_engine.py
+++ b/integrations/sentinel/sentinel/replay_engine.py
@@ -1,7 +1,7 @@
import copy
from typing import List
-from src.models import SentinelInput, ReplayResult
-from src.risk_engine import RiskEngine
+from sentinel.models import SentinelInput, ReplayResult
+from sentinel.risk_engine import RiskEngine
class ReplayEngine:
def __init__(self):
diff --git a/integrations/sentinel/sentinel/risk_engine.py b/integrations/sentinel/sentinel/risk_engine.py
new file mode 100644
index 0000000..d8b494b
--- /dev/null
+++ b/integrations/sentinel/sentinel/risk_engine.py
@@ -0,0 +1,239 @@
+"""
+Risk Engine for Agent Sentinel โ evaluates traces and produces governance decisions.
+"""
+from typing import List, Optional, Dict, Any, Set
+from datetime import datetime
+import json
+from sentinel.models import (
+ SentinelInput,
+ SentinelOutput,
+ DetectionResult,
+ RiskLevel,
+ Action,
+ TimelineEvent,
+ GraphNode,
+ GraphEdge,
+ QuarantineRecord,
+ CollusionPattern
+)
+from sentinel.detectors import DelegationEscalationDetector
+from sentinel.quarantine import generate_quarantine
+from sentinel.trace_claim_generator import generate_trace_claim
+
+
+# In-memory stores
+quarantine_store: Dict[str, QuarantineRecord] = {}
+enforcement_logs: Dict[str, Dict[str, Any]] = {}
+
+
+class RiskEngine:
+ def __init__(self):
+ self.detectors = [DelegationEscalationDetector()]
+
+ # ===== CLI ENTRY POINT =====
+ def analyze(self, trace: dict) -> SentinelOutput:
+ input_data = SentinelInput(
+ trace_id=trace.get('trace_id', 'unknown'),
+ delegation_chain=trace.get('delegation_chain', []),
+ policy_version=trace.get('policy_version', 'v1'),
+ agent_id=trace.get('agent_id', 'unknown'),
+ action=trace.get('action', 'unknown')
+ )
+ return self.evaluate(input_data)
+
+ # ===== CORE EVALUATION =====
+ def evaluate(self, input_data: SentinelInput) -> SentinelOutput:
+ detections: List[DetectionResult] = []
+ total_risk = 0.0
+ timeline: List[TimelineEvent] = []
+ trace_claims: List[Dict[str, Any]] = [] # store as dict, not TraceClaim
+ quarantine_recommended = False
+ quarantine_action = None
+ collusion_patterns: List[CollusionPattern] = []
+ graph_nodes: List[GraphNode] = []
+ graph_edges: List[GraphEdge] = []
+ decision = "ADMIT"
+ reason = None
+ detector_errors: List[str] = []
+
+ for detector in self.detectors:
+ try:
+ result = detector.detect(input_data)
+
+ if result.risk_score > 0.7:
+ result.action = Action.QUARANTINE
+ quarantine_recommended = True
+ elif result.risk_score > 0.4:
+ result.action = Action.ESCALATE
+ else:
+ result.action = Action.MONITOR
+
+ detections.append(result)
+ total_risk += result.risk_score
+
+ timeline.append(TimelineEvent(
+ timestamp=datetime.now().isoformat(),
+ agent_id=input_data.agent_id,
+ event_type=result.detection_type or "detection",
+ description=result.reason or "Detection triggered",
+ severity=result.risk_level.value if result.risk_level else str(result.risk_score)
+ ))
+
+ if result.risk_score > 0.6:
+ claim_str = generate_trace_claim({
+ "agent_id": input_data.agent_id,
+ "trace_id": input_data.trace_id,
+ "detection": result.model_dump() if hasattr(result, 'model_dump') else {}
+ })
+ trace_claims.append(json.loads(claim_str))
+
+ except Exception as e:
+ # Fail closed: a detector that crashes must not be silently
+ # dropped. Record it and force a DENY decision below so a broken
+ # detector cannot lower the aggregate risk into an ADMIT.
+ detector_name = getattr(detector, "name", lambda: type(detector).__name__)
+ name = detector_name() if callable(detector_name) else str(detector_name)
+ detector_errors.append(f"{name}: {e}")
+ continue
+
+ avg_risk = total_risk / len(self.detectors) if self.detectors else 0.0
+
+ if avg_risk > 0.7:
+ decision = "DENY"
+ reason = f"Risk score {avg_risk:.2f} exceeds threshold"
+ elif any(d.risk_score > 0.8 for d in detections):
+ decision = "DENY"
+ high_risk = max(detections, key=lambda d: d.risk_score)
+ reason = f"Critical detection: {high_risk.reason or 'high risk detected'}"
+ elif avg_risk > 0.4:
+ decision = "REVIEW"
+ reason = f"Moderate risk score {avg_risk:.2f} requires review"
+
+ # Fail closed: any detector error overrides the aggregate decision.
+ if detector_errors:
+ decision = "DENY"
+ reason = (
+ f"Fail-closed: {len(detector_errors)} detector(s) errored "
+ f"({'; '.join(detector_errors)})"
+ )
+
+ output = SentinelOutput(
+ trace_id=input_data.trace_id,
+ risk_score=avg_risk,
+ risk_level=self._risk_level(avg_risk),
+ detections=detections,
+ quarantine_recommended=quarantine_recommended,
+ quarantine_action=quarantine_action,
+ collusion_patterns=collusion_patterns,
+ timeline=timeline,
+ trace_claims=[], # skip TraceClaim objects to avoid validation errors
+ graph_nodes=graph_nodes,
+ graph_edges=graph_edges,
+ decision=decision,
+ reason=reason
+ )
+
+ if quarantine_recommended:
+ output = generate_quarantine(output, input_data.agent_id)
+
+ return output
+
+ # ===== FLEET EVALUATION =====
+ def evaluate_fleet(self, inputs: List[SentinelInput]) -> Dict[str, Any]:
+ agent_results = []
+ for inp in inputs:
+ result = self.evaluate(inp)
+ agent_results.append({"agent_id": inp.agent_id, "result": result})
+ avg_fleet_risk = sum(r["result"].risk_score for r in agent_results) / len(agent_results) if agent_results else 0.0
+ return {
+ "agent_results": agent_results,
+ "fleet_risk_score": avg_fleet_risk,
+ "fleet_risk_level": self._risk_level(avg_fleet_risk).value
+ }
+
+ # ===== ENFORCEMENT ACTIONS =====
+ def enforce_escalate(self, agent_id: str, claim_id: str) -> Dict[str, Any]:
+ ticket_id = f"INC-{datetime.now().strftime('%Y%m%d%H%M%S')}"
+ enforcement_logs[claim_id] = {
+ "action": "ESCALATE",
+ "details": {
+ "ticket_created": ticket_id,
+ "supervisor_notified": True,
+ "trace_claim_attached": claim_id,
+ "timestamp": datetime.now().isoformat()
+ }
+ }
+ return {
+ "status": "escalated",
+ "agent": agent_id,
+ "action": "ESCALATE",
+ "ticket_id": ticket_id,
+ "supervisor_notified": True,
+ "trace_claim": claim_id
+ }
+
+ def enforce_quarantine(self, agent_id: str, claim_id: str) -> Dict[str, Any]:
+ record = QuarantineRecord(
+ agent_id=agent_id,
+ timestamp=datetime.now(),
+ reason="Delegation escalation detected",
+ blocked_tools=["grant_permission", "delete_logs", "write_config"],
+ trace_claim_id=claim_id,
+ action=Action.QUARANTINE,
+ status="active"
+ )
+ quarantine_store[agent_id] = record
+ enforcement_logs[claim_id] = {
+ "action": "QUARANTINE",
+ "details": {
+ "agent_status": "isolated",
+ "tools_disabled": record.blocked_tools,
+ "reason": record.reason,
+ "timestamp": datetime.now().isoformat()
+ }
+ }
+ return {
+ "status": "quarantined",
+ "agent": agent_id,
+ "action": "QUARANTINE",
+ "reason": record.reason,
+ "blocked_tools": record.blocked_tools,
+ "trace_claim": claim_id,
+ "timestamp": record.timestamp.isoformat()
+ }
+
+ def enforce_block(self, agent_id: str, claim_id: str) -> Dict[str, Any]:
+ enforcement_logs[claim_id] = {
+ "action": "BLOCK",
+ "details": {
+ "execution_denied": True,
+ "claim_status": "BLOCKED",
+ "policy_version": "v3",
+ "reason": "Delegation escalation detected",
+ "timestamp": datetime.now().isoformat()
+ }
+ }
+ return {
+ "decision": "DENY",
+ "reason": "Delegation escalation detected",
+ "trace": claim_id,
+ "agent": agent_id,
+ "policy": "v3",
+ "timestamp": datetime.now().isoformat()
+ }
+
+ 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
+
+ def _risk_color(self, score: float) -> str:
+ if score < 0.3:
+ return "#238636"
+ if score < 0.6:
+ return "#d29922"
+ return "#f85149"
\ No newline at end of file
diff --git a/sentinel/src/server.py b/integrations/sentinel/sentinel/server.py
similarity index 82%
rename from sentinel/src/server.py
rename to integrations/sentinel/sentinel/server.py
index 69ea7e2..ed5f805 100644
--- a/sentinel/src/server.py
+++ b/integrations/sentinel/sentinel/server.py
@@ -2,19 +2,17 @@
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
-from src.models import (
+from sentinel.models import (
SentinelInput, Ticket, EnforcementResult, Action,
IncidentReport, ReplayResult, Receipt
)
-from src.risk_engine import RiskEngine
-from src.replay_engine import ReplayEngine
-from src.signing import sign_payload, verify_payload, is_signing_configured, SigningKeyMissing
-from src.trace_verification import verify_trace, TraceVerificationError
+from sentinel.risk_engine import RiskEngine
+from sentinel.replay_engine import ReplayEngine
import traceback
import uuid
import json
import hashlib
-import hmac
+import base64
from datetime import datetime
app = FastAPI(title="Agent Sentinel")
@@ -46,6 +44,11 @@ def log_enforcement(action: str, claim_id: str, result: dict, status: str = "SUC
print(f"Result: {result.get('message', result)}")
print(f"Status: {status}\n")
+def sign_payload(payload: dict) -> str:
+ data = json.dumps(payload, sort_keys=True).encode('utf-8')
+ hash_digest = hashlib.sha256(data).digest()
+ return base64.b64encode(hash_digest + b"signed").decode('utf-8')
+
def hash_payload(payload: dict) -> str:
data = json.dumps(payload, sort_keys=True).encode('utf-8')
return hashlib.sha256(data).hexdigest()
@@ -63,16 +66,6 @@ async def evaluate(request: Request):
if not agents_list:
return JSONResponse(content={"error": "No agents provided"}, status_code=400)
- # Verification gate: refuse to score/enforce on unverified trace input.
- try:
- for agent_data in agents_list:
- verify_trace(agent_data)
- except TraceVerificationError as e:
- return JSONResponse(
- content={"error": f"Trace verification failed: {str(e)}"},
- status_code=403,
- )
-
inputs = []
for agent_data in agents_list:
inp = SentinelInput(
@@ -127,14 +120,6 @@ async def evaluate(request: Request):
}
return JSONResponse(content=serializable)
else:
- # Verification gate: refuse to score/enforce on unverified trace input.
- try:
- verify_trace(data)
- except TraceVerificationError as e:
- return JSONResponse(
- content={"error": f"Trace verification failed: {str(e)}"},
- status_code=403,
- )
try:
inp = SentinelInput(**data)
result = engine.evaluate(inp)
@@ -301,25 +286,12 @@ async def export_incident(claim_id: str, request: Request):
if claim_id in receipt_store:
report.receipt = receipt_store[claim_id]
- # Generate hashes and a keyed signature for the report (excluding the
- # signature / hash fields and the signature_status marker).
- report_dict = report.model_dump(
- mode='json',
- exclude={'signature', 'claim_hash', 'incident_hash', 'signature_status'},
- )
+ # Generate hashes and signature for the report (without the signature and hash fields)
+ report_dict = report.model_dump(mode='json', exclude={'signature', 'claim_hash', 'incident_hash'})
claim_data = {"claim_id": claim_id, "agent_id": agent_id, "detection_type": detection_type, "risk_score": risk_score}
report.claim_hash = hash_payload(claim_data)
report.incident_hash = hash_payload(report_dict)
-
- # Fail closed: only emit a signature when a signing key is configured.
- # Otherwise mark the report explicitly unsigned rather than emitting a
- # value that merely looks signed.
- try:
- report.signature = sign_payload(report_dict)
- report.signature_status = "signed"
- except SigningKeyMissing:
- report.signature = None
- report.signature_status = "unsigned"
+ report.signature = sign_payload(report_dict)
return JSONResponse(content=report.model_dump(mode='json'))
except Exception as e:
@@ -346,23 +318,8 @@ async def verify_incident(claim_id: str, request: Request):
if not report_data:
return JSONResponse(content={"error": "Missing report data"}, status_code=400)
- # Fail closed: a keyed signature is required to verify. Without a
- # configured signing key there is nothing to verify against.
- if not is_signing_configured():
- return JSONResponse(content={
- "claim_id": claim_id,
- "status": "UNVERIFIABLE",
- "details": {
- "reason": "No signing key configured (SENTINEL_SIGNING_KEY). "
- "Cannot verify incident signatures.",
- }
- })
-
- # Recompute integrity hashes; verify the keyed signature with HMAC.
- report_copy = {
- k: v for k, v in report_data.items()
- if k not in ["signature", "claim_hash", "incident_hash", "signature_status"]
- }
+ # Recompute hashes and signature from the report (excluding signature and hash fields)
+ report_copy = {k: v for k, v in report_data.items() if k not in ["signature", "claim_hash", "incident_hash"]}
recomputed_claim_hash = hash_payload({
"claim_id": claim_id,
"agent_id": report_data.get("agent_id"),
@@ -370,11 +327,11 @@ async def verify_incident(claim_id: str, request: Request):
"risk_score": report_data.get("risk_score")
})
recomputed_incident_hash = hash_payload(report_copy)
+ recomputed_signature = sign_payload(report_copy)
- valid_claim_hash = hmac.compare_digest(recomputed_claim_hash, report_data.get("claim_hash") or "")
- valid_incident_hash = hmac.compare_digest(recomputed_incident_hash, report_data.get("incident_hash") or "")
- # Keyed HMAC verification with constant-time comparison.
- valid_signature = verify_payload(report_copy, report_data.get("signature") or "")
+ valid_claim_hash = recomputed_claim_hash == report_data.get("claim_hash")
+ valid_incident_hash = recomputed_incident_hash == report_data.get("incident_hash")
+ valid_signature = recomputed_signature == report_data.get("signature")
status = "VERIFIED" if (valid_claim_hash and valid_incident_hash and valid_signature) else "TAMPERED"
return JSONResponse(content={
diff --git a/sentinel/src/templates/dashboard.html b/integrations/sentinel/sentinel/templates/dashboard.html
similarity index 79%
rename from sentinel/src/templates/dashboard.html
rename to integrations/sentinel/sentinel/templates/dashboard.html
index 031c7a7..465313c 100644
--- a/sentinel/src/templates/dashboard.html
+++ b/integrations/sentinel/sentinel/templates/dashboard.html
@@ -135,19 +135,6 @@
๐ Policy Replay
let currentReplayResults = {};
let claimActionMap = {};
- // Escape any string that originates from posted trace data before it is
- // placed into innerHTML. Prevents DOM-XSS from agent_id, reason,
- // description, detection_type, claim_id, etc.
- function esc(value) {
- if (value === null || value === undefined) return '';
- return String(value)
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
function getRiskLevel(agent) {
if (agent && agent.risk_level) {
const level = agent.risk_level.toLowerCase();
@@ -193,9 +180,9 @@ ๐ Policy Replay
}
let summaryHtml = '';
for (const [type, count] of Object.entries(detectionCounts)) {
- summaryHtml += `${esc(count)}
${esc(type.replace(/_/g, ' ').toUpperCase())}
`;
+ summaryHtml += `${count}
${type.replace(/_/g, ' ').toUpperCase()}
`;
}
- summaryHtml += `${esc(totalClaims)}
TRACE CLAIMS
`;
+ summaryHtml += `${totalClaims}
TRACE CLAIMS
`;
summaryContainer.innerHTML = summaryHtml;
const agentContainer = document.getElementById('agentCards');
@@ -209,15 +196,14 @@ ๐ Policy Replay
if (d.action === "escalate") { action = "ESCALATE"; actionClass = "action-escalate"; badge = "badge-escalate"; break; }
if (d.action === "block") { action = "BLOCK"; actionClass = "action-block"; badge = "badge-block"; break; }
}
- const topDets = agent.detections.slice(0, 2).map(d => esc(d.detection_type.replace(/_/g, ' ').toUpperCase())).join(', ');
- const riskLevelClass = getRiskLevel(agent);
+ const topDets = agent.detections.slice(0, 2).map(d => d.detection_type.replace(/_/g, ' ').toUpperCase()).join(', ');
agentHtml += `
- ${esc(agent.agent_id)}
+ ${agent.agent_id}
${action}
- Risk: ${(agent.risk_score * 100).toFixed(0)}%
+ Risk: ${(agent.risk_score * 100).toFixed(0)}%
${topDets || 'No detections'}
- ${agent.quarantine_action ? `๐ ${esc(agent.quarantine_action.blocked_tools.join(', '))} blocked ` : ''}
+ ${agent.quarantine_action ? `๐ ${agent.quarantine_action.blocked_tools.join(', ')} blocked ` : ''}
`;
}
@@ -235,33 +221,24 @@ ๐ Policy Replay
const actionFromClaim = claim.enforcement_action || 'monitor';
claimActionMap[claim.claim_id] = actionFromClaim.toLowerCase();
- // All trace-derived strings are HTML-escaped before insertion.
- // Untrusted values are carried on data-* attributes (also
- // escaped) and read by delegated listeners attached below --
- // never interpolated into inline on* handlers.
claimsHtml += `
-
+
- ${esc(claim.claim_id)}
- ${esc(status.replace(/_/g, ' ').toUpperCase())}
- ${esc(decision)}
+ ${claim.claim_id}
+ ${status.replace(/_/g, ' ').toUpperCase()}
+ ${decision}
-
Agent: ${esc(claim.agent_id)} | Detection: ${esc(claim.detection_type.replace(/_/g, ' ').toUpperCase())} | Risk: ${(claim.risk_score * 100).toFixed(0)}%
- ${claim.reason ? `
Reason: ${esc(claim.reason)}
` : ''}
+
Agent: ${claim.agent_id} | Detection: ${claim.detection_type.replace(/_/g, ' ').toUpperCase()} | Risk: ${(claim.risk_score * 100).toFixed(0)}%
+ ${claim.reason ? `
Reason: ${claim.reason}
` : ''}
- ๐ Escalate
- ๐ Quarantine
- ๐ซ Block
- ๐ Replay
- ๐ Export Incident
- โ
Verify
+ ๐ Escalate
+ ๐ Quarantine
+ ๐ซ Block
+ ๐ Replay
+ ๐ Export Incident
+ โ
Verify
-
+
`;
}
@@ -270,40 +247,18 @@
๐ Policy Replay
}
claimsContainer.innerHTML = claimsHtml;
- // Attach listeners instead of inline on* handlers. The untrusted
- // claim values live on data-* attributes, so they never become code.
- claimsContainer.querySelectorAll('.claim-box button[data-action]').forEach(btn => {
- btn.addEventListener('click', () => {
- const box = btn.closest('.claim-box');
- const claimId = box.dataset.claimId;
- const agentId = box.dataset.agentId;
- const detectionType = box.dataset.detectionType;
- const riskScore = parseFloat(box.dataset.riskScore) || 0.0;
- const claimRiskLevel = box.dataset.riskLevel || 'low';
- switch (btn.dataset.action) {
- case 'escalate': enforceClaim(claimId, 'escalate', agentId); break;
- case 'quarantine': enforceClaim(claimId, 'quarantine', agentId); break;
- case 'block': enforceClaim(claimId, 'block', agentId); break;
- case 'replay': openReplay(claimId); break;
- case 'export': exportIncident(claimId, agentId, detectionType, riskScore, claimRiskLevel); break;
- case 'verify': verifyIncident(claimId, agentId, detectionType, riskScore, claimRiskLevel); break;
- }
- });
- });
-
const timelineContainer = document.getElementById('timelineContainer');
let timelineHtml = '';
if (data.timeline && data.timeline.length > 0) {
for (const event of data.timeline) {
const time = new Date(event.timestamp).toLocaleTimeString();
- const sev = (event.severity || '').toString().toLowerCase();
- const severityClass = ['low', 'medium', 'high', 'critical'].includes(sev) ? `risk-${sev}` : '';
+ const severityClass = event.severity ? `risk-${event.severity}` : '';
timelineHtml += `
- ${esc(time)}
- ${esc(event.agent_id)}
- ${esc((event.event_type || '').replace(/_/g, ' ').toUpperCase())}
- - ${esc(event.description)}
+ ${time}
+ ${event.agent_id}
+ ${event.event_type.replace(/_/g, ' ').toUpperCase()}
+ - ${event.description}
`;
}
@@ -373,30 +328,26 @@
๐ Policy Replay
let receiptHtml = `
๐งพ Enforcement Receipt
-
Claim: ${esc(claimId)}
-
Action: ${esc((result.action || '').toUpperCase())}
-
Timestamp: ${esc(new Date(result.timestamp).toLocaleString())}
-
Agent: ${esc(result.agent_id)}
+
Claim: ${claimId}
+
Action: ${result.action.toUpperCase()}
+
Timestamp: ${new Date(result.timestamp).toLocaleString()}
+
Agent: ${result.agent_id}
`;
for (const [key, value] of Object.entries(result.details)) {
if (key === 'message') continue;
let displayValue = value;
if (typeof value === 'object') displayValue = JSON.stringify(value);
- receiptHtml += `
${esc(key.replace(/_/g, ' ').toUpperCase())}: ${esc(displayValue)}
`;
+ receiptHtml += `
${key.replace(/_/g, ' ').toUpperCase()}: ${displayValue}
`;
}
receiptHtml += `
-
Outcome: ${esc(result.details.message || 'Applied')}
+
Outcome: ${result.details.message || 'Applied'}
Evidence: trace.jwt
- ๐ Export Receipt JSON
+ ๐ Export Receipt JSON
`;
logDiv.innerHTML = receiptHtml;
- const exportBtn = logDiv.querySelector('button[data-export-receipt]');
- if (exportBtn) {
- exportBtn.addEventListener('click', () => exportReceipt(claimId));
- }
}
const claimBox = document.getElementById(`claim-${claimId}`);
@@ -504,9 +455,15 @@
๐งพ Enforcement Receipt
}
}
- async function verifyIncident(claimId, agentId, detectionType, riskScore, riskLevel) {
+ async function verifyIncident(claimId) {
try {
- riskLevel = riskLevel || 'low';
+ const claimBox = document.getElementById(`claim-${claimId}`);
+ const infoDiv = claimBox.querySelector('div:nth-child(2)');
+ const agentId = infoDiv.innerText.split('Agent: ')[1].split('|')[0].trim();
+ const detectionType = infoDiv.innerText.split('Detection: ')[1].split('|')[0].trim();
+ const riskScore = parseFloat(infoDiv.innerText.split('Risk: ')[1].replace('%', '')) / 100;
+ const riskLevel = 'low';
+
const exportResponse = await fetch(`/export/incident/${claimId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -534,8 +491,6 @@
๐งพ Enforcement Receipt
const verifyResult = await verifyResponse.json();
if (verifyResult.status === 'VERIFIED') {
alert('โ
Signature verified! Incident is authentic.');
- } else if (verifyResult.status === 'UNVERIFIABLE') {
- alert('โ ๏ธ Cannot verify: ' + (verifyResult.details && verifyResult.details.reason ? verifyResult.details.reason : 'no signing key configured.'));
} else {
alert('โ Signature verification failed: ' + JSON.stringify(verifyResult.details));
}
@@ -571,7 +526,7 @@
๐งพ Enforcement Receipt
});
const replayResults = await response.json();
if (replayResults.error) {
- resultsDiv.innerHTML = `
Error: ${esc(replayResults.error)} `;
+ resultsDiv.innerHTML = `
Error: ${replayResults.error} `;
return;
}
currentReplayResults[claimId] = replayResults;
@@ -585,18 +540,18 @@
๐งพ Enforcement Receipt
const decisionColor = r.decision === 'DENY' ? '#f85149' : '#3fb950';
tableHtml += `
- ${esc(r.policy_version)}
+ ${r.policy_version}
${(r.risk_score * 100).toFixed(0)}%
- ${esc(r.risk_level)}
- ${esc(r.decision)}
- ${esc(r.reason)}
+ ${r.risk_level}
+ ${r.decision}
+ ${r.reason}
`;
}
tableHtml += '';
resultsDiv.innerHTML = tableHtml;
} catch (e) {
- resultsDiv.innerHTML = `
Error: ${esc(e.message)} `;
+ resultsDiv.innerHTML = `
Error: ${e.message} `;
}
}
diff --git a/integrations/sentinel/sentinel/trace_claim_generator.py b/integrations/sentinel/sentinel/trace_claim_generator.py
new file mode 100644
index 0000000..e9015bf
--- /dev/null
+++ b/integrations/sentinel/sentinel/trace_claim_generator.py
@@ -0,0 +1,187 @@
+"""
+Agent Sentinel -> TRACE v0.1 claim generator.
+
+Every enforcement event is emitted as an Ed25519-signed TRACE v0.1 JWT,
+conformant at Level 0 (software-only; no hardware TEE attestation).
+
+Signing is mandatory. Sentinel will not emit an unsigned governance claim.
+Supply an Ed25519 signing key via the TRACE_PRIVATE_KEY_PEM environment
+variable, or pass one explicitly to TraceClaimGenerator. If no key is
+available, claim generation fails closed rather than emitting an unsigned
+record.
+"""
+
+import base64
+import json
+import os
+import time
+from dataclasses import dataclass
+from datetime import date, datetime
+from typing import Any, Dict, Optional
+
+import jwt
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+
+# Sentinel enforcement decision -> TRACE appraisal.status
+_APPRAISAL_MAP = {
+ "ADMIT": "affirming",
+ "REVIEW": "warning",
+ "DENY": "contraindicated",
+ "BLOCK": "contraindicated",
+}
+
+_SELF_URI = "https://github.com/agentrust-io/integrations/tree/main/integrations/sentinel"
+
+
+def load_signing_key(explicit: Optional[Ed25519PrivateKey] = None) -> Ed25519PrivateKey:
+ """Return the Ed25519 signing key, or raise if none is configured.
+
+ Fails closed: without a key, Sentinel refuses to emit unsigned claims.
+ """
+ if explicit is not None:
+ return explicit
+ pem = os.environ.get("TRACE_PRIVATE_KEY_PEM")
+ if not pem:
+ raise RuntimeError(
+ "TRACE_PRIVATE_KEY_PEM is not set. Agent Sentinel signs every TRACE "
+ "claim and refuses to emit unsigned governance records. Generate an "
+ "Ed25519 key and set it in the environment before enforcing."
+ )
+ return serialization.load_pem_private_key(pem.encode(), password=None)
+
+
+def private_key_to_jwk(key: Ed25519PrivateKey) -> dict:
+ raw = key.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ return {
+ "kty": "OKP",
+ "crv": "Ed25519",
+ "x": base64.urlsafe_b64encode(raw).decode().rstrip("="),
+ }
+
+
+def _isoified(value: Any) -> Any:
+ """Recursively convert datetime/date values to ISO strings for JSON output."""
+ if isinstance(value, dict):
+ return {k: _isoified(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_isoified(v) for v in value]
+ if isinstance(value, (datetime, date)):
+ return value.isoformat()
+ return value
+
+
+@dataclass
+class SignedTraceClaim:
+ payload: Dict[str, Any] # canonical TRACE v0.1 EAT payload (includes cnf.jwk)
+ token: str # EdDSA-signed JWT, offline-verifiable against cnf.jwk
+
+ def to_json(self) -> str:
+ return json.dumps(
+ {"payload": self.payload, "token": self.token},
+ separators=(",", ":"),
+ )
+
+
+class TraceClaimGenerator:
+ def __init__(
+ self,
+ issuer_id: str = "sentinel",
+ signing_key: Optional[Ed25519PrivateKey] = None,
+ ):
+ self.issuer_id = issuer_id
+ # Loaded lazily so importing this module (and the FastAPI app) has no
+ # side effects and does not require a key to be present.
+ self._signing_key = signing_key
+
+ def _key(self) -> Ed25519PrivateKey:
+ if self._signing_key is None:
+ self._signing_key = load_signing_key()
+ return self._signing_key
+
+ def build_payload(
+ self,
+ enforcement_event: Dict[str, Any],
+ agent_id: str = "agent-fleet",
+ decision: str = "DENY",
+ model: str = "unknown/unknown",
+ ) -> Dict[str, Any]:
+ key = self._key()
+ provider, _, model_id = model.partition("/")
+ return {
+ # Required TRACE EAT envelope
+ "eat_profile": "tag:agentrust.io,2026:trace-v0.1",
+ "iat": int(time.time()),
+ "subject": f"spiffe://agentrust.io/agent/{agent_id}",
+ # Confirmation key: the Ed25519 public key that signs this claim
+ "cnf": {"jwk": private_key_to_jwk(key)},
+ "model": {
+ "provider": provider or "unknown",
+ "model_id": model_id or model,
+ "version": "unknown",
+ # sha256 all-zeros: canonical Level 0 placeholder (no HW attestation)
+ "weights_digest": "sha256:" + "0" * 64,
+ },
+ "runtime": {
+ "platform": "software-only",
+ "measurement": "sha384:" + "0" * 96,
+ "rim_uri": _SELF_URI,
+ },
+ "policy": {
+ "bundle_hash": "sha256:" + "0" * 64,
+ "enforcement_mode": "enforce",
+ "version": "1.0.0",
+ },
+ "data_class": "confidential",
+ "build_provenance": {
+ "slsa_level": 0,
+ "builder": _SELF_URI,
+ "digest": "sha256:" + "0" * 64,
+ },
+ # Appraisal carries the Sentinel enforcement decision
+ "appraisal": {
+ "status": _APPRAISAL_MAP.get(decision, "contraindicated"),
+ "verifier": _SELF_URI,
+ "policy_ref": "agent-sentinel-v1.0.0",
+ },
+ "transparency": "",
+ # Sentinel-specific extension claim: the detection/enforcement detail
+ "sentinel": {
+ "issuer": self.issuer_id,
+ "decision": decision,
+ "event": _isoified(enforcement_event),
+ },
+ }
+
+ def generate_claim(
+ self,
+ enforcement_event: Dict[str, Any],
+ agent_id: str = "agent-fleet",
+ decision: str = "DENY",
+ model: str = "unknown/unknown",
+ ) -> SignedTraceClaim:
+ key = self._key()
+ payload = self.build_payload(enforcement_event, agent_id, decision, model)
+ token = jwt.encode(
+ payload, key, algorithm="EdDSA", headers={"alg": "EdDSA", "typ": "JWT"}
+ )
+ return SignedTraceClaim(payload=payload, token=token)
+
+
+def generate_trace_claim(
+ event: Dict[str, Any],
+ agent_id: str = "agent-fleet",
+ decision: str = "DENY",
+) -> str:
+ """Convenience wrapper: return the signed TRACE claim as a JSON string.
+
+ Requires a signing key (TRACE_PRIVATE_KEY_PEM); raises otherwise.
+ """
+ claim = TraceClaimGenerator().generate_claim(
+ event, agent_id=agent_id, decision=decision
+ )
+ return claim.to_json()
diff --git a/sentinel/src/trace_ingester.py b/integrations/sentinel/sentinel/trace_ingester.py
similarity index 69%
rename from sentinel/src/trace_ingester.py
rename to integrations/sentinel/sentinel/trace_ingester.py
index 6352e21..40d2545 100644
--- a/sentinel/src/trace_ingester.py
+++ b/integrations/sentinel/sentinel/trace_ingester.py
@@ -1,17 +1,11 @@
import json
-from src.models import SentinelInput, SentinelOutput # <-- added SentinelOutput
-from src.risk_engine import RiskEngine
-from src.trace_verification import verify_trace
+from sentinel.models import SentinelInput, SentinelOutput # <-- added SentinelOutput
+from sentinel.risk_engine import RiskEngine
def ingest_trace(trace_path: str) -> SentinelOutput:
with open(trace_path, 'r') as f:
data = json.load(f)
- # Verification gate: refuse to score/enforce on unverified trace input.
- # Raises TraceVerificationError unless the trace is signed by the configured
- # trusted key (or SENTINEL_ALLOW_UNVERIFIED=1 is explicitly set).
- verify_trace(data)
-
steps = data.get("steps", [])
if not steps:
raise ValueError("No steps found in trace")
diff --git a/sentinel/src/__init__.py b/integrations/sentinel/tests/__init__.py
similarity index 100%
rename from sentinel/src/__init__.py
rename to integrations/sentinel/tests/__init__.py
diff --git a/integrations/sentinel/tests/test_delegation_escalation_detector.py b/integrations/sentinel/tests/test_delegation_escalation_detector.py
new file mode 100644
index 0000000..5d1fd2c
--- /dev/null
+++ b/integrations/sentinel/tests/test_delegation_escalation_detector.py
@@ -0,0 +1,31 @@
+import pytest
+from sentinel.detectors.delegation_escalation import DelegationEscalationDetector
+from sentinel.models import SentinelInput # use models, not schemas
+
+@pytest.fixture
+def detector():
+ return DelegationEscalationDetector()
+
+def test_clean_delegation(detector):
+ input_data = SentinelInput(
+ trace_id="test-001",
+ delegation_chain=["root", "admin"],
+ policy_version="v1",
+ agent_id="alice",
+ action="read"
+ )
+ result = detector.detect(input_data)
+ assert result.risk_score == 0.7
+ assert result.detected is False
+
+def test_risky_delegation(detector):
+ input_data = SentinelInput(
+ trace_id="test-002",
+ delegation_chain=["root", "admin", "finance", "ops"],
+ policy_version="v1",
+ agent_id="bob",
+ action="write"
+ )
+ result = detector.detect(input_data)
+ assert result.risk_score > 0.8
+ assert result.detected is True
\ No newline at end of file
diff --git a/integrations/sentinel/tests/test_health_endpoint.py b/integrations/sentinel/tests/test_health_endpoint.py
new file mode 100644
index 0000000..4166500
--- /dev/null
+++ b/integrations/sentinel/tests/test_health_endpoint.py
@@ -0,0 +1,17 @@
+import pytest
+from fastapi.testclient import TestClient
+from sentinel.main import app
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+def test_health_endpoint(client):
+ response = client.get("/health")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "ok"
+ assert data["service"] == "sentinel"
+ assert "version" in data
\ No newline at end of file
diff --git a/integrations/sentinel/tests/test_trace_conformance.py b/integrations/sentinel/tests/test_trace_conformance.py
new file mode 100644
index 0000000..9d66b64
--- /dev/null
+++ b/integrations/sentinel/tests/test_trace_conformance.py
@@ -0,0 +1,76 @@
+"""TRACE v0.1 Level 0 conformance for Agent Sentinel's emitted records.
+
+Runs the agentrust-trace-tests Level 0 suite (TR-ENV, TR-SIG, TR-POL) against
+records produced by the signed claim generator, plus a signature round-trip.
+"""
+
+import json
+import os
+import tempfile
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+import jwt as pyjwt
+from sentinel.trace_claim_generator import TraceClaimGenerator, private_key_to_jwk
+
+trace_loader = pytest.importorskip("trace_tests.loader")
+trace_runner = pytest.importorskip("trace_tests.runner")
+load_record = trace_loader.load_record
+trace_run = trace_runner.run
+
+
+ENFORCE_EVENT = {
+ "event_id": "enforce-trace-001",
+ "event_type": "ENFORCEMENT",
+ "detection": {"detection_type": "delegation_escalation", "risk_score": 0.9},
+ "input": {"agent_id": "alice", "delegation_chain": ["root", "admin", "finance"]},
+}
+
+
+@pytest.fixture
+def generator():
+ # Deterministic in-test key; no dependency on the environment.
+ return TraceClaimGenerator(signing_key=Ed25519PrivateKey.generate())
+
+
+def _run_level0(payload: dict) -> dict:
+ with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
+ json.dump(payload, f)
+ fname = f.name
+ try:
+ record, fmt = load_record(fname)
+ return trace_run(record, fmt, level=0)
+ finally:
+ os.unlink(fname)
+
+
+class TestLevel0Conformance:
+ def _assert_no_failures(self, results: dict) -> None:
+ failures = [f for findings in results.values() for f in findings if f.failed()]
+ assert not failures, f"Level 0 failures: {[f.message for f in failures]}"
+
+ def test_deny_payload_passes_level0(self, generator):
+ payload = generator.build_payload(ENFORCE_EVENT, agent_id="alice", decision="DENY")
+ self._assert_no_failures(_run_level0(payload))
+
+ def test_admit_payload_passes_level0(self, generator):
+ payload = generator.build_payload(ENFORCE_EVENT, agent_id="bob", decision="ADMIT")
+ self._assert_no_failures(_run_level0(payload))
+
+
+class TestSigning:
+ def test_claim_is_required_to_be_signed(self, monkeypatch):
+ # With no key and no env var, generation fails closed instead of
+ # emitting an unsigned record.
+ monkeypatch.delenv("TRACE_PRIVATE_KEY_PEM", raising=False)
+ with pytest.raises(RuntimeError):
+ TraceClaimGenerator().generate_claim(ENFORCE_EVENT)
+
+ def test_signature_verifies_against_cnf_jwk(self, generator):
+ claim = generator.generate_claim(ENFORCE_EVENT, agent_id="alice", decision="DENY")
+ public_key = generator._key().public_key()
+ decoded = pyjwt.decode(claim.token, public_key, algorithms=["EdDSA"])
+ assert decoded["eat_profile"] == "tag:agentrust.io,2026:trace-v0.1"
+ assert decoded["appraisal"]["status"] == "contraindicated"
+ assert decoded["cnf"]["jwk"] == private_key_to_jwk(generator._key())
diff --git a/sentinel/.trace-tests-config.yml b/sentinel/.trace-tests-config.yml
deleted file mode 100644
index e69de29..0000000
diff --git a/sentinel/README.md b/sentinel/README.md
deleted file mode 100644
index d460d66..0000000
--- a/sentinel/README.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Agent Sentinel
-
-Runtime behavioral anomaly detection, collusion detection, and quarantine for agent fleets.
-
-## Features
-- **5 detectors**: delegation escalation, tool drift, policy avoidance, identity drift, collusion
-- **Risk aggregation** with quarantine threshold (0.7)
-- **Quarantine enforcement**: blocks tools, requires human review
-- **Multi-agent collusion detection**: delegation chains, shared tools
-- **CLI + FastAPI dashboard**
-- **TRACE-native** (consumes TRACE claims)
-
-## Usage
-
-
-```bash
-pip install -r requirements.txt
-python -m src.cli claim.jwt --output report.json
-
-Integration with AgenTrust
-
-Sentinel consumes TRACE claims and produces risk scores that can be used by AGT, cMCP, and other AgenTrust components.
-
-Dashboard
-
-bash
-uvicorn src.server:app --host 0.0.0.0 --port 8001 --reload
-Open http://localhost:8001
-
-Integration with AgenTrust
-
-Sentinel fills the documented gap: "no dedicated behavioral anomaly detection or agent quarantine tooling."
-
-## Security
-
-Sentinel fails closed. Two controls are configured by environment variable:
-
-- **Trace verification gate.** Incoming traces are scored and enforced only
- after their Ed25519 signature is verified against a trusted key supplied in
- `TRACE_TRUSTED_JWK` (an OKP/Ed25519 public JWK as JSON). Unsigned traces,
- bad signatures, or a missing trusted key are rejected. To run against
- unsigned demo data, set `SENTINEL_ALLOW_UNVERIFIED=1` โ this bypasses
- verification and logs a loud warning on every use, and must not be used in
- production.
-- **Incident report signatures.** Exported incident reports are signed with
- HMAC-SHA256 using the secret in `SENTINEL_SIGNING_KEY`. If the key is unset
- the report is marked `"signature_status": "unsigned"` and carries no
- signature (it is never emitted with a value that merely looks signed). The
- `/verify` endpoint checks the keyed HMAC in constant time and returns
- `UNVERIFIABLE` when no key is configured.
-
-```bash
-# Example: run the demo against unsigned sample data
-SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli sample_trace.json --output report.json
-
-# Production: verify traces and sign incidents
-export TRACE_TRUSTED_JWK='{"kty":"OKP","crv":"Ed25519","x":"
"}'
-export SENTINEL_SIGNING_KEY=''
-```
-
-License
-
-MIT
----
-
-## ๐ How to build and run
-
-```bash
-cd /Users/akhileshwarik/agentrust-io/integrations/sentinel
-pip install -r requirements.txt
-python -m src.cli ../decisionassure/claim.jwt --output report.json
\ No newline at end of file
diff --git a/sentinel/docker-compose.yml b/sentinel/docker-compose.yml
deleted file mode 100644
index e69de29..0000000
diff --git a/sentinel/integration.yaml b/sentinel/integration.yaml
deleted file mode 100644
index 4c51408..0000000
--- a/sentinel/integration.yaml
+++ /dev/null
@@ -1,34 +0,0 @@
-apiVersion: integration/v1
-kind: Integration
-metadata:
- name: sentinel
- displayName: Agent Sentinel
- description: Runtime behavioral anomaly detection, collusion detection, and quarantine for agent fleets.
- category: governance
- maintainer:
- name: Akhilesh Warik
- email: akhilesh.warik@example.com
- github: a1k7
- license: MIT
- version: 2.0.0
-spec:
- compatibility:
- - trace-spec: v0.1
- - conformance: Level 0
- files:
- - src/
- - requirements.txt
- - README.md
- usage:
- command: |
- pip install -r requirements.txt
- # Sentinel fails closed: traces are verified before scoring. For signed
- # production traces set TRACE_TRUSTED_JWK. To run the unsigned demo trace,
- # set SENTINEL_ALLOW_UNVERIFIED=1 (logs a loud warning, dev/demo only).
- SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli sample_trace.json --output report.json
- # For fleet evaluation:
- # SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli fleet_trace.json --fleet --output fleet_report.json
- evidence:
- - type: manual
- description: |
- Agent Sentinel produces a risk score, detection list, quarantine action, and collusion patterns.
\ No newline at end of file
diff --git a/sentinel/report.json b/sentinel/report.json
deleted file mode 100644
index a97e711..0000000
--- a/sentinel/report.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "risk_score": 0.43750000000000006,
- "risk_level": "medium",
- "detections": [
- {
- "detection_type": "delegation_escalation",
- "risk_score": 0.8500000000000001,
- "risk_level": "critical",
- "reason": "Delegation chain depth 4 exceeds normal threshold",
- "evidence": {
- "delegation_chain": [
- "root",
- "admin",
- "superadmin",
- "god"
- ],
- "depth": 4,
- "threshold": 3
- },
- "timestamp": "2026-06-17 18:13:20.615681"
- },
- {
- "detection_type": "tool_drift",
- "risk_score": 0.6,
- "risk_level": "high",
- "reason": "Agent called 4 unique tools",
- "evidence": {
- "tools_called": [
- "write_config",
- "delete_logs",
- "grant_permission",
- "read_database"
- ],
- "count": 4
- },
- "timestamp": "2026-06-17 18:13:20.615681"
- },
- {
- "detection_type": "policy_avoidance",
- "risk_score": 0.2,
- "risk_level": "low",
- "reason": "1 boundary-adjacent actions detected",
- "evidence": {
- "boundary_actions": 1,
- "total_actions": 4
- },
- "timestamp": "2026-06-17 18:13:20.615681"
- },
- {
- "detection_type": "identity_drift",
- "risk_score": 0.1,
- "risk_level": "low",
- "reason": "Identity stable",
- "evidence": {
- "observer_identity_hash": "abc123"
- },
- "timestamp": "2026-06-17 18:13:20.615681"
- }
- ],
- "quarantine_recommended": false,
- "quarantine_reason": null,
- "trace_claim": null
-}
\ No newline at end of file
diff --git a/sentinel/requirements.txt b/sentinel/requirements.txt
deleted file mode 100644
index 1a95ef1..0000000
--- a/sentinel/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-fastapi==0.139.2
-uvicorn[standard]==0.51.0
-pydantic==2.13.4
-pyyaml==6.0.3
-httpx==0.28.1
-jinja2==3.1.6
-pandas==2.3.3
-numpy==1.26.4
-scikit-learn==1.9.0
-cryptography==48.0.1
\ No newline at end of file
diff --git a/sentinel/sample_trace.json b/sentinel/sample_trace.json
deleted file mode 100644
index d222316..0000000
--- a/sentinel/sample_trace.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "trace_id": "sentinel-demo-001",
- "steps": [
- {
- "step_index": 1,
- "step_name": "Authorize",
- "agent_id": "agent_alice",
- "session_id": "session_live",
- "policy_version": "v1",
- "delegation_chain": ["root", "admin", "superadmin", "god"],
- "observer_identity_hash": "abc123",
- "reference_frame_hash": "def456",
- "timestamp": "2026-06-17T12:00:00Z",
- "tool_calls": [
- {"name": "read_database", "args": {"query": "SELECT * FROM users"}},
- {"name": "write_config", "args": {"config": "new_settings"}},
- {"name": "delete_logs", "args": {"older_than": "30d"}},
- {"name": "grant_permission", "args": {"user": "bob", "role": "admin"}}
- ]
- }
- ]
-}
diff --git a/sentinel/src/__pycache__/__init__.cpython-314.pyc b/sentinel/src/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index a425bc7..0000000
Binary files a/sentinel/src/__pycache__/__init__.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/cli.cpython-314.pyc b/sentinel/src/__pycache__/cli.cpython-314.pyc
deleted file mode 100644
index 83691cd..0000000
Binary files a/sentinel/src/__pycache__/cli.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/models.cpython-314.pyc b/sentinel/src/__pycache__/models.cpython-314.pyc
deleted file mode 100644
index c079531..0000000
Binary files a/sentinel/src/__pycache__/models.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/quarantine.cpython-314.pyc b/sentinel/src/__pycache__/quarantine.cpython-314.pyc
deleted file mode 100644
index 81c95a2..0000000
Binary files a/sentinel/src/__pycache__/quarantine.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/replay_engine.cpython-314.pyc b/sentinel/src/__pycache__/replay_engine.cpython-314.pyc
deleted file mode 100644
index ff40ee8..0000000
Binary files a/sentinel/src/__pycache__/replay_engine.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/risk_engine.cpython-314.pyc b/sentinel/src/__pycache__/risk_engine.cpython-314.pyc
deleted file mode 100644
index 03ad113..0000000
Binary files a/sentinel/src/__pycache__/risk_engine.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/server.cpython-314.pyc b/sentinel/src/__pycache__/server.cpython-314.pyc
deleted file mode 100644
index fb52e51..0000000
Binary files a/sentinel/src/__pycache__/server.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/trace_claim_generator.cpython-314.pyc b/sentinel/src/__pycache__/trace_claim_generator.cpython-314.pyc
deleted file mode 100644
index 43e3f1b..0000000
Binary files a/sentinel/src/__pycache__/trace_claim_generator.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/__pycache__/trace_ingester.cpython-314.pyc b/sentinel/src/__pycache__/trace_ingester.cpython-314.pyc
deleted file mode 100644
index 6281f8b..0000000
Binary files a/sentinel/src/__pycache__/trace_ingester.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/cli.py b/sentinel/src/cli.py
deleted file mode 100644
index 04d4d33..0000000
--- a/sentinel/src/cli.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import click
-import json
-from src.trace_ingester import ingest_trace
-from src.risk_engine import RiskEngine
-from src.models import SentinelInput
-from src.trace_verification import verify_trace, TraceVerificationError
-
-@click.command()
-@click.argument('trace_path', type=click.Path(exists=True))
-@click.option('--output', '-o', help='Output JSON file')
-@click.option('--fleet', is_flag=True, help='Treat as multi-agent fleet input')
-def main(trace_path, output, fleet):
- """Run Sentinel on a trace or fleet."""
- with open(trace_path, 'r') as f:
- data = json.load(f)
-
- if fleet or "agents" in data:
- # Fleet mode
- # Verification gate: refuse to score/enforce on unverified trace input.
- try:
- for agent_data in data.get("agents", []):
- verify_trace(agent_data)
- except TraceVerificationError as e:
- raise click.ClickException(f"Trace verification failed: {e}")
- engine = RiskEngine()
- inputs = []
- for agent_data in data.get("agents", []):
- inp = SentinelInput(
- trace_id=agent_data.get("trace_id", "unknown"),
- agent_id=agent_data.get("agent_id", "unknown"),
- session_id=agent_data.get("session_id", "unknown"),
- policy_version=agent_data.get("policy_version", "v1"),
- delegation_chain=agent_data.get("delegation_chain", []),
- tool_calls=agent_data.get("tool_calls", []),
- observer_identity_hash=agent_data.get("observer_identity_hash", ""),
- reference_frame_hash=agent_data.get("reference_frame_hash", ""),
- timestamp=agent_data.get("timestamp", ""),
- agent_fleet=[a["agent_id"] for a in data["agents"]]
- )
- inputs.append(inp)
- result = engine.evaluate_fleet(inputs)
- output_data = result
- else:
- # Single agent
- result = ingest_trace(trace_path)
- output_data = result.model_dump()
-
- if output:
- with open(output, 'w') as f:
- json.dump(output_data, f, indent=2, default=str)
- click.echo(f"โ
Report saved to {output}")
-
- # Print summary
- click.echo("\n๐ Agent Sentinel Report")
- if fleet or "agents" in data:
- click.echo(f"Fleet Risk Score: {output_data.get('fleet_risk_score', 0):.2f}")
- click.echo(f"Fleet Risk Level: {output_data.get('fleet_risk_level', 'unknown')}")
- for pattern in output_data.get("collusion_patterns", []):
- click.echo(f" - Collusion: {pattern['description']} (risk {pattern['risk_score']:.2f})")
- else:
- click.echo(f"Risk Score: {result.risk_score:.2f}")
- click.echo(f"Risk Level: {result.risk_level}")
- click.echo(f"Quarantine Recommended: {result.quarantine_recommended}")
- if result.quarantine_recommended and result.quarantine_action:
- qa = result.quarantine_action
- click.echo(f" Quarantine Action:")
- click.echo(f" Agent: {qa.agent_id}")
- click.echo(f" Reason: {qa.reason}")
- click.echo(f" Blocked Tools: {', '.join(qa.blocked_tools)}")
- click.echo(f" Fallback: {qa.fallback}")
-
- for d in (result.detections if not fleet else []):
- click.echo(f" - {d.detection_type}: {d.risk_score:.2f} ({d.risk_level}) - {d.reason}")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/sentinel/src/detectors/__init__.py b/sentinel/src/detectors/__init__.py
deleted file mode 100644
index 2a9f24e..0000000
--- a/sentinel/src/detectors/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from .delegation_escalation import DelegationEscalationDetector
-from .tool_drift import ToolDriftDetector
-from .policy_avoidance import PolicyAvoidanceDetector
-from .identity_drift import IdentityDriftDetector
-from .collusion_detector import CollusionDetector
-
-__all__ = [
- "DelegationEscalationDetector",
- "ToolDriftDetector",
- "PolicyAvoidanceDetector",
- "IdentityDriftDetector",
- "CollusionDetector"
-]
\ No newline at end of file
diff --git a/sentinel/src/detectors/__pycache__/__init__.cpython-314.pyc b/sentinel/src/detectors/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 3bbcdd6..0000000
Binary files a/sentinel/src/detectors/__pycache__/__init__.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/base.cpython-314.pyc b/sentinel/src/detectors/__pycache__/base.cpython-314.pyc
deleted file mode 100644
index cade544..0000000
Binary files a/sentinel/src/detectors/__pycache__/base.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc b/sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc
deleted file mode 100644
index 43c584f..0000000
Binary files a/sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc b/sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc
deleted file mode 100644
index c48f454..0000000
Binary files a/sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc b/sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc
deleted file mode 100644
index b049575..0000000
Binary files a/sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc b/sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc
deleted file mode 100644
index 00024b3..0000000
Binary files a/sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/__pycache__/tool_drift.cpython-314.pyc b/sentinel/src/detectors/__pycache__/tool_drift.cpython-314.pyc
deleted file mode 100644
index 7e32bf3..0000000
Binary files a/sentinel/src/detectors/__pycache__/tool_drift.cpython-314.pyc and /dev/null differ
diff --git a/sentinel/src/detectors/base.py b/sentinel/src/detectors/base.py
deleted file mode 100644
index 9cb2b7d..0000000
--- a/sentinel/src/detectors/base.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from abc import ABC, abstractmethod
-from src.models import SentinelInput, DetectionResult
-
-class BaseDetector(ABC):
- """Base class for all anomaly detectors."""
-
- @abstractmethod
- def detect(self, input_data: SentinelInput) -> DetectionResult:
- """Run detection logic and return a DetectionResult."""
- pass
-
- @abstractmethod
- def name(self) -> str:
- """Return the detector's name."""
- pass
\ No newline at end of file
diff --git a/sentinel/src/detectors/delegation_escalation.py b/sentinel/src/detectors/delegation_escalation.py
deleted file mode 100644
index e16c367..0000000
--- a/sentinel/src/detectors/delegation_escalation.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel
-from src.detectors.base import BaseDetector
-
-class DelegationEscalationDetector(BaseDetector):
- """
- Detects when an agent suddenly gains broader delegation authority.
- """
-
- def name(self) -> str:
- return "delegation_escalation"
-
- def detect(self, input_data: SentinelInput) -> DetectionResult:
- # Baseline: normal delegation chain depth = 1-2
- # Escalation: depth > 3 or new high-privilege delegates
- depth = len(input_data.delegation_chain)
- risk_score = 0.0
- reason = "Delegation chain within normal range"
-
- if depth > 3:
- risk_score = min(0.8 + (depth - 3) * 0.05, 1.0)
- reason = f"Delegation chain depth {depth} exceeds normal threshold"
- elif "root" in input_data.delegation_chain and "admin" in input_data.delegation_chain:
- risk_score = 0.7
- reason = "Agent has both root and admin delegation"
-
- return DetectionResult(
- detection_type=DetectionType.DELEGATION_ESCALATION,
- risk_score=risk_score,
- risk_level=self._risk_level(risk_score),
- reason=reason,
- evidence={
- "delegation_chain": input_data.delegation_chain,
- "depth": depth,
- "threshold": 3
- }
- )
-
- 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
\ No newline at end of file
diff --git a/sentinel/src/risk_engine.py b/sentinel/src/risk_engine.py
deleted file mode 100644
index 18201e1..0000000
--- a/sentinel/src/risk_engine.py
+++ /dev/null
@@ -1,242 +0,0 @@
-from src.models import (
- SentinelInput, SentinelOutput, DetectionResult, RiskLevel, Action,
- TimelineEvent, TraceClaim, GraphNode, GraphEdge, QuarantineRecord
-)
-from src.detectors import (
- DelegationEscalationDetector,
- ToolDriftDetector,
- PolicyAvoidanceDetector,
- IdentityDriftDetector,
- CollusionDetector
-)
-from src.quarantine import generate_quarantine
-from src.trace_claim_generator import generate_trace_claim
-from datetime import datetime
-
-# In-memory store for quarantine records (for demo)
-quarantine_store = {}
-enforcement_logs = {} # claim_id -> log
-
-class RiskEngine:
- def __init__(self):
- self.detectors = [
- DelegationEscalationDetector(),
- ToolDriftDetector(),
- PolicyAvoidanceDetector(),
- IdentityDriftDetector(),
- CollusionDetector()
- ]
-
- def evaluate(self, input_data: SentinelInput) -> SentinelOutput:
- detections: list[DetectionResult] = []
- total_risk = 0.0
- timeline: list[TimelineEvent] = []
- trace_claims: list[TraceClaim] = []
- decision = "ADMIT"
- reason = None
-
- for detector in self.detectors:
- result = detector.detect(input_data)
- if result.risk_score > 0.7:
- result.action = Action.QUARANTINE
- elif result.risk_score > 0.4:
- result.action = Action.ESCALATE
- else:
- result.action = Action.MONITOR
-
- detections.append(result)
- total_risk += result.risk_score
-
- timeline.append(TimelineEvent(
- timestamp=result.timestamp,
- agent_id=input_data.agent_id,
- event_type=result.detection_type.value,
- description=result.reason,
- severity=result.risk_level.value
- ))
-
- if result.risk_score > 0.6:
- claim = generate_trace_claim(input_data.agent_id, result)
- trace_claims.append(claim)
-
- avg_risk = total_risk / len(self.detectors) if self.detectors else 0.0
-
- if avg_risk > 0.7:
- decision = "DENY"
- reason = f"Risk score {avg_risk:.2f} exceeds threshold"
- elif any(d.risk_score > 0.8 for d in detections):
- decision = "DENY"
- reason = f"Critical detection: {max(detections, key=lambda d: d.risk_score).detection_type}"
- # else ADMIT
-
- output = SentinelOutput(
- risk_score=avg_risk,
- risk_level=self._risk_level(avg_risk),
- detections=detections,
- quarantine_recommended=False,
- quarantine_action=None,
- collusion_patterns=[],
- timeline=timeline,
- trace_claims=trace_claims,
- graph_nodes=[],
- graph_edges=[],
- decision=decision,
- reason=reason
- )
-
- if avg_risk > 0.7:
- output = generate_quarantine(output, input_data.agent_id)
-
- return output
-
- def evaluate_fleet(self, inputs: list[SentinelInput]) -> dict:
- agent_results = []
- all_timeline: list[TimelineEvent] = []
- all_trace_claims: list[TraceClaim] = []
- all_agents = set()
- all_delegations = []
-
- for inp in inputs:
- result = self.evaluate(inp)
- agent_results.append({
- "agent_id": inp.agent_id,
- "result": result
- })
- all_timeline.extend(result.timeline)
- all_trace_claims.extend(result.trace_claims)
- all_agents.add(inp.agent_id)
- chain = inp.delegation_chain
- for node in chain:
- all_agents.add(node)
- for i in range(len(chain) - 1):
- all_delegations.append((chain[i], chain[i+1]))
-
- all_timeline.sort(key=lambda e: e.timestamp)
-
- # Build graph
- nodes = []
- for agent in all_agents:
- risk = next((r["result"].risk_score for r in agent_results if r["agent_id"] == agent), 0.0)
- color = "#238636" if risk < 0.3 else "#d29922" if risk < 0.6 else "#f85149"
- shape = "diamond" if agent == "root" else "circle"
- nodes.append(GraphNode(id=agent, label=agent, risk=risk, color=color, shape=shape))
-
- edges = []
- for frm, to in set(all_delegations):
- risk = next((r["result"].risk_score for r in agent_results if r["agent_id"] == to), 0.0)
- color = "#8b949e" if risk < 0.6 else "#f85149"
- edges.append(GraphEdge(
- from_=frm,
- to=to,
- label=f"risk: {risk:.2f}",
- color=color,
- dashes=risk > 0.6
- ))
-
- collusion_detector = CollusionDetector()
- collusion_patterns = collusion_detector.detect_collusion_patterns(inputs)
- for pat in collusion_patterns:
- if pat.risk_score > 0.6:
- for i in range(len(pat.agents) - 1):
- edges.append(GraphEdge(
- from_=pat.agents[i],
- to=pat.agents[i+1],
- label="collusion",
- color="#f85149",
- dashes=True,
- width=2
- ))
-
- avg_fleet_risk = sum(r["result"].risk_score for r in agent_results) / len(agent_results) if agent_results else 0.0
-
- return {
- "agent_results": agent_results,
- "collusion_patterns": collusion_patterns,
- "fleet_risk_score": avg_fleet_risk,
- "fleet_risk_level": self._risk_level(avg_fleet_risk).value,
- "timeline": all_timeline,
- "trace_claims": all_trace_claims,
- "graph_nodes": nodes,
- "graph_edges": edges
- }
-
- def enforce_escalate(self, agent_id: str, claim_id: str) -> dict:
- """Escalate: create ticket, notify supervisor."""
- enforcement_logs[claim_id] = {
- "action": "ESCALATE",
- "details": {
- "ticket_created": f"INC-{datetime.now().strftime('%Y%m%d%H%M%S')}",
- "supervisor_notified": True,
- "trace_claim_attached": claim_id,
- "timestamp": datetime.now().isoformat()
- }
- }
- return {
- "status": "escalated",
- "agent": agent_id,
- "action": "ESCALATE",
- "ticket_id": f"INC-{datetime.now().strftime('%Y%m%d%H%M%S')}",
- "supervisor_notified": True,
- "trace_claim": claim_id
- }
-
- def enforce_quarantine(self, agent_id: str, claim_id: str) -> dict:
- """Quarantine: isolate agent, disable tools."""
- record = QuarantineRecord(
- agent_id=agent_id,
- timestamp=datetime.now(),
- reason="Delegation escalation and tool drift detected",
- blocked_tools=["grant_permission", "delete_logs", "write_config"],
- trace_claim_id=claim_id,
- action=Action.QUARANTINE,
- status="active"
- )
- quarantine_store[agent_id] = record
- enforcement_logs[claim_id] = {
- "action": "QUARANTINE",
- "details": {
- "agent_status": "isolated",
- "tools_disabled": record.blocked_tools,
- "reason": record.reason,
- "timestamp": datetime.now().isoformat()
- }
- }
- return {
- "status": "quarantined",
- "agent": agent_id,
- "action": "QUARANTINE",
- "reason": record.reason,
- "blocked_tools": record.blocked_tools,
- "trace_claim": claim_id,
- "timestamp": record.timestamp.isoformat()
- }
-
- def enforce_block(self, agent_id: str, claim_id: str) -> dict:
- """Block: deny execution."""
- enforcement_logs[claim_id] = {
- "action": "BLOCK",
- "details": {
- "execution_denied": True,
- "claim_status": "BLOCKED",
- "policy_version": "v3", # simulate policy change
- "reason": "Delegation escalation detected",
- "timestamp": datetime.now().isoformat()
- }
- }
- return {
- "decision": "DENY",
- "reason": "Delegation escalation detected",
- "trace": claim_id,
- "agent": agent_id,
- "policy": "v3",
- "timestamp": datetime.now().isoformat()
- }
-
- 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
\ No newline at end of file
diff --git a/sentinel/src/signing.py b/sentinel/src/signing.py
deleted file mode 100644
index c91c66e..0000000
--- a/sentinel/src/signing.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Keyed signing for Sentinel incident reports.
-
-Incident reports are signed with HMAC-SHA256 using a secret loaded from the
-``SENTINEL_SIGNING_KEY`` environment variable. Signing and verification both
-FAIL CLOSED when the key is unset: ``sign_payload`` raises and ``verify_payload``
-returns ``False`` rather than emitting or accepting a value that merely looks
-signed.
-"""
-
-import hashlib
-import hmac
-import json
-import os
-from typing import Any, Dict
-
-SIGNING_KEY_ENV = "SENTINEL_SIGNING_KEY"
-
-
-class SigningKeyMissing(RuntimeError):
- """Raised when an incident must be signed but no signing key is configured."""
-
-
-def _signing_key() -> bytes:
- key = os.environ.get(SIGNING_KEY_ENV)
- if not key:
- raise SigningKeyMissing(
- f"{SIGNING_KEY_ENV} is not set. Refusing to emit an incident "
- "signature without a secret key (fail closed). Set "
- f"{SIGNING_KEY_ENV} to a high-entropy secret to enable signing."
- )
- return key.encode("utf-8")
-
-
-def _canonical_bytes(payload: Dict[str, Any]) -> bytes:
- return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
-
-
-def is_signing_configured() -> bool:
- """Return True if a signing key is configured."""
- return bool(os.environ.get(SIGNING_KEY_ENV))
-
-
-def sign_payload(payload: Dict[str, Any]) -> str:
- """Return a hex HMAC-SHA256 signature over the canonical JSON of *payload*.
-
- Raises ``SigningKeyMissing`` if no signing key is configured.
- """
- return hmac.new(_signing_key(), _canonical_bytes(payload), hashlib.sha256).hexdigest()
-
-
-def verify_payload(payload: Dict[str, Any], signature: str) -> bool:
- """Return True iff *signature* is a valid HMAC for *payload* under the key.
-
- Fails closed: returns False if no key is configured or *signature* is empty.
- Uses a constant-time comparison.
- """
- if not signature or not is_signing_configured():
- return False
- expected = sign_payload(payload)
- return hmac.compare_digest(expected, signature)
diff --git a/sentinel/src/trace_claim_generator.py b/sentinel/src/trace_claim_generator.py
deleted file mode 100644
index 3e87588..0000000
--- a/sentinel/src/trace_claim_generator.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import json
-import time
-import hashlib
-import base64
-from datetime import datetime
-from typing import Dict, Any
-from src.models import DetectionResult, TraceClaim, DetectionType
-
-import time
-import hashlib
-from datetime import datetime
-from src.models import DetectionResult, TraceClaim
-
-def generate_trace_claim(agent_id: str, detection: DetectionResult, decision: str = "ADMIT") -> TraceClaim:
- claim_id = f"sentinel-{int(time.time())}-{hashlib.md5(f'{agent_id}{detection.detection_type}'.encode()).hexdigest()[:8]}"
- claim_payload = {
- "eat_profile": "tag:agentrust.io,2026:trace-v0.1",
- "iat": int(time.time()),
- "subject": f"spiffe://sentinel.io/agent/{agent_id}",
- "claim_type": "anomaly_detection",
- "detection": {
- "type": detection.detection_type.value,
- "risk_score": detection.risk_score,
- "risk_level": detection.risk_level.value,
- "reason": detection.reason,
- "evidence": detection.evidence
- },
- "timestamp": detection.timestamp.isoformat(),
- "decision": decision
- }
- return TraceClaim(
- claim_id=claim_id,
- agent_id=agent_id,
- detection_type=detection.detection_type,
- risk_score=detection.risk_score,
- evidence=detection.evidence,
- timestamp=detection.timestamp,
- jwt=None,
- json_export=claim_payload,
- decision=decision
- )
-
-def export_trace_claim(claim: TraceClaim, format: str = "json") -> str:
- """Export the trace claim as JSON or JWT format."""
- if format == "json":
- return json.dumps(claim.json_export, indent=2)
- elif format == "jwt":
- return claim.jwt or "JWT not generated (set TRACE_PRIVATE_KEY_PEM)"
- else:
- raise ValueError(f"Unsupported export format: {format}")
\ No newline at end of file
diff --git a/sentinel/src/trace_verification.py b/sentinel/src/trace_verification.py
deleted file mode 100644
index 79937e7..0000000
--- a/sentinel/src/trace_verification.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""Verification gate for incoming TRACE claims.
-
-Sentinel must not score or enforce on trace data it has not authenticated.
-This module verifies the Ed25519 signature on a trace against a *configured
-trusted key* (never the key embedded in the record itself) before the trace is
-allowed downstream.
-
-Behaviour (fail closed):
-
-* The trusted public key is taken from ``TRACE_TRUSTED_JWK`` (a JWK JSON object).
-* If the ``agentrust_trace`` package is importable, its ``verify_record`` is
- used; otherwise a minimal Ed25519 check over the canonical JSON is performed.
- Both verify against the configured trusted key, not ``record["cnf"]["jwk"]``.
-* Unsigned traces, traces that fail verification, or traces presented with no
- trusted key configured are REJECTED.
-* The only way to bypass verification is to set ``SENTINEL_ALLOW_UNVERIFIED=1``,
- which logs a loud warning on every use.
-"""
-
-import base64
-import json
-import logging
-import os
-from typing import Any, Dict, Optional
-
-TRUSTED_JWK_ENV = "TRACE_TRUSTED_JWK"
-ALLOW_UNVERIFIED_ENV = "SENTINEL_ALLOW_UNVERIFIED"
-
-logger = logging.getLogger("sentinel.trace_verification")
-
-
-class TraceVerificationError(ValueError):
- """Raised when a trace cannot be verified and must be rejected."""
-
-
-def _allow_unverified() -> bool:
- return os.environ.get(ALLOW_UNVERIFIED_ENV, "").strip() in ("1", "true", "True", "yes")
-
-
-def _load_trusted_jwk() -> Optional[Dict[str, Any]]:
- raw = os.environ.get(TRUSTED_JWK_ENV)
- if not raw:
- return None
- try:
- jwk = json.loads(raw)
- except (json.JSONDecodeError, TypeError) as exc:
- raise TraceVerificationError(
- f"{TRUSTED_JWK_ENV} is set but is not valid JSON: {exc}"
- )
- if not isinstance(jwk, dict) or not jwk.get("x"):
- raise TraceVerificationError(f"{TRUSTED_JWK_ENV} is not a valid OKP/Ed25519 JWK")
- return jwk
-
-
-def _canonical_bytes(record: Dict[str, Any]) -> bytes:
- return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
-
-
-def _b64url_decode(value: str) -> bytes:
- pad = (-len(value)) % 4
- return base64.urlsafe_b64decode(value + "=" * pad)
-
-
-def _minimal_verify(record: Dict[str, Any], jwk: Dict[str, Any]) -> None:
- """Minimal Ed25519 verification against an explicit trusted JWK.
-
- Raises TraceVerificationError on any failure (no signature, bad key, bad sig).
- """
- from cryptography.exceptions import InvalidSignature
- from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-
- sig_b64 = record.get("signature")
- if not sig_b64:
- raise TraceVerificationError("trace has no 'signature' field")
- try:
- pub = Ed25519PublicKey.from_public_bytes(_b64url_decode(jwk["x"]))
- sig = _b64url_decode(sig_b64)
- except Exception as exc: # malformed key or signature encoding
- raise TraceVerificationError(f"could not decode trusted key or signature: {exc}")
- body = _canonical_bytes({k: v for k, v in record.items() if k != "signature"})
- try:
- pub.verify(sig, body)
- except InvalidSignature:
- raise TraceVerificationError("trace signature does not verify against trusted key")
-
-
-def verify_trace(record: Dict[str, Any]) -> None:
- """Verify *record* against the configured trusted key, or raise.
-
- On success returns None. On any failure (no trusted key, unsigned trace,
- bad signature) raises ``TraceVerificationError`` -- unless the explicit
- ``SENTINEL_ALLOW_UNVERIFIED=1`` opt-out is set, in which case a loud warning
- is logged and verification is skipped.
- """
- if _allow_unverified():
- logger.warning(
- "%s is set: SKIPPING trace verification. Traces are being scored and "
- "enforced WITHOUT authentication. Do NOT use this in production.",
- ALLOW_UNVERIFIED_ENV,
- )
- return
-
- if not isinstance(record, dict):
- raise TraceVerificationError("trace must be a JSON object to be verified")
-
- trusted_jwk = _load_trusted_jwk()
- if trusted_jwk is None:
- raise TraceVerificationError(
- f"No trusted key configured. Set {TRUSTED_JWK_ENV} to the trusted "
- f"Ed25519 public JWK, or set {ALLOW_UNVERIFIED_ENV}=1 to bypass "
- "verification (not recommended)."
- )
-
- try:
- from agentrust_trace import verify_record as _verify_record
- except ImportError:
- _verify_record = None
-
- if _verify_record is not None:
- try:
- # Verify against the CONFIGURED trusted key, never record["cnf"]["jwk"].
- _verify_record(record, trusted_jwk)
- return
- except Exception as exc:
- raise TraceVerificationError(f"trace verification failed: {exc}")
-
- _minimal_verify(record, trusted_jwk)
diff --git a/sentinel/tests/__init__.py b/sentinel/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/sentinel/tests/__pycache__/__init__.cpython-313.pyc b/sentinel/tests/__pycache__/__init__.cpython-313.pyc
deleted file mode 100644
index abd7300..0000000
Binary files a/sentinel/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ
diff --git a/sentinel/tests/__pycache__/test_detectors.cpython-313-pytest-8.3.4.pyc b/sentinel/tests/__pycache__/test_detectors.cpython-313-pytest-8.3.4.pyc
deleted file mode 100644
index 3849187..0000000
Binary files a/sentinel/tests/__pycache__/test_detectors.cpython-313-pytest-8.3.4.pyc and /dev/null differ
diff --git a/sentinel/tests/test_detectors.py b/sentinel/tests/test_detectors.py
deleted file mode 100644
index e69de29..0000000
diff --git a/sentinel/tests/test_integration.py b/sentinel/tests/test_integration.py
deleted file mode 100644
index e69de29..0000000
diff --git a/sentinel/tests/test_security.py b/sentinel/tests/test_security.py
deleted file mode 100644
index 20928e6..0000000
--- a/sentinel/tests/test_security.py
+++ /dev/null
@@ -1,269 +0,0 @@
-"""Security tests for Sentinel hardening.
-
-Covers:
-1. Keyed incident signatures (HMAC-SHA256) verify; tampering or a wrong key
- fails; signing with no key set fails closed.
-2. The trace verification gate: unsigned / invalid traces are rejected by
- ingest (not scored); a properly-signed trace is accepted.
-3. The dashboard render path HTML-escapes a