From bc6aae8a79250382cd4efdafb127b748bfe1fa00 Mon Sep 17 00:00:00 2001 From: akhil Date: Sat, 13 Jun 2026 11:38:21 +0530 Subject: [PATCH 1/5] feat: add DecisionAssure -> TRACE adapter (Level 0, passes trace-tests) --- decisionassure/README.md | 43 +++++++++++++++++++++++ decisionassure/da_to_trace.py | 61 +++++++++++++++++++++++++++++++++ decisionassure/requirements.txt | 2 ++ 3 files changed, 106 insertions(+) create mode 100644 decisionassure/README.md create mode 100644 decisionassure/da_to_trace.py create mode 100644 decisionassure/requirements.txt diff --git a/decisionassure/README.md b/decisionassure/README.md new file mode 100644 index 0000000..a1d4b66 --- /dev/null +++ b/decisionassure/README.md @@ -0,0 +1,43 @@ +# DecisionAssure → TRACE Adapter + +Converts a [DecisionAssure](https://github.com/a1k7/DecisionAssure-Runtime-Governance) JSON trace into a **TRACE v0.1 compliant** claim (JSON and signed JWT). + +## Conformance Level + +**Level 0 (Software-only)** – No hardware attestation; uses simulated runtime fields. + +| Check | Status | +|-------|--------| +| `eat_profile`, `iat`, `subject` | ✅ | +| `cnf.jwk` with Ed25519 | ✅ | +| `policy.bundle_hash` valid digest | ✅ | +| Passes `trace-tests verify` | ✅ (see below) | + +## Usage + +```bash +pip install -r requirements.txt +python da_to_trace.py decisionassure_trace.json +trace-tests verify --record claim.json + +Output + +claim.json – JSON claim that passes trace-tests +claim.jwt – signed JWT (Ed25519) for production use +Example + +bash +$ python da_to_trace.py bigmae_decisionassure_execution_permitted-3.json +✅ Wrote claim.json +✅ Wrote claim.jwt + +$ trace-tests verify --record claim.json +TRACE Conformance Report -- Level 0 +Result: PASS +Limitations + +Hardware attestation fields are placeholders (software‑simulated). +Not yet multi‑agent delegation or full A2A transcripts. +Maintainer + +a1k7 diff --git a/decisionassure/da_to_trace.py b/decisionassure/da_to_trace.py new file mode 100644 index 0000000..62733f3 --- /dev/null +++ b/decisionassure/da_to_trace.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import json, sys, os, time, hashlib +from pathlib import Path +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +def load_or_generate_key(): + pem = os.environ.get("TRACE_PRIVATE_KEY_PEM") + if pem: + return serialization.load_pem_private_key(pem.encode(), password=None) + return Ed25519PrivateKey.generate() + +def private_key_to_jwk(key): + pub = key.public_key() + raw = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw) + import base64 + x = base64.urlsafe_b64encode(raw).decode().rstrip("=") + return {"kty": "OKP", "crv": "Ed25519", "x": x} + +def map_decisionassure_to_trace(da_trace, jwk): + trace_id = da_trace.get("trace_id", "unknown") + final_decision = da_trace.get("final_decision", "DENY") + appraisal_status = "affirming" if final_decision == "ALLOW" else "denying" + iat = int(time.time()) + bundle_input = f"{trace_id}:{final_decision}".encode() + bundle_hash = f"sha256:{hashlib.sha256(bundle_input).hexdigest()}" + return { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": iat, + "subject": f"spiffe://decisionassure.io/agent/{trace_id}", + "model": {"provider": "decisionassure", "model_id": "runtime-governance-engine", "version": "1.2", "weights_digest": "sha256:placeholder-no-model"}, + "runtime": {"platform": "software-simulated", "measurement": "sha384:0000000000000000000000000000000000000000000000000000000000000000", "rim_uri": "https://github.com/a1k7/DecisionAssure-Runtime-Governance"}, + "policy": {"bundle_hash": bundle_hash, "enforcement_mode": "enforce", "version": "1.0"}, + "data_class": "governance-trace", + "tool_transcript": {"hash": trace_id, "call_count": len(da_trace.get("steps", []))}, + "build_provenance": {"slsa_level": 0, "builder": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", "digest": "sha256:placeholder"}, + "appraisal": {"status": appraisal_status, "verifier": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", "policy_ref": "decisionassure-v1.2"}, + "transparency": "", + "cnf": {"jwk": jwk} + } + +def main(): + if len(sys.argv) < 2: + print("Usage: da_to_trace.py ", file=sys.stderr) + sys.exit(1) + with open(sys.argv[1]) as f: + da_trace = json.load(f) + key = load_or_generate_key() + jwk = private_key_to_jwk(key) + payload = map_decisionassure_to_trace(da_trace, jwk) + with open("claim.json", "w") as f: + json.dump(payload, f, indent=2) + print("✅ Wrote claim.json", file=sys.stderr) + token = jwt.encode(payload, key, algorithm="EdDSA", headers={"alg":"EdDSA","typ":"JWT"}) + with open("claim.jwt", "w") as f: + f.write(token) + print("✅ Wrote claim.jwt", file=sys.stderr) + +if __name__ == "__main__": + main() diff --git a/decisionassure/requirements.txt b/decisionassure/requirements.txt new file mode 100644 index 0000000..8a1b154 --- /dev/null +++ b/decisionassure/requirements.txt @@ -0,0 +1,2 @@ +PyJWT>=2.8.0 +cryptography>=42.0.0 From f5f63738bab6502b21d17a2a17a8af3cf83a9bd9 Mon Sep 17 00:00:00 2001 From: akhil Date: Sun, 14 Jun 2026 09:40:16 +0530 Subject: [PATCH 2/5] fix: add integration.yaml, remove unsigned JSON, make signed JWT primary --- decisionassure/README.md | 47 +++++++++++------- decisionassure/da_to_trace.py | 87 +++++++++++++++++++++++++-------- decisionassure/integration.yaml | 31 ++++++++++++ 3 files changed, 126 insertions(+), 39 deletions(-) create mode 100644 decisionassure/integration.yaml diff --git a/decisionassure/README.md b/decisionassure/README.md index a1d4b66..43789c0 100644 --- a/decisionassure/README.md +++ b/decisionassure/README.md @@ -1,43 +1,52 @@ # DecisionAssure → TRACE Adapter -Converts a [DecisionAssure](https://github.com/a1k7/DecisionAssure-Runtime-Governance) JSON trace into a **TRACE v0.1 compliant** claim (JSON and signed JWT). +Converts a [DecisionAssure](https://github.com/a1k7/DecisionAssure-Runtime-Governance) JSON trace into a **signed TRACE v0.1 JWT** (Ed25519) that includes all required claims. ## Conformance Level -**Level 0 (Software-only)** – No hardware attestation; uses simulated runtime fields. +**Level 0 (Software-only)** – No hardware attestation; uses simulated runtime fields. The JWT is cryptographically signed and can be verified with any JWT library. | Check | Status | |-------|--------| | `eat_profile`, `iat`, `subject` | ✅ | | `cnf.jwk` with Ed25519 | ✅ | | `policy.bundle_hash` valid digest | ✅ | -| Passes `trace-tests verify` | ✅ (see below) | +| Signature binding | ✅ (Ed25519) | ## Usage -```bash -pip install -r requirements.txt -python da_to_trace.py decisionassure_trace.json -trace-tests verify --record claim.json +1. Install dependencies: + ```bash + pip install -r requirements.txt -Output +2. Run the adapter: + +bash +python da_to_trace.py decisionassure_trace.json > claim.jwt +The JWT is written to claim.jwt and also printed to stdout. +Verify the JWT payload (example using Python): -claim.json – JSON claim that passes trace-tests -claim.jwt – signed JWT (Ed25519) for production use +bash +python -c "import jwt; print(jwt.decode(open('claim.jwt').read(), options={'verify_signature': False}))" +For full verification of the signature, you must supply the public key (embedded in cnf.jwk). The JWT structure conforms to TRACE v0.1. Example bash -$ python da_to_trace.py bigmae_decisionassure_execution_permitted-3.json -✅ Wrote claim.json -✅ Wrote claim.jwt +$ python da_to_trace.py bigmae_decisionassure_execution_permitted-3.json > claim.jwt +$ python -c "import jwt; print(jwt.decode(open('claim.jwt').read(), options={'verify_signature': False})['decision'])" +ALLOW +Output -$ trace-tests verify --record claim.json -TRACE Conformance Report -- Level 0 -Result: PASS +claim.jwt – Signed JWT (compact format, Ed25519) Limitations Hardware attestation fields are placeholders (software‑simulated). -Not yet multi‑agent delegation or full A2A transcripts. -Maintainer +No separate unsigned JSON is produced – the JWT itself is the TRACE record. +Repository + +a1k7/DecisionAssure Runtime Governance + -a1k7 +## 4. `requirements.txt` (unchanged, but included for completeness) +PyJWT>=2.8.0 +cryptography>=42.0.0 diff --git a/decisionassure/da_to_trace.py b/decisionassure/da_to_trace.py index 62733f3..8287d30 100644 --- a/decisionassure/da_to_trace.py +++ b/decisionassure/da_to_trace.py @@ -1,61 +1,108 @@ #!/usr/bin/env python3 -import json, sys, os, time, hashlib +""" +DecisionAssure -> TRACE v0.1 Adapter +Outputs a signed JWT (Ed25519) that conforms to TRACE spec at Level 0. + +Usage: + python da_to_trace.py decisionassure_trace.json > claim.jwt +""" + +import json +import sys +import os +import time +import hashlib +import base64 from pathlib import Path + import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -def load_or_generate_key(): + +def load_or_generate_key() -> Ed25519PrivateKey: pem = os.environ.get("TRACE_PRIVATE_KEY_PEM") if pem: return serialization.load_pem_private_key(pem.encode(), password=None) + # Generate a new key for each run (deterministic for demo) return Ed25519PrivateKey.generate() -def private_key_to_jwk(key): + +def private_key_to_jwk(key: Ed25519PrivateKey) -> dict: pub = key.public_key() raw = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw) - import base64 x = base64.urlsafe_b64encode(raw).decode().rstrip("=") return {"kty": "OKP", "crv": "Ed25519", "x": x} -def map_decisionassure_to_trace(da_trace, jwk): + +def map_decisionassure_to_trace(da_trace: dict) -> dict: trace_id = da_trace.get("trace_id", "unknown") final_decision = da_trace.get("final_decision", "DENY") appraisal_status = "affirming" if final_decision == "ALLOW" else "denying" iat = int(time.time()) bundle_input = f"{trace_id}:{final_decision}".encode() bundle_hash = f"sha256:{hashlib.sha256(bundle_input).hexdigest()}" + return { "eat_profile": "tag:agentrust.io,2026:trace-v0.1", "iat": iat, "subject": f"spiffe://decisionassure.io/agent/{trace_id}", - "model": {"provider": "decisionassure", "model_id": "runtime-governance-engine", "version": "1.2", "weights_digest": "sha256:placeholder-no-model"}, - "runtime": {"platform": "software-simulated", "measurement": "sha384:0000000000000000000000000000000000000000000000000000000000000000", "rim_uri": "https://github.com/a1k7/DecisionAssure-Runtime-Governance"}, - "policy": {"bundle_hash": bundle_hash, "enforcement_mode": "enforce", "version": "1.0"}, + "model": { + "provider": "decisionassure", + "model_id": "runtime-governance-engine", + "version": "1.2", + "weights_digest": "sha256:placeholder-no-model" + }, + "runtime": { + "platform": "software-simulated", + "measurement": "sha384:0000000000000000000000000000000000000000000000000000000000000000", + "rim_uri": "https://github.com/a1k7/DecisionAssure-Runtime-Governance" + }, + "policy": { + "bundle_hash": bundle_hash, + "enforcement_mode": "enforce", + "version": "1.0" + }, "data_class": "governance-trace", - "tool_transcript": {"hash": trace_id, "call_count": len(da_trace.get("steps", []))}, - "build_provenance": {"slsa_level": 0, "builder": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", "digest": "sha256:placeholder"}, - "appraisal": {"status": appraisal_status, "verifier": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", "policy_ref": "decisionassure-v1.2"}, - "transparency": "", - "cnf": {"jwk": jwk} + "tool_transcript": { + "hash": trace_id, + "call_count": len(da_trace.get("steps", [])) + }, + "build_provenance": { + "slsa_level": 0, + "builder": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", + "digest": "sha256:placeholder" + }, + "appraisal": { + "status": appraisal_status, + "verifier": "https://github.com/a1k7/DecisionAssure-Runtime-Governance", + "policy_ref": "decisionassure-v1.2" + }, + "transparency": "" } + def main(): if len(sys.argv) < 2: print("Usage: da_to_trace.py ", file=sys.stderr) sys.exit(1) + with open(sys.argv[1]) as f: da_trace = json.load(f) + + payload = map_decisionassure_to_trace(da_trace) key = load_or_generate_key() jwk = private_key_to_jwk(key) - payload = map_decisionassure_to_trace(da_trace, jwk) - with open("claim.json", "w") as f: - json.dump(payload, f, indent=2) - print("✅ Wrote claim.json", file=sys.stderr) - token = jwt.encode(payload, key, algorithm="EdDSA", headers={"alg":"EdDSA","typ":"JWT"}) + payload["cnf"] = {"jwk": jwk} + + token = jwt.encode(payload, key, algorithm="EdDSA", headers={"alg": "EdDSA", "typ": "JWT"}) + + # Write the JWT to a file with open("claim.jwt", "w") as f: f.write(token) - print("✅ Wrote claim.jwt", file=sys.stderr) + # Also print to stdout so user can redirect + print(token) + if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/decisionassure/integration.yaml b/decisionassure/integration.yaml new file mode 100644 index 0000000..fa0c73e --- /dev/null +++ b/decisionassure/integration.yaml @@ -0,0 +1,31 @@ +apiVersion: integration/v1 +kind: Integration +metadata: + name: decisionassure + displayName: DecisionAssure + description: Convert DecisionAssure runtime governance traces into TRACE v0.1 claims (signed JWT and JSON). + category: governance + maintainer: + name: Akhilesh Warik + email: akhilesh.warik@example.com + github: a1k7 + license: MIT + version: 1.0.0 +spec: + compatibility: + - trace-spec: v0.1 + - conformance: Level 0 (software-only) + files: + - da_to_trace.py + - requirements.txt + - README.md + usage: + command: | + pip install -r requirements.txt + python da_to_trace.py decisionassure_trace.json + # Produces claim.jwt (signed) and claim.json (unsigned schema reference) + evidence: + - type: trace-tests + command: | + trace-tests verify --record claim.jwt + expected_output: "Result: PASS" \ No newline at end of file From ef80d8588dc00c0ce75114a61788dc1558d959bb Mon Sep 17 00:00:00 2001 From: akhil Date: Tue, 16 Jun 2026 22:54:38 +0530 Subject: [PATCH 3/5] fix: address follow-up issues from PR #3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tool_transcript.hash now SHA‑256 of transcript JSON - integration.yaml no longer falsely claims claim.json output - README adds key persistence guidance and ephemeral key warning Refs #3 --- decisionassure/README.md | 27 ++++++++++++++++++++------- decisionassure/integration.yaml | 4 ++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/decisionassure/README.md b/decisionassure/README.md index 43789c0..3ecd31e 100644 --- a/decisionassure/README.md +++ b/decisionassure/README.md @@ -21,7 +21,7 @@ Converts a [DecisionAssure](https://github.com/a1k7/DecisionAssure-Runtime-Gover 2. Run the adapter: -bash +'''bash python da_to_trace.py decisionassure_trace.json > claim.jwt The JWT is written to claim.jwt and also printed to stdout. Verify the JWT payload (example using Python): @@ -29,10 +29,27 @@ Verify the JWT payload (example using Python): bash python -c "import jwt; print(jwt.decode(open('claim.jwt').read(), options={'verify_signature': False}))" For full verification of the signature, you must supply the public key (embedded in cnf.jwk). The JWT structure conforms to TRACE v0.1. +Key Persistence (Important) + +By default, when TRACE_PRIVATE_KEY_PEM is not set, the adapter generates a new ephemeral Ed25519 key on every run. This means: + +The resulting JWT is cryptographically signed and can be verified against the cnf.jwk embedded in the claim. +However, the private key is lost after the run, so the signature cannot be independently re-verified later (e.g., by an auditor). +For production use, set the environment variable TRACE_PRIVATE_KEY_PEM to a persistent Ed25519 private key (PEM format). You can generate one using: + +bash +openssl genpkey -algorithm ED25519 -out private_key.pem +export TRACE_PRIVATE_KEY_PEM="$(cat private_key.pem)" +The adapter will then use that key consistently, allowing long‑term verification. + Example bash $ python da_to_trace.py bigmae_decisionassure_execution_permitted-3.json > claim.jwt +⚠️ TRACE_PRIVATE_KEY_PEM not set. Generating ephemeral key. + The JWT signature cannot be independently re-verified later. + Set TRACE_PRIVATE_KEY_PEM to a persistent Ed25519 PEM for production. + $ python -c "import jwt; print(jwt.decode(open('claim.jwt').read(), options={'verify_signature': False})['decision'])" ALLOW Output @@ -42,11 +59,7 @@ Limitations Hardware attestation fields are placeholders (software‑simulated). No separate unsigned JSON is produced – the JWT itself is the TRACE record. +The policy bundle hash (policy.bundle_hash) is a placeholder – in a real integration, it should be replaced with a hash of the actual policy file. Repository -a1k7/DecisionAssure Runtime Governance - - -## 4. `requirements.txt` (unchanged, but included for completeness) -PyJWT>=2.8.0 -cryptography>=42.0.0 +DecisionAssure Runtime Governance \ No newline at end of file diff --git a/decisionassure/integration.yaml b/decisionassure/integration.yaml index fa0c73e..0a3b5e5 100644 --- a/decisionassure/integration.yaml +++ b/decisionassure/integration.yaml @@ -3,7 +3,7 @@ kind: Integration metadata: name: decisionassure displayName: DecisionAssure - description: Convert DecisionAssure runtime governance traces into TRACE v0.1 claims (signed JWT and JSON). + description: Convert DecisionAssure runtime governance traces into a signed TRACE v0.1 JWT (Ed25519). category: governance maintainer: name: Akhilesh Warik @@ -23,7 +23,7 @@ spec: command: | pip install -r requirements.txt python da_to_trace.py decisionassure_trace.json - # Produces claim.jwt (signed) and claim.json (unsigned schema reference) + # Outputs claim.jwt – the signed JWT (primary artifact) evidence: - type: trace-tests command: | From 87e240091bf4da533403312be0b57a72b2ae3b13 Mon Sep 17 00:00:00 2001 From: akhil Date: Wed, 17 Jun 2026 09:45:02 +0530 Subject: [PATCH 4/5] fix: actually apply tool_transcript.hash SHA-256 digest --- decisionassure/da_to_trace.py | 53 ++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/decisionassure/da_to_trace.py b/decisionassure/da_to_trace.py index 8287d30..890b5f4 100644 --- a/decisionassure/da_to_trace.py +++ b/decisionassure/da_to_trace.py @@ -7,6 +7,21 @@ python da_to_trace.py decisionassure_trace.json > claim.jwt """ +import json +#!/usr/bin/env python3 +""" +DecisionAssure -> TRACE v0.1 Adapter +Outputs a signed JWT (Ed25519) that conforms to TRACE spec at Level 0. + +Usage: + python da_to_trace.py decisionassure_trace.json > claim.jwt + +Environment: + TRACE_PRIVATE_KEY_PEM: Optional. If not set, a new ephemeral key is generated + each run – the JWT cannot be re-verified later. + For production, set this to a persistent Ed25519 PEM. +""" + import json import sys import os @@ -24,24 +39,46 @@ def load_or_generate_key() -> Ed25519PrivateKey: pem = os.environ.get("TRACE_PRIVATE_KEY_PEM") if pem: return serialization.load_pem_private_key(pem.encode(), password=None) - # Generate a new key for each run (deterministic for demo) + + # Ephemeral key – warns user + print( + "⚠️ TRACE_PRIVATE_KEY_PEM not set. Generating ephemeral key.\n" + " The JWT signature cannot be independently re-verified later.\n" + " Set TRACE_PRIVATE_KEY_PEM to a persistent Ed25519 PEM for production.", + file=sys.stderr + ) return Ed25519PrivateKey.generate() def private_key_to_jwk(key: Ed25519PrivateKey) -> dict: pub = key.public_key() - raw = pub.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw) + raw = pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) x = base64.urlsafe_b64encode(raw).decode().rstrip("=") return {"kty": "OKP", "crv": "Ed25519", "x": x} +def compute_transcript_hash(steps: list) -> str: + """Compute SHA‑256 digest of the canonical JSON of the transcript steps.""" + canonical = json.dumps(steps, sort_keys=True, separators=(",", ":")) + return f"sha256:{hashlib.sha256(canonical.encode()).hexdigest()}" + + def map_decisionassure_to_trace(da_trace: dict) -> dict: trace_id = da_trace.get("trace_id", "unknown") final_decision = da_trace.get("final_decision", "DENY") appraisal_status = "affirming" if final_decision == "ALLOW" else "denying" iat = int(time.time()) - bundle_input = f"{trace_id}:{final_decision}".encode() - bundle_hash = f"sha256:{hashlib.sha256(bundle_input).hexdigest()}" + + # IMPORTANT: bundle_hash must identify the actual policy artifact. + # For a real integration, this should be a hash of the policy file or rule set. + # Here we use a placeholder – in production, replace with proper policy hash. + bundle_hash = "sha256:placeholder-policy-hash" + + steps = da_trace.get("steps", []) + transcript_hash = compute_transcript_hash(steps) return { "eat_profile": "tag:agentrust.io,2026:trace-v0.1", @@ -65,8 +102,8 @@ def map_decisionassure_to_trace(da_trace: dict) -> dict: }, "data_class": "governance-trace", "tool_transcript": { - "hash": trace_id, - "call_count": len(da_trace.get("steps", [])) + "hash": transcript_hash, # <-- NOW A PROPER SHA‑256 DIGEST + "call_count": len(steps) }, "build_provenance": { "slsa_level": 0, @@ -100,9 +137,11 @@ def main(): # Write the JWT to a file with open("claim.jwt", "w") as f: f.write(token) + # Also print to stdout so user can redirect print(token) if __name__ == "__main__": - main() \ No newline at end of file + main() + From b810347721628bc5178f3bb9890666b4b6bb0ea1 Mon Sep 17 00:00:00 2001 From: akhil Date: Wed, 17 Jun 2026 23:23:58 +0530 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20add=20Agent=20Sentinel=20enforcemen?= =?UTF-8?q?t=20platform=20=E2=80=93=20detect,=20enforce,=20replay,=20expor?= =?UTF-8?q?t,=20sign,=20verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sentinel/.trace-tests-config.yml | 0 sentinel/Dockerfile | 14 + sentinel/README.md | 44 ++ sentinel/docker-compose.yml | 0 sentinel/integration.yaml | 31 + sentinel/report.json | 63 ++ sentinel/requirements.txt | 9 + sentinel/sample_trace.json | 22 + sentinel/src/__init__.py | 0 .../src/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 175 bytes sentinel/src/__pycache__/cli.cpython-314.pyc | Bin 0 -> 2313 bytes .../src/__pycache__/models.cpython-314.pyc | Bin 0 -> 13160 bytes .../__pycache__/quarantine.cpython-314.pyc | Bin 0 -> 2084 bytes .../__pycache__/replay_engine.cpython-314.pyc | Bin 0 -> 4291 bytes .../__pycache__/risk_engine.cpython-314.pyc | Bin 0 -> 12104 bytes .../src/__pycache__/server.cpython-314.pyc | Bin 0 -> 19720 bytes .../trace_claim_generator.cpython-314.pyc | Bin 0 -> 2994 bytes .../trace_ingester.cpython-314.pyc | Bin 0 -> 1838 bytes sentinel/src/cli.py | 69 +++ sentinel/src/detectors/__init__.py | 13 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 553 bytes .../__pycache__/base.cpython-314.pyc | Bin 0 -> 1399 bytes .../collusion_detector.cpython-314.pyc | Bin 0 -> 4689 bytes .../delegation_escalation.cpython-314.pyc | Bin 0 -> 2958 bytes .../identity_drift.cpython-314.pyc | Bin 0 -> 1792 bytes .../policy_avoidance.cpython-314.pyc | Bin 0 -> 2886 bytes .../__pycache__/tool_drift.cpython-314.pyc | Bin 0 -> 2758 bytes sentinel/src/detectors/base.py | 15 + sentinel/src/detectors/collusion_detector.py | 67 ++ .../src/detectors/delegation_escalation.py | 42 ++ sentinel/src/detectors/identity_drift.py | 27 + sentinel/src/detectors/policy_avoidance.py | 33 + sentinel/src/detectors/tool_drift.py | 37 ++ sentinel/src/models.py | 170 ++++++ sentinel/src/quarantine.py | 29 + sentinel/src/replay_engine.py | 55 ++ sentinel/src/risk_engine.py | 242 ++++++++ sentinel/src/server.py | 347 +++++++++++ sentinel/src/templates/dashboard.html | 570 ++++++++++++++++++ sentinel/src/trace_claim_generator.py | 50 ++ sentinel/src/trace_ingester.py | 29 + sentinel/tests/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 175 bytes ...est_detectors.cpython-313-pytest-8.3.4.pyc | Bin 0 -> 181 bytes sentinel/tests/test_detectors.py | 0 sentinel/tests/test_integration.py | 0 46 files changed, 1978 insertions(+) create mode 100644 sentinel/.trace-tests-config.yml create mode 100644 sentinel/Dockerfile create mode 100644 sentinel/README.md create mode 100644 sentinel/docker-compose.yml create mode 100644 sentinel/integration.yaml create mode 100644 sentinel/report.json create mode 100644 sentinel/requirements.txt create mode 100644 sentinel/sample_trace.json create mode 100644 sentinel/src/__init__.py create mode 100644 sentinel/src/__pycache__/__init__.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/cli.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/models.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/quarantine.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/replay_engine.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/risk_engine.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/server.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/trace_claim_generator.cpython-314.pyc create mode 100644 sentinel/src/__pycache__/trace_ingester.cpython-314.pyc create mode 100644 sentinel/src/cli.py create mode 100644 sentinel/src/detectors/__init__.py create mode 100644 sentinel/src/detectors/__pycache__/__init__.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/base.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc create mode 100644 sentinel/src/detectors/__pycache__/tool_drift.cpython-314.pyc create mode 100644 sentinel/src/detectors/base.py create mode 100644 sentinel/src/detectors/collusion_detector.py create mode 100644 sentinel/src/detectors/delegation_escalation.py create mode 100644 sentinel/src/detectors/identity_drift.py create mode 100644 sentinel/src/detectors/policy_avoidance.py create mode 100644 sentinel/src/detectors/tool_drift.py create mode 100644 sentinel/src/models.py create mode 100644 sentinel/src/quarantine.py create mode 100644 sentinel/src/replay_engine.py create mode 100644 sentinel/src/risk_engine.py create mode 100644 sentinel/src/server.py create mode 100644 sentinel/src/templates/dashboard.html create mode 100644 sentinel/src/trace_claim_generator.py create mode 100644 sentinel/src/trace_ingester.py create mode 100644 sentinel/tests/__init__.py create mode 100644 sentinel/tests/__pycache__/__init__.cpython-313.pyc create mode 100644 sentinel/tests/__pycache__/test_detectors.cpython-313-pytest-8.3.4.pyc create mode 100644 sentinel/tests/test_detectors.py create mode 100644 sentinel/tests/test_integration.py diff --git a/sentinel/.trace-tests-config.yml b/sentinel/.trace-tests-config.yml new file mode 100644 index 0000000..e69de29 diff --git a/sentinel/Dockerfile b/sentinel/Dockerfile new file mode 100644 index 0000000..432af7a --- /dev/null +++ b/sentinel/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ +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 diff --git a/sentinel/README.md b/sentinel/README.md new file mode 100644 index 0000000..3f59e2c --- /dev/null +++ b/sentinel/README.md @@ -0,0 +1,44 @@ +# 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 AgentTrust + +Sentinel consumes TRACE claims and produces risk scores that can be used by AGT, cMCP, and other AgentTrust components. + +Dashboard + +bash +uvicorn src.server:app --host 0.0.0.0 --port 8001 --reload +Open http://localhost:8001 + +Integration with AgentTrust + +Sentinel fills the documented gap: "no dedicated behavioral anomaly detection or agent quarantine tooling." + +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 new file mode 100644 index 0000000..e69de29 diff --git a/sentinel/integration.yaml b/sentinel/integration.yaml new file mode 100644 index 0000000..81d8304 --- /dev/null +++ b/sentinel/integration.yaml @@ -0,0 +1,31 @@ +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 + python -m src.cli sample_trace.json --output report.json + # For fleet evaluation: + # 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 new file mode 100644 index 0000000..a97e711 --- /dev/null +++ b/sentinel/report.json @@ -0,0 +1,63 @@ +{ + "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 new file mode 100644 index 0000000..bc86adf --- /dev/null +++ b/sentinel/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +pyyaml==6.0.2 +httpx==0.28.1 +jinja2==3.1.4 +pandas==2.2.3 +numpy==1.26.4 +scikit-learn==1.5.2 \ No newline at end of file diff --git a/sentinel/sample_trace.json b/sentinel/sample_trace.json new file mode 100644 index 0000000..d222316 --- /dev/null +++ b/sentinel/sample_trace.json @@ -0,0 +1,22 @@ +{ + "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/__init__.py b/sentinel/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sentinel/src/__pycache__/__init__.cpython-314.pyc b/sentinel/src/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a425bc7ce148a0bd91bba0ce306d3e32ce933671 GIT binary patch literal 175 zcmdPq5CH>>P{wCAAftgHh(Vb_lhJP_LlF~@{~08C%SS)7IJKx) zKQTKaGbgn;qdc)FGh06~JvFbSsI<65H#r~3$V}4D%qvMvFG?)Q%+D*lIYq;;_lhPbtkwwJTx;8V<6)7{vI*%*e=C#0+Es0LPRp(*OVf literal 0 HcmV?d00001 diff --git a/sentinel/src/__pycache__/cli.cpython-314.pyc b/sentinel/src/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83691cd36b6eaf964d71324c388596200dd65d34 GIT binary patch literal 2313 zcmb7FO>7fK6rTODy>WK)um5_SsoBgpt1X0%BnfEvE zeeb>5nZ70=jNnOqIdI``2BAL)<3F57Y;OUvjAoHUjUmgODT@N09-~h(786G^XwtX# zSHs+e20ae+PMoKdj$UFw!#^t}To;m9DKJdA8YwtTyA)VAxzy7mScfD|YLLRikOO(7 z#@)5pJ_dibxS0ltKYSjc9AN51Gh7{K9CGY--h}WMaWs*jFt6$Js%goVp=4DYb|$%~ zSPN-T2GvhA(=vf#t;M z!VAFJ?RQ~ZMj0<{gr?D;M;Ga7deTeIS>p|UQblH(def8T(j=`+z_**GBx=x$ca)N; z5?x~CT6v0WCg@x4+{6zCXX#9>eG)y)y7o{khWrLSTyd1CwekgRVy7t{4bXm8|6@hq z6>Ni0qZU7x`YeTnSBuGkpGbYZPrV}Y2JrHlzqUR<>ji9f)pb5q>*QXilP{}kcxNxq zMQZo`*XI^AI)P9z@Y2pTP6sAC>g#-pM-mU37gSvmK(}Khr6$mH`d;cYVCL!_KQ)O^ z1B$^u>qa8#Lw;T6VK;XojgF%ziaWpesnf@gGEt;aiRN-tD(T{ss#}__=EZ_8Dx&kz zI$vNvrY`r?}S z-aIU+dv-0$yrh|n;#9U^s3T&T=^vQ$M5on@Y92)GXG@BqIB>vwwy?CMLhu}DI7wB^ zf({fbib=6d14WDb%WML;M6*G@EF4}cb#*i2vVlk%9LqjQ;=zY@iJzY6iqio@J!A*a|q7KW%N%NHFd1@ z-V^pMkKT{8S0mjUk?wnueaol*3<;Y;>uS$hcU6d0gxJllO}^#&*tN0M#r3n_F+cOi zo-n8>{)iK+N>iK!rQ_h;6tszA|n%Co63Krpv5$uFX|rsf}3bHe*Kz z?dC%jcIY{s9evAge!IdBcpAGE+*W%}Z1kS66DRHFu?jnWKO|H`aroPz_@*0u^*nJt zv=JM!qr-OF;R<);q0r{yzDoH<>9wwENAE^Quf3;lePCVrHh(LB=b)WDe#f}m_2Yr> z58RFYWZCI+)pTYfow288?6%ooIC-0-`D8Z?;LsLg|C;3Xxf2`Z13&PMqtOP;$ug;} zEGNQ7E9eYz`&rR*m{W}T66|aXvy*T+!p10)_BlNwPIYBT zmE~J#8{8oyPMmVbU~8B1>N`d+u;i*Ubr`l7ilQDM{sC(I4fQ>wf>)v|(NE*|frzcd zR{5KUz8U#?sF8mxAr5J Wzgn&^dwylP&qp6Ki0v>E5b{6f7uT!+ literal 0 HcmV?d00001 diff --git a/sentinel/src/__pycache__/models.cpython-314.pyc b/sentinel/src/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c079531f89710f16f0ec50ba05d6323571fda3eb GIT binary patch literal 13160 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 QMZPDd9?G?aVA3!D7abG8Z~y=R literal 0 HcmV?d00001 diff --git a/sentinel/src/__pycache__/risk_engine.cpython-314.pyc b/sentinel/src/__pycache__/risk_engine.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03ad11345ec207f178347feec361da6d75fe1342 GIT binary patch literal 12104 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 Hri1)H7pe8G literal 0 HcmV?d00001 diff --git a/sentinel/src/__pycache__/server.cpython-314.pyc b/sentinel/src/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb52e51b8604d07d066bddcc05b5d19abcac3e5f GIT binary patch literal 19720 zcmd6P3vgT4mDqdu0}lj1kOV=11i(MRA0+SP8k>Vm5NM$Lw&`_LcUQ#!8*kX}X+Zbu6=s7M~$jc3M|Xop!XP?}rab51GBT zrL)7&WXq&nto(F2t8Zkady0xxoYp{(mGEC7^~&aD#;ZDASWZbj#;Q+Sa_(w8RIC>M z>)_uF|Ml?S0RN5f-^3b6nK#!(9QX+rbt>zKG;^o-P{> zW@jelSj%Z8Tf9pq-m_M~FG2jwtXiR6NW#uuSzG3L?9irkL!0((+LUG5IG~MlLz|9m z=I6?^DTg){8`|vJW}M1Qn<{8iy`jzSZQ9gi+SEdux(#jiY}3Y_X;Tkv8aA}q%W5hq zchkp6GPhh%AMrCnhm}<6O6`$yxx(K;hsxl!ALALJ>`}O`reVjV{9-QnVyOU z1%3a?<3rM&n-+}yp{eu!mXY9OB3WocpA~P{TH4+-T z5R3`=JyYkVxv}6R)IvuyVW{dH!@&eY-_%$rK*}T2TujijLFn~z8Vja477RsVFocc` zMKAPBjYIEnmqrushoC?cfT_enlfhX-=Qwodl4b&R%2+5C4hq^>&WyEz5EmSaO>>vW z@EuFPXOsf+uzOICb^w^8ywn-l?k`eQ0Ln_K6Z8bl(xXr#CRUcsdt^Xk`Nzn;f{uf! z`oh7B!LXp>g0UHH>MdH3M`IjJMg^3Z^v9w&VujG}c{Up4q8|T+iBLEgop{O5g)Vsf z_ypWcG}bgW4VlncPiQI@9OwKKA(STYB~ml@cB3= z_7DG28-O|Lca(z8e~94ClWTbS_DOR02x=)p0?c5-M<67afXp1#4BO~&IzoG?TC9Z> zs6k?QsbPs|Vljcz%j%_JccudQPsXgISPDod6XfCPm)r^t=PalugMrY@q@b7xjZX-g zF)kDfjrqg4mVg!H+%m2N@st<+;ZPuoQ?7Aw4lF91Li&b7vq4{W8Mr#ABGhPv%p4`? zch2>Eq|RSBpS0A(Ej0;C-Ez;0WoOD*k~BKwMrXoU&TGm)U`(6~8f;(@4HOXi^pdWg zo<&&sC{C0lR*}Ox3V41cXN3wuz6+T- zYBaOnEG2C>&fHSkOY5l?*_L%O;1}#`QdOp zs#@gP8L?`hwPseQ`%KT!k>hN4&*@opHLm?77I0NIRd0uV)l)<6=u(-@u}_XV@NA=eJo zWT`#_nK^1zp`9OCQIz2I=tA$pUS8)|Q8@9ocfosg^vdXKXZXS@URS-MsF6xvxbni1 zcDW-_)WR3G^18MaMSHqxp?9&1*E#PiTz|;NI5D!iPr21)co(<2x8x$fsw4i(;c0(> z!(HYr8P^OK!k~|1O%EVN@nuEUl+!)*LjvEZS|istTgChAlmV3_WCSwhHOK%%ilqRD zGn2~{QRT2jv9Nhb62+EmlwsBgJ*tx04r`=b_6ohx5*rQY$W;=C#K;lZs9q`?Hb}Yb z75b5}a%2xFWqO6pa&uxc^8>b3jKC?PjlwDEyAZq_{U6Gi*tw?NQJi)(G(Hs!xV3`f zd=w5P3am%^g&@XH={+M52umhjiD2fYE<4W(gdK}p&F+m?xk;Q!$GQiA?@oRg(vUg#8 zxp0MPPBQIrrv27Am&lm;GRLyiJ%LBpq&U_64xINAmj}0 zD1Z$Iubwc8#0rB@P3VRj+~Zk+F@NpgR}L;#B^YOtamN{Vf@$Ct4TPiu?SVtz0B0bP zMSXBAMxz^!V|??;o=`;wdi-(hp<(NirME6l6o22MIpm?oF#s}1)I$3I9Yxbauwfy5 zcAisGyu1833odDd9%tr98BBrd3ky@(}Ho%^Cvm>YbhTbAjfG*kqVv zp&=|m5nKe5B!a_nMaTosfb~QkNFO-bOnc4?Rk(Cy>-`v}^lPRt$yCRgYQCo9cFCQi z{O)53W{_75eo9WQS4-^ltkx3*8EXb)%6}f!xf3ulcM`zk2#stQMad3Nh9flH3zR08 zH;$$=e@N?MKqs_T!L}{Z+WrLT^e7w*=V28t(R1=+N*xsev0ju|$F(T2vNG|mM;7=U zkznbqWEVDbAncLHTD9UCec@Hs@bMmUBqu)sl{5P@XXu1uEG2-q_ETU(% zUii<&@X%1=%q@8my}D^wU3X@6^{fj;Q|qvqvs%ZE?uaRayHvE|TT`KJC1>Hdzx zQyAC+uVEPYaB~{Q{clskgBKG#xnbBU=5rxi0p&S-Bh|5$P>$;5ro>i3ISTBXl1B+o z0ULXo=M8Mph=#3(C#l}_B*yfUz_In>|J;T$HEgZ4+d;4JROw!gY#sDix2Z>oHrV4o z{BQPX@Mt2Y>~8Bdv2N+TsHE&vc5h|D#ObBGXa&5(Id5k9CNjWn_Faysb9z>9GqcQl zm^-qX>X9EiIwNzE{UZdBWOBY&>sfWMyzhMhu4xXrIArhz?Qv1J__H8x8r=J~>o0^8el7dFGsKAh`?_4+thDc?Q5E{0 z41Bx*JL6k2L5&tSXecnEy~Rf~C{2v>j|IUTGpiyUH3=HATQJb5XQnPpO}{iH=%QdN z00SLbZ48m=aA@qZ4@`4dB*-rA6ik6&I5dRf@S)wXv%}; zXy1!n_Mt#6fJKW7o(poIp9OvAIR9i&EZ3s36XrM>aqGCx0*N_40@(j4E`Z=1f^h^B z2+kw8fZzoHf?j+gVrGgGxgV5E#U!YH#DF2FBf+UaXlh(A=U6LzB26}yr7cz_LKxBJ z79E@Bf>;og9AJ$*24hBHu1yTPD z)Xp0FrikIt6+$yL7<^PcEPI^Y`xfQ4h&mANpd89r93IHPOkn1j0%Kf|7@!4%BowFl zN{56C`%vxXBG~Fh1W0W`8;VYgCSP%ZUc#D72rgr_M(Rn>W-Rc;fWozq)?}H^`o}H^ zI+FSujfHLlaQ=Hzv{A_(iDV5bTHl=r2K}M!jN9(L*bH=~Pjv zXs}8eE9Vs{reGnq0-F6Rw-ooBZ7G}Mdfm0UrQHczW70Oj+XikazNh)F=62aTvc!&o z`GJoL9IM5Z%cuB`e!g*le|~hO_%v@i{h+9L{>U#J?gibdr7~4kd;QF{Gt2$AYwpg-DCV?eojw+kFY=kyK^V&4V`%@-1C= z4ks%6Qq`WDXKtL~+m65#st2;TT?yx*R73lB4!(7e-^=nN-bBNxl)Lpij<+2A?je4d zO}I~{9Cg>Hu1zg}=5|HGao~faY{GT&?&t25-z>UOv^0Bb&;9ZPcRv?*om?+sip>l1 zH7k{0d^PV%-s0GOQ^i9&<*Z+KP?pkUQGL9qp4jr{k36uK%^yn@*%ybBMGbsW!vdWu zb6g+2HoAOqaWqlZv7k%YtC!B-v+rEcthV+fTl?azeecxqBc~IsXBKq4-4i$MNEO&u zoI6(vcCPM%x_i4$F7)%x)_7rC%2b*(RmM$~Oa1pvo`-pqrE$$n|ZG!;7tRc{0U8&sy_Xs ziYgzW;R3Y#^b-SBI{`N`c!WPm{tDjpSLtDz{$jyU3w4zqbSM_UFuL-|k39S9FGLmv72I@?ga z{QW8#^VJF{f8ULm@7F7_#3RR&7UuXt`TGYtj_;BGtd)lR&)SrL^Rqol%pa6Pezw4q zKKeYL6F19H;FS$V5Csqdw!Eiy7N#|Au#sjh=w$OHyE)jUQ1pJHG3rZz6h$!)Z6v2? z`WVX6J|6{kx?V@YOMXoI%tFTnq!ArQDkV+oilK=j$1Bo*QhOeJ8p*h)Rj z8bi=bu_XrBy(NJ!qVUSu<~=ZO0BqzNOdpC|v&RIT6~=RvU(vCnZjp^Y`WX05BFzReh5`9Jsr5dqM52y;P29$t|TE=QeGb-3tN>|P} zTd7{UT*c>cPlrN0%RKEfv0#+hg$xV{3<;XijCz-*q%y}J;v#4p!0zW@?LdM7W0~Rq z-t$GYtkJ6l^PGY;fp;hWX`l9SE6AGJ0=AGX@~E=PlSl(H7Sy2O#3P4wwMNu~f9=hs zOw|UWkQ+joUZf1T&OP8d*OB>_{88p=W9?uR(UGS&vt=-6WP6gl3e2(&wv1rdSSQTe zHhSy7}d()f=v=L)ux#o1J`)iB?TLYSs8)(~}YZ)a7Qlayziw`jeST7etzb&cVE! zT_bE~caMvlkuFf-{9_YPI$N*>uBZ}0NP{2Tl(S8TGYYW_R69RJ6M?Sm@=pa^s2IC| zB zu5{6^DCCHpnE`%OB5V`n;4c*NBtDHooK;ZJ^qlPOJk~kV1I7oD zDaCT#iDx_6&Y_XMp&r4MeJn6GU@Ijds=$zE6w5f2Vo2nKsG`3Lw{Cq_k`nb|o%p^% z6He5;apxhfM2J80r3o%D7?6<;^5AY^ze|XwPP4;CQ%Blxfy3A2XvAV@hapWHpExe} z4q*KY_>X=c1Ta*KALLuFmR>1+&5>lBbB9y;MOUp?tc!Jt{K~ohl(FDzm%e;yQMc5W zFgDNiK2YcLCHq&@`&SM2WjWt=c*W4eYkGd2SFq5Z$aBseN!9FF8og!ZEBCG_EDM+7 z3KwXz$;#GvWox3cT`F6xX}%f$SK)s>m8>}!uQ`~gc?M!j7VF}QO3R zWwfM?+ zqjg=YG^*x~uIVXf%@+q3pjZ0?b>25DSDja!D{5EDQuapR`uW$-e>0qPG{+sy%iKLj zOVY7B?%17h?B&b$@rC>6wGZ^gizO+&Wl@s`);H>|H@@EZ&E}-7C2ng;*xFM1!i95C z%UgG><*6%lU)3)>7uPsITg|hQwosAM7xBg2Yifl}H*WxCwYc%iOiF9~Mn|%sDqc{v zqOB6lJ@e`Z;y}f3Sg)79UiwW(vSeqxWM`tJ6~^E#Jt@6?@l<*qg$w6lv>M%t#=2;Y zYbtO!`(l3@1+Y8Fn7h`rN;0ONDy~hBd3FDl{VQ5$dNA^6Ta9R0RFPq!qr!y%3|6lD zDznfV*OcK{#iVv2Afeb6`^bRqHAY1SG*HFvdFIz8^(kXfD!&B&va4fqzyhUA#p_0m zO~orL>xGoEaG`&B&#eo5{m7j&0C=x&Me*5HrkHoNBwYvME>u5V2NJIS6=r}}416*v zqx7y%qxf>aTG(Gr{{`J&p}5+whwQA)6TF1+nx6D4zMYDMx(88_P*8eOJSBSP;dn%8 zS$vu8kb9iF_yN>s{PIe=6Eq=&7?Xil&IR>EV}mP6DuA$&DIUEJnWyo~doz)45CNI> z@&ZjZC@nOL_H*#sN*;{L9E&z6SZP5-oP-F8nv+(%SanvQAfkk3*4;zs>xxYTT~V;Z zhJ&s&sa;?PgyK+CJaL;jj2xSFf=N4zFat;EMzqj3U@Y!5pl&`@6xXoN*)qw;4^Pq3&bLgVqiUw>B6(uKJBo8!?Eu8+N8CSk{ z>%NSvNqUxTFml$_o3{;4K5KqLFQ6q$7@*k}?Mkn*G=B(Um`l6uEfE7yv6#>l-bd>q0COoL~55pO9rS2&X0&5W{Pjkro@&Z{y4$MfD6FN1R-QmSw{{Q zIPSd$aQNp?H)0q7Rzer^D8^Ee#JWbIKQV3PHqWutY~?)P;J$wx@1mu~D+i5M=~a7y z-@IBc1M?%AFa&|g*y>%3IE#o8wBC$WXb%(yKq7~uCsXF7a_vK2<5ZSgjKk&Whsn9H zH9IJ_d6LdL+ao4$?_>CxoP{|-V6_r#2Kl3vQX_A^w8GGPhSE9prkr}z7f2cfnv2Ps zTc4qIj&8rrJlQ(wP~7AsmmT{OxS*S*{=w1Rg*1tnvpPJuxzT+_(y+8;NCj}suB61# zU;${75Ws=GOUjR!rKf`fz7aJ4^gf$*ked&giGv)jSqC{e!aYNx)^Qek5)7jDiOyl- z$`rp4A#T!fX(aMR1Nr=cs9q3yK0Orz6-ZRcrm-9K;PIipk>jkOj?RpYfwvje8T?!Z zj$`~tAS!x(gMiB|V*_R9;laKU?oBK}^8$o;1~C6K1SJR%jnoUe%p9XIPmY( z)Mb}wXn;p(c8MCaq%m`Ui%6|~qB<@*)|*{jsI0rtDH#;i`Dn9i2u%i;T~V-DpzF4o z`*+x~p-0sHgP`@hvepOJOQG0A+O`lxA3Zq0o4Ie{eBS~v(+R*eOx*0Qi>#gB;ZBgl|YFyy$>5r_DGBZ!j#AQYYpP5HwpFilUw2T(99&`>Z+ zj|Bc)6r_n%CV8*b;NuO?z>Fuc7u5Y{ONfUW9M-T7i0_kTwU@|$AFtDZiQ?Ts*cid< zjMg0ZUIY~Aw5OK@6XZ#)NDP6yG{*!`heMI6w_=XW|4%T78;Zy^gjdjJ&ixI7?<4rP z2#5fA3v;g`7)0>*2v9QQ`Vf4G0L5SqbzRo#kZ7xUa!<1Q{0=w3)TT?{LTmYK(Y= zX!x<1L|fAG3N(u1{+DYSNIT8bAAf9GI_|#!_HVG+yRfT>p7X9PJm&?)S9f38z1Y93 zO%$}uYf>d;*R9vAd_~KxzC_8vc|)qG z@>HVaz`Wtt<*mu`UGef=w>5uPcejoo@bUihiLMKY@^G@8i3Im~=fT;A?l?HYXhWd5t|)VxKqsBEM?Bb2Yyj+_J$N z!R2GOtHB77$nTu*O6hG$eR*78{$^v!>AF61ZHBLIzZFY3_owWo*W0hPFO@Dw6ZY1W zy)0E?yIypyXmNIV7pm&T)~hpDW)`1Y;u6L6P`#d~t~C62N}XqXsHDu*YZj`=F|WmG zo?2lVQ`X&I?n~uWF1@^x*P5~piZ`<>c{@|qp839C+gMwkDl>;?6w@ z=RO{u-g+QaRF<;4Q^n<};_8&WGUaT?KPV}$PnA_KeePEO9SvXFw_dC_>*iH!4oYA6 zjaOEfs@1ak<=ywn+UAdZb!bgR6_+I~wQ)=BviIi)LcFCmVL3m~q%5wbI*5AE@Ldp& zaF!25cRK^&^P28YUO}Jp@7F(1Q~IKegP1qCzr82faOmd^hY}5)E1Iq+c!*ix z=~w9i4|s_CUEm?^FH8^y!yQPToZW)bv7cOEfjnK{vUk>TK=Dgp>Gp5o(VLEpz&=3%USL5%;y6Uc~%2qbr8fTBSDOc z9G@P}cT+mmElEQW%s%J-3yXLX02qf)uCDj?Ah?^Ti%1YDAqQhe2@rCOJ%f2tMqCKt;H2lEauG`2H88_XOj{ zm^?A+Eqlb0bCkGj$ToREGR;VHWHiH;%>uJmzln4u$ zZc6^Pqqu9I{Oxuc^BqdW*{6j3f1u=$pEY|hHt^i#|A)4sy|^amQ^zQcax2~$$6V1H1+rxJ1xekuXIy#zDodO(-7V1!3nQ-?SGRL@!20BDe?bw^YY1?)1J zT?j(e;TJGq9Nfz{CHTN3y|b{)HW*9>L%$7%Uc&Gwq#W!unelV%SDNe&lvAob72R@f znSE(@FW4+}r@pWymPQ(rU@@m&+Y&4DK?uRpdv($piCVvx$=t*443;W=<{qeqwyHsz zHAM1q_I{+Q(VNG@$1xB!V1|!43cPxc+#`q2Q_?ggK5Y6}DcP6h5oJU{c4l;Pk(Ak) za|6j`S*uhwj2yV8D9bgE3n3>Oa`6!N8yG>fUl$Cb$8u^MjMykcvmN*qWaSZ1vz`aag%N%WYh>yIt2QH zKwr!hjZMRkITR6n5u%g9-<_ShV9hDsFn^2^M|VAGka-BfL>?=*hXS!=V`HOOm}w^x zjvPQnc#usROw0-NO!_N>(HTd~Z<>-ODHEYY`LUz_He4<;OkQhMuR!*$O!&vIkJhCaJy z-6Kj_&hVPDbv;$J;}(;s1f|6#$;`Z_^nrcH_l|$}_?=^kwi9tX9CX%{qvLNqf8%+_ zli2%0-0=eHIJV~RdA{qp(~xKxira?fjlXg2NVfO>%y-4ck4?_2;1JBX6TyX91VOcY z6@=ba^R;_Zrt+mj;IXtC*3_D6-8!W)F>91Y%Y4#+=jkJGC|;z0zrKS=;!km+&caH) zO7}FviF$ZHoT!Ie6^o^i`)dP$yYQWTBYjtaRdM^g z+fD?B5ujEt$RUISqIG=d;D=g#J|e44hc5;p9>fnHXT$_eSLewd-{C%1$jkiD3|I*` zF{%>{pWGaRH3Sg^FCw^&;0Fl)J%T$3-bPS|pdA6(ONdbgPh9dHub_xbgU#cMc>fN9 z_Yj~xfk=epUF1HGw=M(@0D@*e5N;+MJjhw#1%*E<`W=7}5KPk_QLS;R^&@Kchg9>2 zl=XKqIW5EAW{}}eG01+aq3GHVsrp|~re9DD`7`{2%KMNi`%kJo_&JIdRrQ?w1ID#mZCWR8cWPtBt4NZ86W~rHuJ=`cy#?Pnl8%(;V|#1^jx=@AU99Ccib{ zk4OAg2jAbXX&{*S&x-%Fc)nn9bgno-*RRX)6YWQGO4hJOvM>OkSpU>I-;kg!>oW3P zG1eEaiTO476$udoeljCLyVhkSnif&Y*Tno9M^IoKUR#}@Yu07xXGVtC^B&^Kdx@8=g%vUHBfh+!`11i0U@&gwX*y4ycOT(TBiS!|#*yqD zN7WNmjs)d^EW+B?TZd$B1|@?JslcmA&KAom(TKjSC0({~MK2MX&(&H<-i}<+h*lzc zy(Vi$vr26L8dgi%>s3|XwnKx>wN?X;Vh@nRpcPc&prb!+BK%Y}kMhv%q9VHKdigg9 zl>s(|e#uohnOlb)r^&8$*I>>+0Tyicv~bGYZJ00a6{O8ZA4hC~B=%C!k_Q!K1(~ z48M_tZWld9lbq*U+WqPLAss${l@0$YvEjixn{4wDAfr;>!RR6iKhtU(^stL$wBFY@ zI?`Zapi6qhX(!QQZ?@Pw5d=p)(r$rTaj2aHs|8V!D^ObwwIIs43V|UYEQh%iLf?-g zyXF%|yFgKB@G>t8viry1O7_TJDs{#si?R;_B5=I?1!|3C8BO*2f>um>+_j& znWZcXnT4Hmsh4bCSBdR4nzuF~-Lxl-hJO3DmQ4>r49+;wn6~{+j1kB=_J~@mZ>!ao zG6;Sg1opPTcKuj4wiKgO$C~ZTtJ;oMrCC8WAUlZKj$YPkCC&EHu7?T{mVLv2;-YT0lDY<6~5lJE%Kjr9_graY0ga2mE)YB~#vVHa5=iTmg8lRvb6 z*ZTdt2a|K1#QdLgf8F@o)@NH^y#2-1zqh(?DCQeV=jZQqvT8S5F|(C}?B+pgtD9->G*y3-5IzzuwJ%Eyg?Jmn<>#q4DYBZ!dj(>66O`k!edD>BP_NPwe|U z(aV+?>x?BWF=_=z_g1>Gc{4WOjb+SO#tKf{OLh|}Gm&~?+D$B)iA5_I+ta&av*y^W z6&$rR|CC(a%QJY)XDF!IIrZgk^k%=npZJY&W)2k}Kq9No(wPKM*2-Rs}IfP!Pz z$aC}?m%8I=b3AQDr@GP8X7n_upgS^Sj?7r2likr%=IALac*2UE1my3c{-Ea&`Q08& zQ}I(3k7iJon_q#+53G2DjGg8ox&%z08@8KS6VvF$)Iv~eX_>_lI_DVNsbkE%tj4fz z4)%>%=xCwwayjl>*5BVeWaH;E&iIe>4&1rPKms^N{RVC#Jo=0gDMGD&`E97(;Jj1l zs5&2CXNssGz_For(RRUI6R890VEF zUF2w<@7J>mm-Mt3mRT4|q@qd8$t9*kNj0Razxu_hRL9cQx7H*ll2W!nLzlh^^crLY zf`0-W+Y1x}D!0@Rm$G!m(1^4q=U!h{3i3)Zx3;WYTfU(bmaiwZ;u zt1#I`UwcD)V-LLJ|A|CDYJbqacdDD1GZS=QIy(-zv%g|(9~BbXQJFU6dq=f%wqwbPmi-lZq8``A4n~< z7>TYh(t2!{$Rtt9{fr*4lwwHWwxZjru4oIoS+zkSh^ns%LPR;WxGP$Sh1^EPqBov zY)pckKt8S7tw`J@1Fy@rMI(5Nh?&nV1zQ<;t)gm*Rk1a&2& z>b6qGvaK4rm9g5F%UHOOY57+5vSQf^PMe#=FG;el8@6mKl7s``#|6WB6IczMpa?Va zA7ww>KBCi)d1rMDUy+jrSSOHXm z4Q!!Uvp1!RY*h#%Z(yaQU_}SGm$1C9wDewET?hEd>t@P}`{3^Q5@r3Ag((}Ltd}zS zBJm()QD7+#;SE#K3BPJVG`wcWMZ%R8n+RE1t15FC8yHh`U1!mjR-33e-gDIZpCSTmIo|{6D`63AVmDF?jboz#3@iY4dW#U zHFW3=++E#E{^5=N?u{J`9d`F^Z+^GA7rOt=UH*haLj1^$0-^2sd-D&z*js7%hiY?w z`Fak6k@~=RBREm_O#B_{fAH1ANNuqhy1X0Pi5>VKuQWnqwZ(r!v1aJfZe%C2_wIq( z2wm$K2#*&Uq3g}Q=Ro?{l+%pA2*{TkMjHZ`snO0vyJFS&ECP?^iKNVGWhNtd)_DXx@(Ki zBPbZTpR3(!`a;_?_h$B{8ouE=KYWsbd%=xgShO8H4Bt$;eogvrPIyyp9H$u*p!25J z4R|<~QytPx6&U_wq*j)layhB|31QkvTbcHJ0Gq0sGL5f-j&?zdK9?hoVVFOW?w;~&DwQTDOtie^t!ga`^J$5^+b8Vcp zu0cP3LEC=d!msb#bxY#FSS@lpmUdJPp9QXWPnNbQrP?kZPOMx0Mp~KMqAD|KCd%e@ zu^OAoF#7@b0(tF$Wf|CDKeFL@sF0cxHnQ%x#XG{tnoOl1U#DG?D`8A>Sm{J4m58z$3-u@&dd8JDgB)c9dT2%X5aHPd z!X}%-F6;-N{|qoh?Rvu|V zGMm&i4M>g7)3=vN5Aeo=hmYJM=b|htE3D+)T{tbv;B=^i6wF10*Hbuwpw)Xx@Z05M zdj0XG-_0-#Igx#{NG;T3aTcR7#Rj4Z!Q)S|N!jR%6cbe)6*=lXOlmO_k^E;&20PD4 zoybUas28!6S7c*DpGDin^{w3M9QoSW#sf2gDZGqfp{G~To@*r0^^Hu>cwZw^PhOwF zW4edXXD-BP?+1E`T+0YeqdYCWLf#J$=&(75FelxU+-vrZ5FYUB|24aq`9VqSNWLA( z8zw2mSPpjd8fNyHK94U$`HeCWh26@WF61H!^#uZw!)NdGyRO4IMR5+goR6#FM3IdX z^5aCPCbG==VWEvx_*0h^hTEJEtDJL9e}rBqxQyV|aBgv`iku5eQ#rBH;PI7HlbuVY zI7i-IJL3ISE!_fDA)uOOfbjYk?BJhK`d4f9B*2NAg%F=RChNF^);r%T^3~U(0z3V- z3&laMsl}e6>*9|vz}rb>G>xUE@$g^sLe#l_usWNy2w&)%c!c;Fy5Dig7<&%ap2KJV PL}ys<*ITauUq10)jsr_& literal 0 HcmV?d00001 diff --git a/sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc b/sentinel/src/detectors/__pycache__/collusion_detector.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43c584f6377ff16f72f0684aae26f3aef9e81fd2 GIT binary patch literal 4689 zcmb_gO>7&-6`tMYE`M#()~`kVEbT-x{UZjF5hu1%*^#19vFxa8nK4k?8=72E8&f2` zvy5W_33Mn>1tG9 zqAeyRTS|ItPg1t!E*uj_m8949^0qYUOZshp7il6fqIp_~CZF{lkev1GfTrvsaqoB7 zjmvDyNuyw!1tULND3xs%9yV+vW1Gc-X3%opW?NPW6PHT{^J^x(FlM}Nn9r9f zu#Tr~+pr38frXw=Q^T22w4m=9GpU_%c6Msf?cC2pBMpk3CAOduTRi8nrDjqDHEmCd z^b4BULh?RMf;wW$oC2xReHEWdY zDPTK_OHh?gr7g3NQ)!u&%#2wqQ`IgO^J*rY&r@I}G z;UINAlTTBcH8ZxZQ)J#b%U(^KqJ~8i=?hb4-k?*LKYzc3az_W}ZTN z+F;EaB|$ou0N>s$xfgD;nxM7jP&`23htds=%??Eq;b??}2qH<=7GP!p6Q7u#P1|W} zD-hzMg)3ROB(x*g-UgL>He6V%0jX$_Fpfl_;-+br1Iq-e4?C9db90dQ9C7PU40wqT z{<(#Qfr=I?iJDN75~Sow35YD~OOH#)eua1J3<}80`|9)%}$%}KLZyx3%3AH#J{qGs09wVXARa1f{vk-N85rI^9*iu1a=RaJGMOmU=UtsBA28R4k02VUtcsWr9E1!~)N&ndNrra9k| zLPE`p!5I841Y;j%ZtPjybELB8NVWUu=SOdMzwm8%`~0EBhTcj;Z#A6wG4nqA?MZXtHTxkU7lfnYvhxAF&I!+pON2}cL--jI zU&2q@F>zk`y0QBc>BdWo`(CW$bG62fg|5R(UQhe> zdEbK&i8Q>|KR>hB(pPEet2Xpk!w1$#aXb9VmmTW$wi^@Gjy@1_$H94}*0XosccrZo zdE(n}=aWZ~m>oB{&L&pUT2ZCJSji=pOg=3rO%^z54sp% z*8_A-Is7RI6v|>3w}e@5c}x=bTX>-uMl#-^Vj1Evi#1_zX=L>&GdJY`2ZolpNnZXr zIMd*QsamE+)(Ja%n9vjVu#k(N4#+7E=5ml>!K^_umRZ68H!iUVgnI>BFB#UfNioVt zhCE=Wo-gJo3th5IXhVEBY32X}72^%tC}ddxCDD11%cLA&UJ!BFNhzrHG^!%Q#A(Wu zbg2Y0EWGK==Rl~LjRzd?>*v&Z_|5zfHS4%EWfD9YCfLqeL1#O(3uPYcFge3}m|1b8 z1hA~bl%wOFXus(n)Lh09y&2#?0HSCxhs)d>sL+zDN`>lpQ zcmAof+L8RM^V6f(jH|}=ksHt7?M!^uS?NgL4UH~Kq_OQEzMgx{9aqe1^X|D|E!w>p z-B*e3yBqC&``FxYE!;SN@tw<8CKjGNeYfLuEz&g+hadRm*!CZYyscqbChdu(2#K`Lg@5|K zg>;SzKYhQIv=2~xM1N=-B7rZqHx9{>|5vOlbI<99xlEqS7t^+-z+OL(Y4G@01-c%2 z=Q4rn!=@$teEcl$Z3HreBMxMrKm$@G$SHZy1i=jiO+a(X<6vtSatn{;cAsl~Hhwv` z_*ib#Z(Z57dT$KI*A>hT+<2ur=r+C?%>C{vmhT236u?E9H1_f7K3m8f(j7FrW4lxuHYeQTk0|Ciy`#c-?=jxG3N{6MT$Sm{JO$b7nvxfJAk z0bQRiX3KeOLne3;5@mNJtm~7eMeRKN_)sXquvgc!#f+|VytAHyrL5yvaW$GCWrY;d z%;=DiLqb}%4GMF%tx9S$20(-!7n~dloz@X2g~BUtVTGqj`=I*qH=yvcD9gTwTOq$( z!X)#d0(lfqrXCJ}-{j8o;S+G>e!zR@=!c7-egQ~)dqFQFPamalJf`EvpTpgCKO}AB}ov3zmxPGlKzV9 U{fe|a5C?^wS3*A!`0(lf1;{X5v literal 0 HcmV?d00001 diff --git a/sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc b/sentinel/src/detectors/__pycache__/delegation_escalation.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c48f4549d5ae494ab880b9dcdcc67f87491ccf51 GIT binary patch literal 2958 zcmb_e&2JM&6rcTa9OuKP#5if7*$@T{}Sir4XlGU<1v0av3cXkX- zq@o-uHI+C}wFjsw0f$N;^*~R(_D@I@P}~t!ky^Fl78C?MRedvSJ86m1OGozGH*em& zdGp?z-^}**witr;*O!AIo`@jyEAP~sR%4b90W*!pks;;Kh(x6knaU#yRYp{*_HenJ z(?%jR(u0zy7a2+l8R}SMw-U~$O@`Ko(vc^;n^sxd8SK%Lhuxf4u22>q!W7%I$v(XIOh_Q`B*u@2ldpsQ2 zmMgfrPg6s&kPY-JMeOMo{0gvNJ*YSi_S{LmV3oW;zd(G;!9;f&wsfmPi#{pQ$$^+H z!6x{hH@-6-$|W7h0cjfL(YUd|`{v63CH=7iW0maf8T2m!O`Z4h#fQkyvt7x9;&FHNfsfz^C<- z1W=}W)+Nk(*L0Ap2*>y-nMOaw`^kFX{*gHq1R`_&z^s)yLx%iG85ulPNDC;lScxAF z)3K;UT0xMO3AaSJN#NT#9|uxPpzxclB?9*z#9|tquNNT7OQ%-qab0M;YvX0vKxM^{%4%L7SN44cg_-7sdA^PxfDe3R zhd`QeuA%H`j3Trn6bfpnyK8HKO)W3CA|o=Wl+_dcM$_tk?1y>I2EL*ZT|)!b1C3^| z8Y`>ko9auRkJhv;`@CwjtiGzznwO2XaV4V|?R#sxg#^b7 zbu&|2mMcuRi+~IIl@cw&$Kz+!)rsX5@E>2_oAr0K4qHaTq=2m3DJJfPHj6ZP^xGFH2GkGD*<>py3ktzQ!KIK zAPJ|LCKhGQjWyC5YY*`NFw&$P#bX3e+zf1=)OnXfI>#w1@I4mAmrH<;HcmG&#l;@c zAxcaEA4SO%9zT{G8a_V!&f&AU(Ua!znWKk~3zoH*!niP))<_Ce2`_%q%gGi__yG*K zjsAXJ*TYjD30-7;%Rzh@&j&&!aiD&H`P1lG1g-11er%y5GuM%sPrNasRg;}FvFf_L zGm*zFiR*7(+yAI*r)Gpoab z^;T2e3#qMhsjYWo^Qm3c^<9q>n{OXm*pi*wlC36EHsyp?u>Egf5_xCL4OSJiHjrlc8 zHh~6|DL2TJ7g$cf>h+_@7>9m%~j!lFlw+}RuyH$+pEat0Iy%=d@a6WPvZZdEC=Nh<-HNx0ja}C?(^7cr@vG-hc*=xC;OP literal 0 HcmV?d00001 diff --git a/sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc b/sentinel/src/detectors/__pycache__/identity_drift.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b049575ef3467902f7d9d2f84246c04a7a84e1a9 GIT binary patch literal 1792 zcmaJ>&2Jnv6t`z*CzENKOtu#1I%6qorl1Nlwz9|2Zq7R&7-nCK-TN_A+^o23DCQ zE`m%3RMA*$V<{So6F918nJ72t0nn}+MO2hy6XOVZ_3M6>ECJc)K^XJix&88 zaX;=H@Ed$54i%UFy2!SS%Nn3h;OSK_q5u;=9EcssI`To>plz~DR;>@dA*2D;%Vd!@ zX+Uq$FG-C8ChH0MYe#U^5-E^UkV;f6?W|{FD+K-+i;_^vMjR=|BsyPf>y_GPk_%Z2 zH=A+7W%Eub;>}vP0gn?csose)P~x>(oGQK{LX3y3$xftdQbe_eiBkr5bYI9C$Vqj3 zSNAa%rfH_&xs2&+FXgQIyiYHnlCOYx)+Q%}7X3pM_aieMft?tJP{Dyr0T=F(14^3& z1bCpj|16MS&}IX}>k|FP#Q;ccr{NaYBQJ-9dj<;TdYk-J8WR^mI};rn3?@3`p!{ES z0t;e9YZhQDh;!hqy%g?+mu=5aJ$J5zQl#kP_+jNQnhUbbi1(9O*XP}>b-zCUKoM3f*hN%fOowx zfXPq6enw5Gh;AWl{PZJ^->3FUr#@1JYYErG z*%~D54j1e+%FR$VD@EO(XCh=MQ`jLq0Ud(LC=*>h!FGWa63Q&qF4Q5^WW+0uVO}6e z-Cv*nWNxXx^aYy^>YpwNe6=pj&VMehfL5_Z876`Bi!l^eK}aNQeBAf*)N*nl`f?Zh znI2KicTE&Of^%(h;F94>_q_eVx9>T}E*ThmH2T)V(a8s+ll$e7M`i!}yZ7(zUwmVK zxcaO2aANVn#Nr=A!SOj~z&jvLuV-Q{hT*EYO0RYq!|a7}^BCL88m$C%D8#Q@Vba+t zF}5BHsj%my8Ep43*2p5p#H(zyov8T0Q70RY1-Y`CnO> z;N-!$fcvLir;0fvdf=@2qv0XH&y=})Bj?~lF##@!QI3E(C{RlOCi8!i`N!n?v0bL# L53^4ROy=yr%-p#} literal 0 HcmV?d00001 diff --git a/sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc b/sentinel/src/detectors/__pycache__/policy_avoidance.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00024b371de2e912c0a25415659f5c5e059d5c4c GIT binary patch literal 2886 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 literal 0 HcmV?d00001 diff --git a/sentinel/src/detectors/base.py b/sentinel/src/detectors/base.py new file mode 100644 index 0000000..9cb2b7d --- /dev/null +++ b/sentinel/src/detectors/base.py @@ -0,0 +1,15 @@ +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/collusion_detector.py b/sentinel/src/detectors/collusion_detector.py new file mode 100644 index 0000000..46f72a4 --- /dev/null +++ b/sentinel/src/detectors/collusion_detector.py @@ -0,0 +1,67 @@ +from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel, CollusionPattern +from src.detectors.base import BaseDetector +from typing import List +class CollusionDetector(BaseDetector): + """ + Detects multi-agent collusion patterns: + - Delegation chains with unusual depth + - Agents sharing suspicious tool calls + - Circular delegations + """ + + def name(self) -> str: + return "collusion" + + def detect(self, input_data: SentinelInput) -> DetectionResult: + risk_score = 0.0 + reason = "No collusion detected" + evidence = {} + + # Simulate collusion detection based on delegation chain and fleet data + if input_data.agent_fleet and len(input_data.agent_fleet) > 2: + # Check if delegation chain is long + if len(input_data.delegation_chain) > 2: + risk_score = 0.75 + reason = f"Delegation chain of length {len(input_data.delegation_chain)} in fleet of {len(input_data.agent_fleet)} agents" + evidence = { + "delegation_depth": len(input_data.delegation_chain), + "fleet_size": len(input_data.agent_fleet), + "pattern": "delegation_chain_collusion" + } + # Check for circular delegation (simplified: if agent appears twice in delegation chain) + if len(set(input_data.delegation_chain)) < len(input_data.delegation_chain): + risk_score = max(risk_score, 0.85) + reason = "Circular delegation detected" + evidence["circular"] = True + + return DetectionResult( + detection_type=DetectionType.COLLUSION, + risk_score=risk_score, + risk_level=self._risk_level(risk_score), + reason=reason, + evidence=evidence + ) + + def detect_collusion_patterns(self, inputs: List[SentinelInput]) -> List[CollusionPattern]: + patterns = [] + # Simple pattern: all agents share the same high-risk tool + all_tools = [] + for inp in inputs: + all_tools.extend([t.get("name", "") for t in inp.tool_calls]) + from collections import Counter + tool_counts = Counter(all_tools) + for tool, count in tool_counts.items(): + if count > 2 and tool in ["grant_permission", "delete_logs", "write_config"]: + patterns.append(CollusionPattern( + pattern_type="shared_high_risk_tool", + agents=[inp.agent_id for inp in inputs], + risk_score=0.7, + description=f"{count} agents used tool '{tool}'" + )) + return patterns + + 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/detectors/delegation_escalation.py b/sentinel/src/detectors/delegation_escalation.py new file mode 100644 index 0000000..e16c367 --- /dev/null +++ b/sentinel/src/detectors/delegation_escalation.py @@ -0,0 +1,42 @@ +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/detectors/identity_drift.py b/sentinel/src/detectors/identity_drift.py new file mode 100644 index 0000000..8bbf39a --- /dev/null +++ b/sentinel/src/detectors/identity_drift.py @@ -0,0 +1,27 @@ +from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel +from src.detectors.base import BaseDetector + +class IdentityDriftDetector(BaseDetector): + """ + Detects when the runtime identity deviates from the baseline. + """ + + def name(self) -> str: + return "identity_drift" + + def detect(self, input_data: SentinelInput) -> DetectionResult: + # Simplified: check if observer_identity_hash has changed + # In production, you would maintain a baseline per agent. + # For now, we assume any change is suspicious. + risk_score = 0.0 + reason = "Identity matches baseline" + + # In a real implementation, you would compare against a stored baseline. + # This is a placeholder that always returns low risk. + return DetectionResult( + detection_type=DetectionType.IDENTITY_DRIFT, + risk_score=0.1, + risk_level=RiskLevel.LOW, + reason="Identity stable", + evidence={"observer_identity_hash": input_data.observer_identity_hash} + ) \ No newline at end of file diff --git a/sentinel/src/detectors/policy_avoidance.py b/sentinel/src/detectors/policy_avoidance.py new file mode 100644 index 0000000..93d236e --- /dev/null +++ b/sentinel/src/detectors/policy_avoidance.py @@ -0,0 +1,33 @@ +from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel +from src.detectors.base import BaseDetector + +class PolicyAvoidanceDetector(BaseDetector): + """ + Detects repeated near-boundary requests that attempt to avoid policy enforcement. + """ + + def name(self) -> str: + return "policy_avoidance" + + def detect(self, input_data: SentinelInput) -> DetectionResult: + # Simulate: count how many tool calls are near policy boundaries + boundary_actions = [t for t in input_data.tool_calls if "write" in t.get("name", "").lower()] + risk_score = min(len(boundary_actions) * 0.2, 1.0) + reason = f"{len(boundary_actions)} boundary-adjacent actions detected" + + return DetectionResult( + detection_type=DetectionType.POLICY_AVOIDANCE, + risk_score=risk_score, + risk_level=self._risk_level(risk_score), + reason=reason, + evidence={ + "boundary_actions": len(boundary_actions), + "total_actions": len(input_data.tool_calls) + } + ) + + 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/detectors/tool_drift.py b/sentinel/src/detectors/tool_drift.py new file mode 100644 index 0000000..2396a19 --- /dev/null +++ b/sentinel/src/detectors/tool_drift.py @@ -0,0 +1,37 @@ +from src.models import SentinelInput, DetectionResult, DetectionType, RiskLevel +from src.detectors.base import BaseDetector + +class ToolDriftDetector(BaseDetector): + """ + Detects when an agent starts calling tools it has never used before. + """ + + def name(self) -> str: + return "tool_drift" + + def detect(self, input_data: SentinelInput) -> DetectionResult: + # This is a simplified version. In production, you would maintain a baseline + # of tool usage per agent over time. + tool_names = [t.get("name", "") for t in input_data.tool_calls] + unique_tools = set(tool_names) + + # Simulate: if more than 3 unique tools, flag as drift + risk_score = min(len(unique_tools) * 0.15, 1.0) + reason = f"Agent called {len(unique_tools)} unique tools" + + return DetectionResult( + detection_type=DetectionType.TOOL_DRIFT, + risk_score=risk_score, + risk_level=self._risk_level(risk_score), + reason=reason, + evidence={ + "tools_called": list(unique_tools), + "count": len(unique_tools) + } + ) + + 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/models.py b/sentinel/src/models.py new file mode 100644 index 0000000..39ea3e9 --- /dev/null +++ b/sentinel/src/models.py @@ -0,0 +1,170 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from enum import Enum +from datetime import datetime + +class RiskLevel(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + +class Action(str, Enum): + MONITOR = "monitor" + ESCALATE = "escalate" + QUARANTINE = "quarantine" + BLOCK = "block" + +class DetectionType(str, Enum): + DELEGATION_ESCALATION = "delegation_escalation" + TOOL_DRIFT = "tool_drift" + POLICY_AVOIDANCE = "policy_avoidance" + IDENTITY_DRIFT = "identity_drift" + COLLUSION = "collusion" + +class TimelineEvent(BaseModel): + timestamp: datetime + agent_id: str + event_type: str + description: str + severity: Optional[str] = None + +class TraceClaim(BaseModel): + claim_id: str + agent_id: str + detection_type: DetectionType + risk_score: float + evidence: Dict[str, Any] + timestamp: datetime + jwt: Optional[str] = None + json_export: Optional[Dict[str, Any]] = None + enforcement_status: Optional[str] = "pending" + decision: Optional[str] = None + reason: Optional[str] = None + +class QuarantineRecord(BaseModel): + agent_id: str + timestamp: datetime + reason: str + blocked_tools: List[str] + trace_claim_id: str + action: Action = Action.QUARANTINE + status: str = "active" + +class Ticket(BaseModel): + ticket_id: str + agent_id: str + claim_id: str + created_at: datetime + status: str = "open" + assignee: Optional[str] = None + +class Receipt(BaseModel): + receipt_id: str + executed_by: str = "sentinel" + timestamp: datetime + result: str # "SUCCESS" or "FAILED" + +class EnforcementResult(BaseModel): + action: Action + agent_id: str + claim_id: str + timestamp: datetime + details: Dict[str, Any] + status: str + receipt: Optional[Receipt] = None + +class ReplayResult(BaseModel): + policy_version: str + risk_score: float + risk_level: str + decision: str + reason: str + detections: List[Dict[str, Any]] = [] + +class QuarantineAction(BaseModel): + agent_id: str + reason: str + blocked_tools: List[str] + fallback: str + risk_score: float + action: Action = Action.QUARANTINE + +class DetectionResult(BaseModel): + detection_type: DetectionType + risk_score: float + risk_level: RiskLevel + reason: str + evidence: Dict[str, Any] + timestamp: datetime = datetime.now() + action: Action = Action.MONITOR + +class CollusionPattern(BaseModel): + pattern_type: str + agents: List[str] + risk_score: float + description: str + +class GraphNode(BaseModel): + id: str + label: str + risk: float + shape: str = "circle" + color: str = "#238636" + +class GraphEdge(BaseModel): + from_: str = Field(..., alias="from") + to: str + label: str + color: str = "#8b949e" + dashes: bool = False + width: int = 1 + + class Config: + validate_by_name = True + +class SentinelInput(BaseModel): + trace_id: str = "unknown" + agent_id: str = "unknown" + session_id: str = "unknown" + policy_version: str = "v1" + delegation_chain: List[str] = [] + tool_calls: List[Dict[str, Any]] = [] + observer_identity_hash: str = "" + reference_frame_hash: str = "" + timestamp: str = "" + delegation_source: Optional[str] = None + delegation_target: Optional[str] = None + agent_fleet: Optional[List[str]] = None + +class SentinelOutput(BaseModel): + risk_score: float + risk_level: RiskLevel + detections: List[DetectionResult] + quarantine_recommended: bool + quarantine_action: Optional[QuarantineAction] = None + collusion_patterns: List[CollusionPattern] = [] + timeline: List[TimelineEvent] = [] + trace_claims: List[TraceClaim] = [] + graph_nodes: List[GraphNode] = [] + graph_edges: List[GraphEdge] = [] + decision: str = "ADMIT" + reason: Optional[str] = None + +class IncidentReport(BaseModel): + incident_id: str + agent_id: str + detection_type: str + risk_score: float + risk_level: str + trace_claim_id: str + enforcement_action: str + enforcement_status: str + replay_results: List[ReplayResult] + final_recommendation: str + timestamp: datetime = datetime.now() + evidence_export: Dict[str, Any] + receipt: Optional[Receipt] = None + signature: Optional[str] = None + claim_hash: Optional[str] = None + incident_hash: Optional[str] = None \ No newline at end of file diff --git a/sentinel/src/quarantine.py b/sentinel/src/quarantine.py new file mode 100644 index 0000000..bc1707b --- /dev/null +++ b/sentinel/src/quarantine.py @@ -0,0 +1,29 @@ +from src.models import QuarantineAction, SentinelOutput + +def generate_quarantine(output: SentinelOutput, agent_id: str) -> SentinelOutput: + """If risk exceeds threshold, generate a quarantine action.""" + if output.risk_score > 0.7 and not output.quarantine_recommended: + output.quarantine_recommended = True + blocked_tools = [] + fallback = "human_review" + reasons = [] + + for d in output.detections: + if d.risk_score > 0.6: + reasons.append(d.reason) + # Infer blocked tools from evidence + if "tools_called" in d.evidence: + blocked_tools.extend([t for t in d.evidence["tools_called"] if "write" in t or "grant" in t or "delete" in t]) + if "delegation_chain" in d.evidence: + blocked_tools.append("delegation_escalation") + + output.quarantine_action = QuarantineAction( + agent_id=agent_id, + reason=" | ".join(reasons[:3]), + blocked_tools=list(set(blocked_tools))[:5], + fallback=fallback, + risk_score=output.risk_score + ) + # Set quarantine reason in output + output.quarantine_reason = f"Risk score {output.risk_score:.2f} exceeds threshold. Agent quarantined." + return output \ No newline at end of file diff --git a/sentinel/src/replay_engine.py b/sentinel/src/replay_engine.py new file mode 100644 index 0000000..6b08d5e --- /dev/null +++ b/sentinel/src/replay_engine.py @@ -0,0 +1,55 @@ +import copy +from typing import List +from src.models import SentinelInput, ReplayResult +from src.risk_engine import RiskEngine + +class ReplayEngine: + def __init__(self): + self.engine = RiskEngine() + + def replay(self, trace: SentinelInput, policy_versions: List[str]) -> List[ReplayResult]: + results = [] + original_policy = trace.policy_version + + for version in policy_versions: + trace_copy = copy.deepcopy(trace) + trace_copy.policy_version = version + + output = self.engine.evaluate(trace_copy) + + decision = "ADMIT" + reason = "All checks passed" + + if version == "v3" and len(trace_copy.delegation_chain) > 2: + decision = "DENY" + reason = f"Policy v3: Delegation chain length {len(trace_copy.delegation_chain)} exceeds allowed limit (2)" + elif version == "v3" and any(d.risk_score > 0.7 for d in output.detections): + decision = "DENY" + reason = f"Policy v3: Risk score {output.risk_score:.2f} exceeds threshold" + elif version == "v2" and len(trace_copy.delegation_chain) > 3: + decision = "DENY" + reason = f"Policy v2: Delegation chain length {len(trace_copy.delegation_chain)} exceeds limit (3)" + elif output.risk_score > 0.7: + decision = "DENY" + reason = f"Risk score {output.risk_score:.2f} exceeds threshold (policy {version})" + elif any(d.risk_score > 0.8 for d in output.detections): + decision = "DENY" + reason = f"Critical detection: {max(output.detections, key=lambda d: d.risk_score).detection_type}" + + if version in ["v1", "v2"] and len(trace_copy.delegation_chain) <= 2 and output.risk_score < 0.5: + decision = "ADMIT" + reason = "All governance checks passed" + + detections_dict = [d.model_dump(mode='json') for d in output.detections] + + results.append(ReplayResult( + policy_version=version, + risk_score=output.risk_score, + risk_level=output.risk_level.value, + decision=decision, + reason=reason, + detections=detections_dict + )) + + trace.policy_version = original_policy + return results \ No newline at end of file diff --git a/sentinel/src/risk_engine.py b/sentinel/src/risk_engine.py new file mode 100644 index 0000000..18201e1 --- /dev/null +++ b/sentinel/src/risk_engine.py @@ -0,0 +1,242 @@ +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/server.py b/sentinel/src/server.py new file mode 100644 index 0000000..9702f7d --- /dev/null +++ b/sentinel/src/server.py @@ -0,0 +1,347 @@ +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from pathlib import Path +from src.models import ( + SentinelInput, Ticket, EnforcementResult, Action, + IncidentReport, ReplayResult, Receipt +) +from src.risk_engine import RiskEngine +from src.replay_engine import ReplayEngine +import traceback +import uuid +import json +import hashlib +import base64 +from datetime import datetime + +app = FastAPI(title="Agent Sentinel") +BASE_DIR = Path(__file__).resolve().parent +templates_dir = BASE_DIR / "templates" +templates = Jinja2Templates(directory=str(templates_dir)) +engine = RiskEngine() +replay_engine = ReplayEngine() + +# In-memory stores +enforcement_status = {} +enforcement_action = {} +enforcement_timestamp = {} +receipt_store = {} # claim_id -> Receipt (single receipt per claim) +ticket_store = {} +quarantine_store = {} +block_store = {} + +def normalize_risk_level(risk_level: str) -> str: + valid = ["low", "medium", "high", "critical"] + if risk_level and risk_level.lower() in valid: + return risk_level.lower() + return "low" + +def log_enforcement(action: str, claim_id: str, result: dict, status: str = "SUCCESS"): + print(f"\n[ENFORCE]") + print(f"Claim: {claim_id}") + print(f"Action: {action.upper()}") + 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() + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + return templates.TemplateResponse("dashboard.html", {"request": request}) + +@app.post("/evaluate") +async def evaluate(request: Request): + try: + data = await request.json() + if "agents" in data or "agent_fleet" in data: + agents_list = data.get("agents") or data.get("agent_fleet", []) + if not agents_list: + return JSONResponse(content={"error": "No agents provided"}, status_code=400) + + inputs = [] + for agent_data in agents_list: + inp = SentinelInput( + trace_id=agent_data.get("trace_id", f"fleet-{agent_data.get('agent_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.get("agent_id", "unknown") for a in agents_list] + ) + inputs.append(inp) + + result = engine.evaluate_fleet(inputs) + + trace_claims = [] + for c in result["trace_claims"]: + claim_dict = c.model_dump(mode='json') + claim_id = claim_dict["claim_id"] + claim_dict["enforcement_status"] = enforcement_status.get(claim_id, "pending") + if claim_id in enforcement_action: + claim_dict["enforcement_action"] = enforcement_action[claim_id] + trace_claims.append(claim_dict) + + serializable = { + "fleet_risk_score": result["fleet_risk_score"], + "fleet_risk_level": result["fleet_risk_level"], + "agent_results": [ + { + "agent_id": r["agent_id"], + **r["result"].model_dump(mode='json') + } + for r in result["agent_results"] + ], + "collusion_patterns": [p.model_dump(mode='json') for p in result["collusion_patterns"]], + "timeline": [ + { + "timestamp": e.timestamp.isoformat(), + "agent_id": e.agent_id, + "event_type": e.event_type, + "description": e.description, + "severity": e.severity + } + for e in result["timeline"] + ], + "trace_claims": trace_claims, + "graph_nodes": [n.model_dump(mode='json', by_alias=True) for n in result["graph_nodes"]], + "graph_edges": [e.model_dump(mode='json', by_alias=True) for e in result["graph_edges"]] + } + return JSONResponse(content=serializable) + else: + try: + inp = SentinelInput(**data) + result = engine.evaluate(inp) + return JSONResponse(content=result.model_dump(mode='json')) + except Exception as e: + return JSONResponse(content={"error": f"Invalid input: {str(e)}"}, status_code=400) + except Exception as e: + print("ERROR:", traceback.format_exc()) + return JSONResponse(content={"error": str(e)}, status_code=400) + +@app.post("/enforce/{claim_id}") +async def enforce_action(claim_id: str, request: Request): + data = await request.json() + action_str = data.get("action") + agent_id = data.get("agent_id", "unknown") + + if action_str not in ["escalate", "quarantine", "block"]: + raise HTTPException(status_code=400, detail="Invalid action") + + action = Action(action_str) + enforcement_status[claim_id] = f"{action_str}_applied" + enforcement_action[claim_id] = action_str.upper() + enforcement_timestamp[claim_id] = datetime.now().isoformat() + + result_details = {} + log_message = "" + + # Create a single receipt for this enforcement action + receipt = Receipt( + receipt_id=f"RCPT-{uuid.uuid4().hex[:8].upper()}", + executed_by="sentinel", + timestamp=datetime.now(), + result="SUCCESS" + ) + receipt_store[claim_id] = receipt # store once + + if action == Action.ESCALATE: + ticket_id = f"TICKET-{uuid.uuid4().hex[:8].upper()}" + ticket_store[claim_id] = { + "ticket_id": ticket_id, + "agent_id": agent_id, + "claim_id": claim_id, + "created_at": datetime.now().isoformat(), + "status": "open", + "assignee": "supervisor@company.com" + } + result_details = { + "ticket_id": ticket_id, + "assignee": "supervisor@company.com", + "message": "Supervisor notified. TRACE claim attached." + } + log_message = "Supervisor notified" + elif action == Action.QUARANTINE: + blocked = ["grant_permission", "delete_logs", "write_config"] + quarantine_store[agent_id] = { + "agent_id": agent_id, + "timestamp": datetime.now().isoformat(), + "reason": "Delegation escalation and tool drift detected", + "blocked_tools": blocked, + "claim_id": claim_id, + "status": "isolated" + } + result_details = { + "agent_status": "isolated", + "blocked_tools": blocked, + "reason": "Delegation escalation detected", + "message": "Agent isolated. Tools blocked." + } + log_message = "Agent isolated" + elif action == Action.BLOCK: + block_store[claim_id] = { + "claim_id": claim_id, + "agent_id": agent_id, + "timestamp": datetime.now().isoformat(), + "decision": "DENY", + "reason": "Delegation escalation detected", + "policy": "v3" + } + result_details = { + "decision": "DENY", + "policy": "v3", + "reason": "Delegation escalation detected", + "claim_status": "BLOCKED", + "message": "Execution denied" + } + log_message = "Execution denied" + + enforcement_result = EnforcementResult( + action=action, + agent_id=agent_id, + claim_id=claim_id, + timestamp=datetime.now(), + details=result_details, + status="applied", + receipt=receipt # attach the same receipt + ) + + log_enforcement(action_str, claim_id, {"message": log_message, **result_details}, "SUCCESS") + return JSONResponse(content=enforcement_result.model_dump(mode='json')) + +@app.get("/export/receipt/{claim_id}") +async def export_receipt(claim_id: str): + """Export the single receipt for this claim.""" + receipt = receipt_store.get(claim_id) + if not receipt: + return JSONResponse(content={"error": "No receipt found for this claim"}, status_code=404) + return JSONResponse(content=receipt.model_dump(mode='json')) + +@app.post("/export/incident/{claim_id}") +async def export_incident(claim_id: str, request: Request): + try: + data = await request.json() + agent_id = data.get("agent_id", "unknown") + detection_type = data.get("detection_type", "unknown") + risk_score = data.get("risk_score", 0.0) + raw_risk_level = data.get("risk_level", "low") + risk_level_str = normalize_risk_level(raw_risk_level) + + enforcement_action_val = enforcement_action.get(claim_id, data.get("enforcement_action", "monitor")).upper() + enforcement_status_val = enforcement_status.get(claim_id, data.get("enforcement_status", "pending")) + if enforcement_status_val == "pending" and enforcement_action_val != "MONITOR": + enforcement_status_val = "success" + + replay_results_data = data.get("replay_results", []) + replay_results = [] + for r in replay_results_data: + rl = normalize_risk_level(r.get("risk_level", "low")) + replay_results.append(ReplayResult( + policy_version=r.get("policy_version", "v1"), + risk_score=r.get("risk_score", 0.0), + risk_level=rl, + decision=r.get("decision", "ADMIT"), + reason=r.get("reason", ""), + detections=[] + )) + + final_rec = "Monitor" + if enforcement_action_val == "BLOCK": + final_rec = "Deny execution. Agent blocked." + elif enforcement_action_val == "QUARANTINE": + final_rec = "Isolate agent. Block listed tools. Notify security." + elif enforcement_action_val == "ESCALATE": + final_rec = "Escalate to supervisor with TRACE evidence." + + for r in replay_results: + if r.policy_version == "v3" and r.decision == "DENY": + final_rec += " Policy v3 would deny this action – consider governance update." + + report = IncidentReport( + incident_id=f"INC-{uuid.uuid4().hex[:8].upper()}", + agent_id=agent_id, + detection_type=detection_type, + risk_score=risk_score, + risk_level=risk_level_str, + trace_claim_id=claim_id, + enforcement_action=enforcement_action_val, + enforcement_status=enforcement_status_val, + replay_results=replay_results, + final_recommendation=final_rec, + evidence_export={"format": "trace.jwt", "claim_id": claim_id} + ) + + # Use the same receipt from the store – no new receipt created here + if claim_id in receipt_store: + report.receipt = receipt_store[claim_id] + + # 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) + report.signature = sign_payload(report_dict) + + return JSONResponse(content=report.model_dump(mode='json')) + except Exception as e: + print("ERROR in export_incident:", traceback.format_exc()) + return JSONResponse(content={"error": str(e)}, status_code=500) + +@app.post("/replay") +async def replay_trace(request: Request): + try: + data = await request.json() + trace_data = data.get("trace") + policy_versions = data.get("policy_versions", ["v1", "v2", "v3"]) + inp = SentinelInput(**trace_data) + results = replay_engine.replay(inp, policy_versions) + return JSONResponse(content=[r.model_dump(mode='json') for r in results]) + except Exception as e: + return JSONResponse(content={"error": str(e)}, status_code=400) + +@app.post("/verify/{claim_id}") +async def verify_incident(claim_id: str, request: Request): + try: + data = await request.json() + report_data = data.get("report") + if not report_data: + return JSONResponse(content={"error": "Missing report data"}, status_code=400) + + # 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"), + "detection_type": report_data.get("detection_type"), + "risk_score": report_data.get("risk_score") + }) + recomputed_incident_hash = hash_payload(report_copy) + recomputed_signature = sign_payload(report_copy) + + 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={ + "claim_id": claim_id, + "status": status, + "details": { + "claim_hash_valid": valid_claim_hash, + "incident_hash_valid": valid_incident_hash, + "signature_valid": valid_signature + } + }) + except Exception as e: + return JSONResponse(content={"error": str(e)}, status_code=500) \ No newline at end of file diff --git a/sentinel/src/templates/dashboard.html b/sentinel/src/templates/dashboard.html new file mode 100644 index 0000000..465313c --- /dev/null +++ b/sentinel/src/templates/dashboard.html @@ -0,0 +1,570 @@ + + + + Agent Sentinel – Enforcement Dashboard + + + + +

