rmic-guard 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rmic_guard-0.1.0/PKG-INFO +107 -0
- rmic_guard-0.1.0/PYPI_README.md +79 -0
- rmic_guard-0.1.0/README.md +160 -0
- rmic_guard-0.1.0/core/__init__.py +0 -0
- rmic_guard-0.1.0/core/audit_ledger.py +116 -0
- rmic_guard-0.1.0/core/contract_loader.py +218 -0
- rmic_guard-0.1.0/core/embedder.py +86 -0
- rmic_guard-0.1.0/core/embedding_interface.py +40 -0
- rmic_guard-0.1.0/core/enforcement_engine.py +385 -0
- rmic_guard-0.1.0/core/ids_engine.py +31 -0
- rmic_guard-0.1.0/core/ids_metric.py +399 -0
- rmic_guard-0.1.0/core/integrity_manifest.py +32 -0
- rmic_guard-0.1.0/core/reasoning_layer.py +462 -0
- rmic_guard-0.1.0/core/recovery_engine.py +25 -0
- rmic_guard-0.1.0/core/tool_layer.py +50 -0
- rmic_guard-0.1.0/pyproject.toml +60 -0
- rmic_guard-0.1.0/rmic_guard/__init__.py +70 -0
- rmic_guard-0.1.0/rmic_guard/py.typed +0 -0
- rmic_guard-0.1.0/rmic_guard.egg-info/PKG-INFO +107 -0
- rmic_guard-0.1.0/rmic_guard.egg-info/SOURCES.txt +24 -0
- rmic_guard-0.1.0/rmic_guard.egg-info/dependency_links.txt +1 -0
- rmic_guard-0.1.0/rmic_guard.egg-info/requires.txt +9 -0
- rmic_guard-0.1.0/rmic_guard.egg-info/top_level.txt +3 -0
- rmic_guard-0.1.0/setup.cfg +4 -0
- rmic_guard-0.1.0/utils/__init__.py +1 -0
- rmic_guard-0.1.0/utils/config.py +82 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rmic-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Role-boundary preservation and identity-drift detection for autonomous LLM agents.
|
|
5
|
+
Author-email: Arshitha S <arshitha.s1104@gmail.com>, Charithra H S <charithrahs@gmail.com>, Chandrakala <chandanah1605@gmail.com>, Neethu B Krishna <Neethu.b.krishna10@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Arshu-1104/RMIC
|
|
8
|
+
Project-URL: Repository, https://github.com/Arshu-1104/RMIC
|
|
9
|
+
Keywords: llm,agents,safety,guardrails,prompt-injection,drift-detection
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: litellm>=1.56.0
|
|
21
|
+
Requires-Dist: numpy>=1.26.0
|
|
22
|
+
Requires-Dist: scipy>=1.13.0
|
|
23
|
+
Requires-Dist: cryptography>=42.0.0
|
|
24
|
+
Requires-Dist: PyYAML>=6.0.0
|
|
25
|
+
Requires-Dist: fastembed>=0.2.0
|
|
26
|
+
Provides-Extra: torch
|
|
27
|
+
Requires-Dist: sentence-transformers>=3.0.0; extra == "torch"
|
|
28
|
+
|
|
29
|
+
# rmic-guard
|
|
30
|
+
|
|
31
|
+
Role-boundary preservation and identity-drift detection for autonomous LLM agents.
|
|
32
|
+
|
|
33
|
+
`rmic-guard` gives an agent a **contract** — an explicit statement of its role,
|
|
34
|
+
allowed/forbidden tools, and data-access scope — and enforces it at runtime
|
|
35
|
+
with two layers:
|
|
36
|
+
|
|
37
|
+
- **Hard rules**: exact-match checks on tool names, parameter bounds, and data scope.
|
|
38
|
+
- **IDS (Identity Drift Score)**: an embedding-based signal (role distance,
|
|
39
|
+
semantic grounding, trajectory curvature) that flags gradual drift a
|
|
40
|
+
hard-rule check alone would miss.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install rmic-guard
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If your platform can't run `fastembed`'s ONNX backend, install the
|
|
49
|
+
PyTorch-based fallback instead:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "rmic-guard[torch]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from rmic_guard import load_contract, EnforcementEngine, ClaudeReasoning
|
|
59
|
+
|
|
60
|
+
# 1. Load a role contract (see contracts/*.json in the repo for examples)
|
|
61
|
+
contract = load_contract("contracts/financial_agent.json")
|
|
62
|
+
|
|
63
|
+
# 2. Plan a tool call from a user message
|
|
64
|
+
reasoning = ClaudeReasoning() # reads ANTHROPIC_API_KEY from the environment
|
|
65
|
+
plan = reasoning.plan_tool_call(
|
|
66
|
+
"What's my current account balance?",
|
|
67
|
+
contract=contract,
|
|
68
|
+
condition="C",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# 3. Enforce the contract before executing
|
|
72
|
+
engine = EnforcementEngine(contract=contract, tools=my_tool_registry, ledger=None)
|
|
73
|
+
outcome = engine.evaluate_and_maybe_execute(
|
|
74
|
+
plan,
|
|
75
|
+
recent_ids=[], # pass prior IDS scores for trajectory tracking across a session
|
|
76
|
+
drift_type=None,
|
|
77
|
+
execute_tool=True,
|
|
78
|
+
enforcement_mode="full", # "full" | "hard_rules_only" | "ids_only"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
print(outcome.decision) # "PASS" | "WARN" | "BLOCK" | "NEEDS_RECOVERY" | "PREEMPTIVE_WARN"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Groq (Llama/Mixtral) is supported as a drop-in alternative to Claude:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from rmic_guard import GroqReasoning
|
|
88
|
+
reasoning = GroqReasoning(model_name="groq/llama-3.3-70b-versatile")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Writing a contract
|
|
92
|
+
|
|
93
|
+
A contract is a JSON file describing the agent's role, its allowed and
|
|
94
|
+
forbidden tools, parameter constraints, and data-access scope. See the
|
|
95
|
+
`contracts/` directory in the [GitHub repo](https://github.com/Charithra207/RMIC-guard)
|
|
96
|
+
for real examples across financial, support, healthcare-research, and
|
|
97
|
+
legal-review agent roles.
|
|
98
|
+
|
|
99
|
+
## Status
|
|
100
|
+
|
|
101
|
+
This is an early (0.1.x) release from an active research project on
|
|
102
|
+
role-boundary preservation in autonomous agents. APIs may change between
|
|
103
|
+
minor versions until 1.0. Issues and contributions welcome on GitHub.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# rmic-guard
|
|
2
|
+
|
|
3
|
+
Role-boundary preservation and identity-drift detection for autonomous LLM agents.
|
|
4
|
+
|
|
5
|
+
`rmic-guard` gives an agent a **contract** — an explicit statement of its role,
|
|
6
|
+
allowed/forbidden tools, and data-access scope — and enforces it at runtime
|
|
7
|
+
with two layers:
|
|
8
|
+
|
|
9
|
+
- **Hard rules**: exact-match checks on tool names, parameter bounds, and data scope.
|
|
10
|
+
- **IDS (Identity Drift Score)**: an embedding-based signal (role distance,
|
|
11
|
+
semantic grounding, trajectory curvature) that flags gradual drift a
|
|
12
|
+
hard-rule check alone would miss.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install rmic-guard
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If your platform can't run `fastembed`'s ONNX backend, install the
|
|
21
|
+
PyTorch-based fallback instead:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install "rmic-guard[torch]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from rmic_guard import load_contract, EnforcementEngine, ClaudeReasoning
|
|
31
|
+
|
|
32
|
+
# 1. Load a role contract (see contracts/*.json in the repo for examples)
|
|
33
|
+
contract = load_contract("contracts/financial_agent.json")
|
|
34
|
+
|
|
35
|
+
# 2. Plan a tool call from a user message
|
|
36
|
+
reasoning = ClaudeReasoning() # reads ANTHROPIC_API_KEY from the environment
|
|
37
|
+
plan = reasoning.plan_tool_call(
|
|
38
|
+
"What's my current account balance?",
|
|
39
|
+
contract=contract,
|
|
40
|
+
condition="C",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# 3. Enforce the contract before executing
|
|
44
|
+
engine = EnforcementEngine(contract=contract, tools=my_tool_registry, ledger=None)
|
|
45
|
+
outcome = engine.evaluate_and_maybe_execute(
|
|
46
|
+
plan,
|
|
47
|
+
recent_ids=[], # pass prior IDS scores for trajectory tracking across a session
|
|
48
|
+
drift_type=None,
|
|
49
|
+
execute_tool=True,
|
|
50
|
+
enforcement_mode="full", # "full" | "hard_rules_only" | "ids_only"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
print(outcome.decision) # "PASS" | "WARN" | "BLOCK" | "NEEDS_RECOVERY" | "PREEMPTIVE_WARN"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Groq (Llama/Mixtral) is supported as a drop-in alternative to Claude:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from rmic_guard import GroqReasoning
|
|
60
|
+
reasoning = GroqReasoning(model_name="groq/llama-3.3-70b-versatile")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Writing a contract
|
|
64
|
+
|
|
65
|
+
A contract is a JSON file describing the agent's role, its allowed and
|
|
66
|
+
forbidden tools, parameter constraints, and data-access scope. See the
|
|
67
|
+
`contracts/` directory in the [GitHub repo](https://github.com/Charithra207/RMIC-guard)
|
|
68
|
+
for real examples across financial, support, healthcare-research, and
|
|
69
|
+
legal-review agent roles.
|
|
70
|
+
|
|
71
|
+
## Status
|
|
72
|
+
|
|
73
|
+
This is an early (0.1.x) release from an active research project on
|
|
74
|
+
role-boundary preservation in autonomous agents. APIs may change between
|
|
75
|
+
minor versions until 1.0. Issues and contributions welcome on GitHub.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# RMIC-Guard
|
|
2
|
+
|
|
3
|
+
**Role-Model Identity Contract Guard** — research framework proving that enforcement
|
|
4
|
+
position (external middleware vs. in-prompt self-policing) is the primary determinant
|
|
5
|
+
of role identity drift suppression in autonomous LLM agents.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Python 3.11 (Windows recommended)
|
|
12
|
+
- Anthropic API key (`sk-ant-...`) with credits
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
py -3.11 -m venv .venv
|
|
20
|
+
.\.venv\Scripts\Activate.ps1
|
|
21
|
+
pip install -r requirements.txt
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Create `.env` in the project root:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
ANTHROPIC_API_KEY=your_key_here
|
|
28
|
+
ANTHROPIC_MODEL=claude-sonnet-4-6
|
|
29
|
+
GEMINI_API_KEY=your_key_here
|
|
30
|
+
GROQ_API_KEY=your_key_here
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Validate
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
python setup.py
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Expected: 6/6 checks pass.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Run Experiment
|
|
46
|
+
|
|
47
|
+
**Test one provider first** (60 API calls):
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
python -m experiment.runner --test --provider anthropic
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Expected output: `rows_inserted=60`
|
|
54
|
+
|
|
55
|
+
Test Gemini only:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
python -m experiment.runner --test --provider gemini
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Test Groq only:
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
python -m experiment.runner --test --provider groq
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run multi-model comparison (all available providers with keys):
|
|
68
|
+
|
|
69
|
+
```powershell
|
|
70
|
+
python -m experiment.runner --test --multi-model --compare-providers
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Full run** (1,300 API calls per run, ~$8–15 at Sonnet 4.6 pricing):
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
python -m experiment.runner
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run 3 times for statistical variance.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Statistical Report
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
python -m experiment.statistical_tests
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Requires 2+ full runs. Report saved to `results/statistical_report.txt`.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Dashboard
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
python -m uvicorn dashboard.app:app --reload --port 8001
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Open `http://127.0.0.1:8001` — use the run selector to switch between runs.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Experimental Design
|
|
104
|
+
|
|
105
|
+
**4 Roles:** `financial_agent`, `support_agent`, `healthcare_research_agent`, `legal_review_agent`
|
|
106
|
+
|
|
107
|
+
**5 Conditions:**
|
|
108
|
+
|
|
109
|
+
| Condition | Description |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `A_no_contract` | Baseline — no contract, no enforcement |
|
|
112
|
+
| `B_prompt_contract` | Contract in system prompt — LLM self-polices |
|
|
113
|
+
| `C_rmic_middleware` | External middleware — full dual-pass enforcement |
|
|
114
|
+
| `C1_hard_rules_only` | Ablation — hard rules only, no IDS |
|
|
115
|
+
| `C2_ids_only` | Ablation — semantic IDS only, no hard rules |
|
|
116
|
+
|
|
117
|
+
**65 prompts per role per condition:** 50 adversarial + 10 generic legitimate + 5 role-specific legitimate
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## IDS Metrics (7 Independent Signals)
|
|
122
|
+
|
|
123
|
+
| Metric | Description |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Base IDS | 0.4 × RoleDistance + 0.4 × SemanticGrounding + 0.2 × TrajectoryCurvature |
|
|
126
|
+
| Mahalanobis | Covariance-aware distance in 384-dim embedding space |
|
|
127
|
+
| KL Divergence | Asymmetric early-warning divergence signal |
|
|
128
|
+
| Jensen–Shannon | Primary enforcement metric — symmetric, bounded [0, 1] |
|
|
129
|
+
| Wasserstein | Geometry-aware Earth Mover's Distance |
|
|
130
|
+
| Hellinger | Tail-sensitive, bounded [0, 1] |
|
|
131
|
+
| Tool Frequency | Behavioral pattern drift across session window |
|
|
132
|
+
|
|
133
|
+
All 7 computed independently. Never blended.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Key Metrics
|
|
138
|
+
|
|
139
|
+
| Metric | Definition |
|
|
140
|
+
|---|---|
|
|
141
|
+
| DSR | Drift Suppression Rate = blocked / expected_drift |
|
|
142
|
+
| DDR | Drift Detection Rate = detected / expected_drift |
|
|
143
|
+
| FPR | False Positive Rate = false_detections / legitimate |
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Exports
|
|
148
|
+
|
|
149
|
+
Each full run auto-exports to `results/exports/{run_id}.csv` (1,300 rows, 22 columns).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Team Branches
|
|
154
|
+
|
|
155
|
+
| Branch | Scope |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `charithra/core` | Core engine |
|
|
158
|
+
| `chandrakala/experiment` | Experiment pipeline |
|
|
159
|
+
| `arshitha/dashboard` | Dashboard |
|
|
160
|
+
| `neethu/contracts` | Contracts and prompts |
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Append-only audit ledger with per-entry Ed25519 signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
15
|
+
|
|
16
|
+
__all__ = ["AuditEntry", "AuditLedger"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _utc_iso() -> str:
|
|
20
|
+
return datetime.now(timezone.utc).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuditEntry:
|
|
25
|
+
timestamp: str
|
|
26
|
+
agent_id: str
|
|
27
|
+
input_hash: str
|
|
28
|
+
ids_score: float
|
|
29
|
+
drift_type: str | None
|
|
30
|
+
drift_velocity: float | None
|
|
31
|
+
decision: str
|
|
32
|
+
contract_hash: str
|
|
33
|
+
recovery_attempted: bool
|
|
34
|
+
role_distance: float | None = None
|
|
35
|
+
semantic_grounding: float | None = None
|
|
36
|
+
trajectory_curvature: float | None = None
|
|
37
|
+
failure_reason: str | None = None
|
|
38
|
+
false_positive: bool = False
|
|
39
|
+
false_negative: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuditLedger:
|
|
43
|
+
"""
|
|
44
|
+
Append-only JSONL ledger. Each line is a JSON object:
|
|
45
|
+
{ "entry": {...}, "signature": "<base64>" }
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, path: str | Path, private_key: Ed25519PrivateKey | None = None) -> None:
|
|
49
|
+
self.path = Path(path)
|
|
50
|
+
self._private_key = private_key or Ed25519PrivateKey.generate()
|
|
51
|
+
self._public_key = self._private_key.public_key()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def public_key(self) -> Ed25519PublicKey:
|
|
55
|
+
return self._public_key
|
|
56
|
+
|
|
57
|
+
def public_key_bytes(self) -> bytes:
|
|
58
|
+
return self._public_key.public_bytes(
|
|
59
|
+
encoding=serialization.Encoding.Raw,
|
|
60
|
+
format=serialization.PublicFormat.Raw,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def append(self, entry: AuditEntry) -> None:
|
|
64
|
+
payload = json.dumps(asdict(entry), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
65
|
+
sig = self._private_key.sign(payload)
|
|
66
|
+
line = json.dumps(
|
|
67
|
+
{
|
|
68
|
+
"entry": asdict(entry),
|
|
69
|
+
"signature": base64.b64encode(sig).decode("ascii"),
|
|
70
|
+
},
|
|
71
|
+
separators=(",", ":"),
|
|
72
|
+
)
|
|
73
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
75
|
+
f.write(line + "\n")
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def verify_line(line: str, public_key: Ed25519PublicKey) -> bool:
|
|
79
|
+
obj = json.loads(line)
|
|
80
|
+
entry = obj["entry"]
|
|
81
|
+
sig = base64.b64decode(obj["signature"])
|
|
82
|
+
payload = json.dumps(entry, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
83
|
+
try:
|
|
84
|
+
public_key.verify(sig, payload)
|
|
85
|
+
except Exception:
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def hash_text(text: str) -> str:
|
|
91
|
+
"""SHA-256 hex digest of UTF-8 text (for input_hash)."""
|
|
92
|
+
return sha256(text.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def entry_from_mapping(m: Mapping[str, Any]) -> AuditEntry:
|
|
96
|
+
return AuditEntry(
|
|
97
|
+
timestamp=str(m["timestamp"]),
|
|
98
|
+
agent_id=str(m["agent_id"]),
|
|
99
|
+
input_hash=str(m["input_hash"]),
|
|
100
|
+
ids_score=float(m["ids_score"]),
|
|
101
|
+
role_distance=None if m.get("role_distance") is None else float(m["role_distance"]),
|
|
102
|
+
semantic_grounding=(
|
|
103
|
+
None if m.get("semantic_grounding") is None else float(m["semantic_grounding"])
|
|
104
|
+
),
|
|
105
|
+
trajectory_curvature=(
|
|
106
|
+
None if m.get("trajectory_curvature") is None else float(m["trajectory_curvature"])
|
|
107
|
+
),
|
|
108
|
+
drift_type=m.get("drift_type"),
|
|
109
|
+
drift_velocity=None if m.get("drift_velocity") is None else float(m["drift_velocity"]),
|
|
110
|
+
decision=str(m["decision"]),
|
|
111
|
+
failure_reason=(None if m.get("failure_reason") is None else str(m.get("failure_reason"))),
|
|
112
|
+
false_positive=bool(m.get("false_positive", False)),
|
|
113
|
+
false_negative=bool(m.get("false_negative", False)),
|
|
114
|
+
contract_hash=str(m["contract_hash"]),
|
|
115
|
+
recovery_attempted=bool(m["recovery_attempted"]),
|
|
116
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Load, cryptographically seal, and verify RMIC identity contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
from utils.config import load_config
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DataScope",
|
|
15
|
+
"ParameterConstraint",
|
|
16
|
+
"RMICContract",
|
|
17
|
+
"canonical_contract_dict_for_hash",
|
|
18
|
+
"compute_contract_hash",
|
|
19
|
+
"load_contract",
|
|
20
|
+
"seal_contract_file",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_contract_dict_for_hash(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""Return a JSON-serialisable dict for hashing (excludes contract_hash only)."""
|
|
26
|
+
return {k: v for k, v in sorted(data.items()) if k != "contract_hash"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_contract_hash(data: Mapping[str, Any]) -> str:
|
|
30
|
+
"""SHA-256 over stable JSON of all fields except contract_hash."""
|
|
31
|
+
payload = canonical_contract_dict_for_hash(dict(data))
|
|
32
|
+
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
33
|
+
return hashlib.sha256(blob).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class DataScope:
|
|
38
|
+
"""Immutable data-scope slice from the contract JSON."""
|
|
39
|
+
|
|
40
|
+
accessible: tuple[str, ...]
|
|
41
|
+
prohibited: tuple[str, ...]
|
|
42
|
+
pii_categories: tuple[str, ...]
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def from_mapping(m: Mapping[str, Any]) -> DataScope:
|
|
46
|
+
return DataScope(
|
|
47
|
+
accessible=tuple(m.get("accessible") or ()),
|
|
48
|
+
prohibited=tuple(m.get("prohibited") or ()),
|
|
49
|
+
pii_categories=tuple(m.get("pii_categories") or ()),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ParameterConstraint:
|
|
55
|
+
"""Single parameter bound as loaded from the contract."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
max: float | int | None
|
|
59
|
+
min: float | int | None
|
|
60
|
+
value_type: str
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def from_entry(name: str, spec: Mapping[str, Any]) -> ParameterConstraint:
|
|
64
|
+
return ParameterConstraint(
|
|
65
|
+
name=name,
|
|
66
|
+
max=spec.get("max"),
|
|
67
|
+
min=spec.get("min"),
|
|
68
|
+
value_type=str(spec.get("type", "float")),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class RMICContract:
|
|
74
|
+
"""Frozen runtime identity contract. No field may change after construction."""
|
|
75
|
+
|
|
76
|
+
agent_id: str
|
|
77
|
+
role_name: str
|
|
78
|
+
sector: str
|
|
79
|
+
role_description: str
|
|
80
|
+
semantic_anchors: tuple[str, ...]
|
|
81
|
+
allowed_actions: tuple[str, ...]
|
|
82
|
+
forbidden_actions: tuple[str, ...]
|
|
83
|
+
data_scope: DataScope
|
|
84
|
+
parameter_constraints: tuple[ParameterConstraint, ...]
|
|
85
|
+
ids_warn_threshold: float
|
|
86
|
+
ids_block_threshold: float
|
|
87
|
+
drift_velocity_threshold: float
|
|
88
|
+
recovery_policy: str
|
|
89
|
+
compliance_tags: tuple[str, ...]
|
|
90
|
+
contract_version: str
|
|
91
|
+
created_at: str | None
|
|
92
|
+
contract_hash: str
|
|
93
|
+
anchor_embedding: tuple[float, ...]
|
|
94
|
+
|
|
95
|
+
def constraints_by_name(self) -> dict[str, ParameterConstraint]:
|
|
96
|
+
return {c.name: c for c in self.parameter_constraints}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _as_tuple_str(v: Any) -> tuple[str, ...]:
|
|
100
|
+
if v is None:
|
|
101
|
+
return ()
|
|
102
|
+
if isinstance(v, str):
|
|
103
|
+
return (v,)
|
|
104
|
+
return tuple(str(x) for x in v)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _contract_from_dict(data: dict[str, Any], *, require_hash_match: bool) -> RMICContract:
|
|
108
|
+
cfg = load_config()
|
|
109
|
+
tcfg = cfg.get("thresholds", {})
|
|
110
|
+
stored_hash = data.get("contract_hash")
|
|
111
|
+
if require_hash_match:
|
|
112
|
+
if not stored_hash:
|
|
113
|
+
raise ValueError("contract_hash missing; seal the contract before load with verify")
|
|
114
|
+
computed = compute_contract_hash(data)
|
|
115
|
+
if computed != stored_hash:
|
|
116
|
+
raise ValueError("contract_hash mismatch — contract was tampered with or corrupted")
|
|
117
|
+
|
|
118
|
+
ds_raw = data.get("data_scope") or {}
|
|
119
|
+
if not isinstance(ds_raw, dict):
|
|
120
|
+
raise TypeError("data_scope must be an object")
|
|
121
|
+
|
|
122
|
+
pc_raw = data.get("parameter_constraints") or {}
|
|
123
|
+
if not isinstance(pc_raw, dict):
|
|
124
|
+
raise TypeError("parameter_constraints must be an object")
|
|
125
|
+
|
|
126
|
+
constraints: list[ParameterConstraint] = []
|
|
127
|
+
for name in sorted(pc_raw.keys()):
|
|
128
|
+
spec = pc_raw[name]
|
|
129
|
+
if not isinstance(spec, dict):
|
|
130
|
+
raise TypeError(f"parameter_constraints.{name} must be an object")
|
|
131
|
+
constraints.append(ParameterConstraint.from_entry(name, spec))
|
|
132
|
+
|
|
133
|
+
anchors = _as_tuple_str(data.get("semantic_anchors"))
|
|
134
|
+
if not anchors:
|
|
135
|
+
raise ValueError("semantic_anchors must contain at least one sentence")
|
|
136
|
+
|
|
137
|
+
emb = data.get("anchor_embedding")
|
|
138
|
+
if emb is None:
|
|
139
|
+
anchor_embedding: tuple[float, ...] = ()
|
|
140
|
+
else:
|
|
141
|
+
if not isinstance(emb, list):
|
|
142
|
+
raise TypeError("anchor_embedding must be a list of floats (seal the contract first)")
|
|
143
|
+
anchor_embedding = tuple(float(x) for x in emb)
|
|
144
|
+
|
|
145
|
+
return RMICContract(
|
|
146
|
+
agent_id=str(data["agent_id"]),
|
|
147
|
+
role_name=str(data["role_name"]),
|
|
148
|
+
sector=str(data["sector"]),
|
|
149
|
+
role_description=str(data.get("role_description", "")),
|
|
150
|
+
semantic_anchors=anchors,
|
|
151
|
+
allowed_actions=_as_tuple_str(data.get("allowed_actions")),
|
|
152
|
+
forbidden_actions=_as_tuple_str(data.get("forbidden_actions")),
|
|
153
|
+
data_scope=DataScope.from_mapping(ds_raw),
|
|
154
|
+
parameter_constraints=tuple(constraints),
|
|
155
|
+
ids_warn_threshold=float(
|
|
156
|
+
data.get("ids_warn_threshold", data.get("warn_threshold", tcfg.get("warn_threshold", 0.35)))
|
|
157
|
+
),
|
|
158
|
+
ids_block_threshold=float(
|
|
159
|
+
data.get("ids_block_threshold", data.get("block_threshold", tcfg.get("block_threshold", 0.60)))
|
|
160
|
+
),
|
|
161
|
+
drift_velocity_threshold=float(
|
|
162
|
+
data.get(
|
|
163
|
+
"drift_velocity_threshold",
|
|
164
|
+
data.get("velocity_threshold", tcfg.get("velocity_threshold", 0.05)),
|
|
165
|
+
)
|
|
166
|
+
),
|
|
167
|
+
recovery_policy=str(data.get("recovery_policy", "re-anchor")),
|
|
168
|
+
compliance_tags=_as_tuple_str(data.get("compliance_tags")),
|
|
169
|
+
contract_version=str(data.get("contract_version", "1.0.0")),
|
|
170
|
+
created_at=data.get("created_at"),
|
|
171
|
+
contract_hash=str(stored_hash or compute_contract_hash(data)),
|
|
172
|
+
anchor_embedding=anchor_embedding,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_contract(path: str | Path, *, verify_hash: bool = True) -> RMICContract:
|
|
177
|
+
"""Load a contract JSON file and optionally verify SHA-256 integrity."""
|
|
178
|
+
p = Path(path)
|
|
179
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
180
|
+
if not isinstance(raw, dict):
|
|
181
|
+
raise TypeError("contract file must contain a JSON object")
|
|
182
|
+
return _contract_from_dict(raw, require_hash_match=verify_hash)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def seal_contract_file(
|
|
186
|
+
path: str | Path,
|
|
187
|
+
*,
|
|
188
|
+
write_back: bool = True,
|
|
189
|
+
model_name: str | None = None,
|
|
190
|
+
) -> RMICContract:
|
|
191
|
+
"""
|
|
192
|
+
Compute anchor_embedding (once) and contract_hash, optionally persist to disk.
|
|
193
|
+
|
|
194
|
+
anchor_embedding is the L2-normalised mean embedding of semantic_anchors.
|
|
195
|
+
"""
|
|
196
|
+
from core.embedder import anchor_centroid_from_anchors
|
|
197
|
+
|
|
198
|
+
p = Path(path)
|
|
199
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
200
|
+
if not isinstance(data, dict):
|
|
201
|
+
raise TypeError("contract file must contain a JSON object")
|
|
202
|
+
|
|
203
|
+
anchors = _as_tuple_str(data.get("semantic_anchors"))
|
|
204
|
+
if not anchors:
|
|
205
|
+
raise ValueError("semantic_anchors must contain at least one sentence")
|
|
206
|
+
|
|
207
|
+
centroid = anchor_centroid_from_anchors(list(anchors), model_name=model_name)
|
|
208
|
+
data["anchor_embedding"] = [float(x) for x in centroid.tolist()]
|
|
209
|
+
|
|
210
|
+
if data.get("created_at") in (None, ""):
|
|
211
|
+
data["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
212
|
+
|
|
213
|
+
data["contract_hash"] = compute_contract_hash(data)
|
|
214
|
+
|
|
215
|
+
if write_back:
|
|
216
|
+
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
return _contract_from_dict(data, require_hash_match=True)
|