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}
` : ''}
- - - - - - + + + + + +
- +
`; } @@ -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
- +
`; 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