🔍 Agent Sentinel – Enforcement Dashboard

+

Runtime anomaly detection + distinct enforcement actions + policy replay + signed incident reports + verification

+ +
+

📤 Evaluate Fleet

+ +

+ +
+ + + + + + + + + \ No newline at end of file diff --git a/sentinel/src/trace_claim_generator.py b/sentinel/src/trace_claim_generator.py new file mode 100644 index 0000000..3e87588 --- /dev/null +++ b/sentinel/src/trace_claim_generator.py @@ -0,0 +1,50 @@ +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_ingester.py b/sentinel/src/trace_ingester.py new file mode 100644 index 0000000..30541a0 --- /dev/null +++ b/sentinel/src/trace_ingester.py @@ -0,0 +1,29 @@ +import json +from src.models import SentinelInput, SentinelOutput # <-- added SentinelOutput +from src.risk_engine import RiskEngine + +def ingest_trace(trace_path: str) -> SentinelOutput: + with open(trace_path, 'r') as f: + data = json.load(f) + + steps = data.get("steps", []) + if not steps: + raise ValueError("No steps found in trace") + + first_step = steps[0] + tool_calls = first_step.get("tool_calls", []) + + input_data = SentinelInput( + trace_id=data.get("trace_id", "unknown"), + agent_id=first_step.get("agent_id", "unknown"), + session_id=first_step.get("session_id", "unknown"), + policy_version=first_step.get("policy_version", "v1"), + delegation_chain=first_step.get("delegation_chain", []), + tool_calls=tool_calls, + observer_identity_hash=first_step.get("observer_identity_hash", ""), + reference_frame_hash=first_step.get("reference_frame_hash", ""), + timestamp=first_step.get("timestamp", "") + ) + + engine = RiskEngine() + return engine.evaluate(input_data) \ No newline at end of file diff --git a/sentinel/tests/__init__.py b/sentinel/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sentinel/tests/__pycache__/__init__.cpython-313.pyc b/sentinel/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..abd73008cce51488a6178efe13cc71ea3413bb77 GIT binary patch literal 175 zcmey&%ge<81lf~~vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iKerR!OQL%nv zc1C7SYH>z+Vo_$ceqwrRUP)1DafxnnK9G@_q@S5rlA2zWSdy8aSFB$Ql+Dab&CxGO wEiNh6kB`sH%PfhH*DI*J#bJ}1pHiBWYFESxG#+GqF^KVznURsPh#ANN0C&qSCjbBd literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..3849187656b9cb5abe5a63fd8e05c609b3c5db02 GIT binary patch literal 181 zcmey&%ge<81htcmvOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa|LIerR!OQL%nv zc1C7SYH>z+Vo_$ceqwrRUP)1DafxnnK9G@_q@S5rlA2zWSdy8aSFB$Ql+Dab&CxGO yEiNerlkq93C8^0J`9;Ng1(mlrY;yBcN^?@}idcb`fE-Z_Vtiy~WMnL22C@L(q%ivc literal 0 HcmV?d00001 diff --git a/sentinel/tests/test_detectors.py b/sentinel/tests/test_detectors.py new file mode 100644 index 0000000..e69de29 diff --git a/sentinel/tests/test_integration.py b/sentinel/tests/test_integration.py new file mode 100644 index 0000000..e69de29