devtorch-core 3.0.1__py3-none-any.whl

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.
Files changed (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,132 @@
1
+ """What-if simulation for multi-agent sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import dataclasses
7
+ import datetime as _dt
8
+ import json
9
+ import uuid
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .models import Session, Subtask
14
+ from .orchestrator import SessionOrchestrator
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class SimulationResult:
19
+ """Result of a single what-if simulation run."""
20
+
21
+ simulation_id: str
22
+ session_id: str
23
+ baseline_assumptions: Dict[str, Any]
24
+ alternate_assumptions: Dict[str, Any]
25
+ predicted_outcome: str
26
+ affected_concepts: List[str]
27
+ created_at: str
28
+
29
+ def to_dict(self) -> dict:
30
+ return dataclasses.asdict(self)
31
+
32
+
33
+ class WhatIfSimulator:
34
+ """
35
+ Run what-if simulations against a Session without mutating the original.
36
+
37
+ A subtask is considered affected when an alternate assumption key matches:
38
+ - the subtask concept, or
39
+ - a word inside the subtask description, or
40
+ - a key in the subtask's own assumptions.
41
+ """
42
+
43
+ def __init__(self, gcc_dir: Path) -> None:
44
+ self._orchestrator = SessionOrchestrator(gcc_dir)
45
+
46
+ def simulate(
47
+ self,
48
+ session_id: str,
49
+ alternate_assumptions: Dict[str, Any],
50
+ ) -> SimulationResult:
51
+ """
52
+ Load *session_id*, copy its subtasks, apply *alternate_assumptions*,
53
+ determine which subtasks are affected, and return a SimulationResult.
54
+
55
+ The original session on disk is never modified.
56
+ """
57
+ try:
58
+ session = self._orchestrator.get_session(session_id)
59
+ except FileNotFoundError as exc:
60
+ raise ValueError(str(exc)) from exc
61
+
62
+ baseline = dict(session.assumptions)
63
+ affected_concepts = self._affected_concepts(session, alternate_assumptions)
64
+ outcome = self.predict_outcome(session, alternate_assumptions)
65
+
66
+ return SimulationResult(
67
+ simulation_id=str(uuid.uuid4()),
68
+ session_id=session_id,
69
+ baseline_assumptions=baseline,
70
+ alternate_assumptions=dict(alternate_assumptions),
71
+ predicted_outcome=outcome,
72
+ affected_concepts=sorted(set(affected_concepts)),
73
+ created_at=_dt.datetime.now(tz=_dt.timezone.utc).isoformat(),
74
+ )
75
+
76
+ def predict_outcome(
77
+ self,
78
+ session: Session,
79
+ alternate_assumptions: Dict[str, Any],
80
+ ) -> str:
81
+ """Return a concise human-readable summary of predicted changes."""
82
+ affected = self._affected_concepts(session, alternate_assumptions)
83
+ if not affected:
84
+ return (
85
+ "No subtasks are affected by the alternate assumptions; "
86
+ "predicted outcome unchanged."
87
+ )
88
+
89
+ lines = [
90
+ f"Alternate assumptions change {len(affected)} concept(s): "
91
+ + ", ".join(sorted(set(affected)))
92
+ ]
93
+ for subtask in session.subtasks:
94
+ if subtask.concept in affected:
95
+ old = session.assumptions.get(subtask.concept)
96
+ new = alternate_assumptions.get(subtask.concept)
97
+ lines.append(
98
+ f"- Subtask '{subtask.subtask_id}' ({subtask.concept}): "
99
+ f"assumption shifts from {old!r} to {new!r}."
100
+ )
101
+ return "\n".join(lines)
102
+
103
+ @staticmethod
104
+ def _affected_concepts(
105
+ session: Session,
106
+ alternate_assumptions: Dict[str, Any],
107
+ ) -> List[str]:
108
+ """Return concepts that would be affected by alternate assumptions."""
109
+ affected: List[str] = []
110
+ alt_keys = set(alternate_assumptions.keys())
111
+ for subtask in session.subtasks:
112
+ if subtask.concept in alt_keys:
113
+ affected.append(subtask.concept)
114
+ continue
115
+ for key in alt_keys:
116
+ if key in subtask.description.split():
117
+ affected.append(subtask.concept)
118
+ break
119
+ if key in subtask.assumptions:
120
+ affected.append(subtask.concept)
121
+ break
122
+ return affected
123
+
124
+ @staticmethod
125
+ def _apply_assumptions_to_subtasks(
126
+ session: Session,
127
+ alternate_assumptions: Dict[str, Any],
128
+ ) -> Session:
129
+ """Return a copy of *session* with alternate assumptions applied."""
130
+ cloned = copy.deepcopy(session)
131
+ cloned.assumptions = {**cloned.assumptions, **alternate_assumptions}
132
+ return cloned
@@ -0,0 +1,290 @@
1
+ """
2
+ Sprint 9 — Optional cryptographic signing of the `.GCC/events.log.jsonl` hash chain.
3
+
4
+ Provides HMAC-SHA256 event signatures behind a feature flag. When enabled, every
5
+ appended event carries a ``signature`` field that proves the event was produced by
6
+ a party holding the repository signing key. The prior_hash chain remains intact and
7
+ continues to be verified by ``devtorch doctor``.
8
+
9
+ Design decisions
10
+ ----------------
11
+ * HMAC-SHA256 is used so signing works with zero external dependencies (the OSS
12
+ core has no ``cryptography`` requirement).
13
+ * The signing key is a 32-byte random value stored in ``.GCC/signing.key``.
14
+ * Enabling signing is idempotent: if a key already exists it is reused.
15
+ * Verification is tolerant: if a line lacks a signature, it is reported but does not
16
+ break the chain unless signing is explicitly enabled.
17
+ * Feature flag: ``DEVTORCH_SIGN_LOG=1`` environment variable, or a
18
+ ``.GCC/signing.json`` config with ``enabled: true``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import dataclasses
25
+ import hashlib
26
+ import hmac
27
+ import json
28
+ import os
29
+ import secrets
30
+ from pathlib import Path
31
+ from typing import Dict, List, Optional
32
+
33
+
34
+ SIGNING_KEY_FILE = "signing.key"
35
+ SIGNING_CONFIG_FILE = "signing.json"
36
+ SIGNATURE_ALGORITHM = "hmac-sha256"
37
+ SIGNATURE_ENV = "DEVTORCH_SIGN_LOG"
38
+
39
+
40
+ @dataclasses.dataclass
41
+ class SigningConfig:
42
+ """Resolved signing configuration for a repository."""
43
+
44
+ enabled: bool
45
+ key_path: Optional[str] = None
46
+ algorithm: str = SIGNATURE_ALGORITHM
47
+
48
+ @property
49
+ def key_file(self) -> Optional[Path]:
50
+ if self.key_path is None:
51
+ return None
52
+ return Path(self.key_path)
53
+
54
+
55
+ class SigningError(RuntimeError):
56
+ """Raised when signing or verification cannot proceed."""
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Configuration
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def _gcc_dir_from_cwd() -> Path:
65
+ """Best-effort current repo .GCC directory."""
66
+ return Path.cwd() / ".GCC"
67
+
68
+
69
+ def _read_signing_config(gcc_dir: Path) -> SigningConfig:
70
+ """Resolve signing config from environment + config file."""
71
+ env_enabled = os.environ.get(SIGNATURE_ENV, "").strip().lower() in (
72
+ "1",
73
+ "true",
74
+ "yes",
75
+ )
76
+
77
+ config_path = gcc_dir / SIGNING_CONFIG_FILE
78
+ file_enabled = False
79
+ if config_path.exists():
80
+ try:
81
+ data = json.loads(config_path.read_text(encoding="utf-8"))
82
+ file_enabled = bool(data.get("enabled"))
83
+ except (json.JSONDecodeError, OSError):
84
+ file_enabled = False
85
+
86
+ enabled = env_enabled or file_enabled
87
+ return SigningConfig(
88
+ enabled=enabled,
89
+ key_path=str(gcc_dir / SIGNING_KEY_FILE),
90
+ algorithm=SIGNATURE_ALGORITHM,
91
+ )
92
+
93
+
94
+ def _write_signing_config(gcc_dir: Path, enabled: bool) -> None:
95
+ """Persist signing config in .GCC/signing.json."""
96
+ config_path = gcc_dir / SIGNING_CONFIG_FILE
97
+ data = {"enabled": enabled, "algorithm": SIGNATURE_ALGORITHM}
98
+ config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Key management
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def _generate_key() -> bytes:
107
+ """Generate a new 32-byte random signing key."""
108
+ return secrets.token_bytes(32)
109
+
110
+
111
+ def _load_key(key_path: Path) -> Optional[bytes]:
112
+ """Load a base64-encoded signing key from disk."""
113
+ if not key_path.exists():
114
+ return None
115
+ try:
116
+ raw = key_path.read_bytes()
117
+ return base64.b64decode(raw.strip())
118
+ except (OSError, ValueError):
119
+ return None
120
+
121
+
122
+ def _save_key(key_path: Path, key: bytes) -> None:
123
+ """Persist a signing key with restrictive permissions."""
124
+ encoded = base64.b64encode(key)
125
+ key_path.write_bytes(encoded)
126
+ try:
127
+ # Restrict to owner read/write (0o600) on Unix
128
+ key_path.chmod(0o600)
129
+ except (OSError, NotImplementedError):
130
+ pass
131
+
132
+
133
+ def get_or_create_key(gcc_dir: Path) -> bytes:
134
+ """Load existing key or generate and save one."""
135
+ key_path = gcc_dir / SIGNING_KEY_FILE
136
+ key = _load_key(key_path)
137
+ if key is not None:
138
+ return key
139
+ key = _generate_key()
140
+ _save_key(key_path, key)
141
+ return key
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Signing
146
+ # ---------------------------------------------------------------------------
147
+
148
+
149
+ def _canonical_event_bytes(event: dict) -> bytes:
150
+ """Canonical JSON encoding of the event minus signature and hash fields."""
151
+ canonical = {k: v for k, v in event.items() if k not in ("signature", "hash")}
152
+ return json.dumps(canonical, sort_keys=True).encode("utf-8")
153
+
154
+
155
+ def sign_event(event: dict, key: bytes) -> dict:
156
+ """Return a new event dict with ``signature`` populated."""
157
+ payload = _canonical_event_bytes(event)
158
+ sig = hmac.new(key, payload, hashlib.sha256).hexdigest()
159
+ signed = dict(event)
160
+ signed["signature"] = f"{SIGNATURE_ALGORITHM}={sig}"
161
+ return signed
162
+
163
+
164
+ def verify_event(event: dict, key: bytes) -> bool:
165
+ """Verify the signature on an event. Returns False if missing/invalid."""
166
+ sig_field = event.get("signature")
167
+ if not sig_field or not isinstance(sig_field, str):
168
+ return False
169
+ if "=" not in sig_field:
170
+ return False
171
+ algo, _, value = sig_field.partition("=")
172
+ if algo != SIGNATURE_ALGORITHM:
173
+ return False
174
+ expected = hmac.new(key, _canonical_event_bytes(event), hashlib.sha256).hexdigest()
175
+ return hmac.compare_digest(expected, value)
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # High-level helpers
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ def signing_enabled(gcc_dir: Path) -> bool:
184
+ """Return True if signing is enabled for this repository."""
185
+ return _read_signing_config(gcc_dir).enabled
186
+
187
+
188
+ def enable_signing(gcc_dir: Path) -> bytes:
189
+ """Enable signing for the repository, creating a key if needed."""
190
+ key = get_or_create_key(gcc_dir)
191
+ _write_signing_config(gcc_dir, enabled=True)
192
+ return key
193
+
194
+
195
+ def disable_signing(gcc_dir: Path) -> None:
196
+ """Disable signing for the repository (does not delete the key)."""
197
+ _write_signing_config(gcc_dir, enabled=False)
198
+
199
+
200
+ def sign_line_if_enabled(line: dict, gcc_dir: Path) -> dict:
201
+ """Sign a single event dict if signing is enabled and a key exists."""
202
+ config = _read_signing_config(gcc_dir)
203
+ if not config.enabled or config.key_file is None:
204
+ return line
205
+ key = get_or_create_key(gcc_dir)
206
+ return sign_event(line, key)
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Batch verification
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ def verify_log(gcc_dir: Path) -> List[dict]:
215
+ """
216
+ Verify all signatures in events.log.jsonl.
217
+
218
+ Returns a list of failure records:
219
+ {line_no, event_hash, reason}
220
+
221
+ Behaviour:
222
+ * If signing is not enabled and no signatures exist, returns an empty list.
223
+ * Only lines from the first signed line onward are checked. This allows a
224
+ smooth transition: events created before signing was enabled remain
225
+ unsigned, but once signing is enabled every subsequent line must be valid.
226
+ """
227
+ log_path = gcc_dir / "events.log.jsonl"
228
+ config = _read_signing_config(gcc_dir)
229
+ key: Optional[bytes] = None
230
+ if config.enabled and config.key_file is not None:
231
+ key = _load_key(config.key_file)
232
+
233
+ failures: List[dict] = []
234
+ if not log_path.exists():
235
+ return failures
236
+
237
+ text = log_path.read_text(encoding="utf-8")
238
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
239
+ if not lines:
240
+ return failures
241
+
242
+ # Determine the first signed line; earlier lines are pre-signing history.
243
+ first_signed_index: Optional[int] = None
244
+ for idx, line in enumerate(lines):
245
+ try:
246
+ event = json.loads(line)
247
+ except json.JSONDecodeError:
248
+ continue
249
+ if event.get("signature"):
250
+ first_signed_index = idx
251
+ break
252
+
253
+ if first_signed_index is None:
254
+ # No signatures in the log at all.
255
+ return failures
256
+
257
+ for idx in range(first_signed_index, len(lines)):
258
+ line = lines[idx]
259
+ line_no = idx + 1
260
+ try:
261
+ event = json.loads(line)
262
+ except json.JSONDecodeError:
263
+ failures.append({"line_no": line_no, "event_hash": None, "reason": "invalid JSON"})
264
+ continue
265
+
266
+ has_signature = bool(event.get("signature"))
267
+ if not has_signature:
268
+ failures.append({
269
+ "line_no": line_no,
270
+ "event_hash": event.get("hash"),
271
+ "reason": "missing signature while signing enabled",
272
+ })
273
+ continue
274
+
275
+ if key is None:
276
+ failures.append({
277
+ "line_no": line_no,
278
+ "event_hash": event.get("hash"),
279
+ "reason": "signature present but signing key not available",
280
+ })
281
+ continue
282
+
283
+ if not verify_event(event, key):
284
+ failures.append({
285
+ "line_no": line_no,
286
+ "event_hash": event.get("hash"),
287
+ "reason": "invalid signature",
288
+ })
289
+
290
+ return failures
devtorch_core/sis.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ Sprint 5 – SIS (Sensitivity Inference Safety) A3: corpus format, evaluation harness, quarantine decision tree.
3
+
4
+ SIS-TC: Test corpus format (prompt, label_safe), loader, ROC/FPR/FNR evaluation.
5
+ Quarantine: deterministic decision tree (drop, log, rep_penalty, abstain, exclude after 3 consecutive).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Callable, List, Optional
14
+
15
+ SIS_TC_CORPUS_NAME = "sis_tc_corpus.jsonl"
16
+ SIS_TC_REPORT_NAME = "sis_tc_report.json"
17
+ SIS_QUARANTINE_LOG_NAME = "quarantine_events.jsonl"
18
+ SIS_DIR_NAME = "sis"
19
+ QUARANTINE_EVENT_TYPE = "QUARANTINE"
20
+
21
+ # Decision outcomes
22
+ OUTCOME_DROP = "drop"
23
+ OUTCOME_LOG = "log"
24
+ OUTCOME_REP_PENALTY = "rep_penalty"
25
+ OUTCOME_ABSTAIN = "abstain" # insufficient valid peers
26
+ OUTCOME_EXCLUDE = "exclude" # 3 consecutive quarantines for same agent
27
+
28
+
29
+ @dataclass
30
+ class SISTCCorpusEntry:
31
+ """Single SIS-TC corpus entry."""
32
+ prompt_id: str
33
+ prompt_text: str
34
+ label_safe: bool # True = safe, False = unsafe
35
+ metadata: Optional[dict] = None
36
+
37
+ def to_dict(self) -> dict:
38
+ d = {"prompt_id": self.prompt_id, "prompt_text": self.prompt_text, "label_safe": self.label_safe}
39
+ if self.metadata:
40
+ d["metadata"] = self.metadata
41
+ return d
42
+
43
+ @classmethod
44
+ def from_dict(cls, d: dict) -> "SISTCCorpusEntry":
45
+ return cls(
46
+ prompt_id=str(d["prompt_id"]),
47
+ prompt_text=str(d["prompt_text"]),
48
+ label_safe=bool(d["label_safe"]),
49
+ metadata=d.get("metadata"),
50
+ )
51
+
52
+
53
+ def load_sis_tc_corpus(path: Path) -> List[SISTCCorpusEntry]:
54
+ """Load SIS-TC corpus from JSONL (one JSON object per line)."""
55
+ if not path.exists():
56
+ return []
57
+ entries = []
58
+ with path.open("r", encoding="utf-8") as f:
59
+ for line in f:
60
+ line = line.strip()
61
+ if not line:
62
+ continue
63
+ try:
64
+ entries.append(SISTCCorpusEntry.from_dict(json.loads(line)))
65
+ except (json.JSONDecodeError, KeyError):
66
+ continue
67
+ return entries
68
+
69
+
70
+ @dataclass
71
+ class SISTCReport:
72
+ """SIS-TC evaluation report: counts, FPR, FNR, chosen threshold."""
73
+ n_total: int
74
+ n_safe: int
75
+ n_unsafe: int
76
+ tp: int
77
+ tn: int
78
+ fp: int
79
+ fn: int
80
+ fpr: float
81
+ fnr: float
82
+ threshold_used: float
83
+ operating_point: Optional[dict] = None # e.g. {"fpr_target": 0.01, "fnr_target": 0.05}
84
+
85
+ def to_dict(self) -> dict:
86
+ return {
87
+ "n_total": self.n_total,
88
+ "n_safe": self.n_safe,
89
+ "n_unsafe": self.n_unsafe,
90
+ "tp": self.tp, "tn": self.tn, "fp": self.fp, "fn": self.fn,
91
+ "fpr": self.fpr, "fnr": self.fnr,
92
+ "threshold_used": self.threshold_used,
93
+ "operating_point": self.operating_point,
94
+ }
95
+
96
+
97
+ def run_sis_tc_eval(
98
+ corpus: List[SISTCCorpusEntry],
99
+ classifier_fn: Optional[Callable[[str], float]] = None,
100
+ threshold: float = 0.5,
101
+ ) -> SISTCReport:
102
+ """
103
+ Run SIS-TC evaluation. classifier_fn(prompt_text) -> float score in [0,1] (higher = safer).
104
+ If classifier_fn is None, use placeholder: label_safe -> 1.0, else 0.0 (perfect prediction).
105
+ """
106
+ n_safe = sum(1 for e in corpus if e.label_safe)
107
+ n_unsafe = len(corpus) - n_safe
108
+ if classifier_fn is None:
109
+ scores = [1.0 if e.label_safe else 0.0 for e in corpus] # placeholder: perfect prediction
110
+ else:
111
+ scores = [classifier_fn(e.prompt_text) for e in corpus]
112
+ tp = tn = fp = fn = 0
113
+ for e, s in zip(corpus, scores):
114
+ pred_safe = s >= threshold
115
+ if e.label_safe and pred_safe:
116
+ tp += 1
117
+ elif e.label_safe and not pred_safe:
118
+ fn += 1
119
+ elif not e.label_safe and pred_safe:
120
+ fp += 1
121
+ else:
122
+ tn += 1
123
+ fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0
124
+ fnr = fn / (fn + tp) if (fn + tp) > 0 else 0.0
125
+ return SISTCReport(
126
+ n_total=len(corpus),
127
+ n_safe=n_safe,
128
+ n_unsafe=n_unsafe,
129
+ tp=tp, tn=tn, fp=fp, fn=fn,
130
+ fpr=fpr, fnr=fnr,
131
+ threshold_used=threshold,
132
+ )
133
+
134
+
135
+ @dataclass
136
+ class QuarantineDecision:
137
+ """Result of quarantine decision tree."""
138
+ outcome: str # drop, log, rep_penalty, abstain, exclude
139
+ reason: str
140
+ agent_id: Optional[str] = None
141
+ consecutive_count: Optional[int] = None
142
+
143
+
144
+ def quarantine_decision_tree(
145
+ agent_id: str,
146
+ payload_quarantined: bool,
147
+ valid_peer_count: int,
148
+ min_peers_required: int,
149
+ agent_consecutive_quarantines: int,
150
+ exclude_after_n_consecutive: int = 3,
151
+ ) -> QuarantineDecision:
152
+ """
153
+ Deterministic SIS quarantine decision tree.
154
+ - If insufficient valid peers -> abstain.
155
+ - If agent has >= exclude_after_n_consecutive consecutive quarantines -> exclude.
156
+ - Else if payload quarantined -> rep_penalty (and log). Otherwise no quarantine (drop = do not forward; log = log only).
157
+ """
158
+ if valid_peer_count < min_peers_required:
159
+ return QuarantineDecision(OUTCOME_ABSTAIN, "Insufficient valid peers", agent_id=agent_id)
160
+ if agent_consecutive_quarantines >= exclude_after_n_consecutive:
161
+ return QuarantineDecision(
162
+ OUTCOME_EXCLUDE,
163
+ f"Agent {agent_id} exceeded {exclude_after_n_consecutive} consecutive quarantines",
164
+ agent_id=agent_id,
165
+ consecutive_count=agent_consecutive_quarantines,
166
+ )
167
+ if payload_quarantined:
168
+ return QuarantineDecision(
169
+ OUTCOME_REP_PENALTY,
170
+ "Payload quarantined by classifier",
171
+ agent_id=agent_id,
172
+ )
173
+ return QuarantineDecision(OUTCOME_LOG, "Logged only", agent_id=agent_id)
174
+
175
+
176
+ class QuarantineEventStore:
177
+ """Persist QUARANTINE events for audit (append-only JSONL)."""
178
+ def __init__(self, gcc_dir: Path) -> None:
179
+ self.sis_dir = gcc_dir / SIS_DIR_NAME
180
+ self.log_path = self.sis_dir / SIS_QUARANTINE_LOG_NAME
181
+
182
+ def ensure_dir(self) -> None:
183
+ self.sis_dir.mkdir(parents=True, exist_ok=True)
184
+
185
+ def append(self, decision: QuarantineDecision, timestamp: str, round_id: Optional[int] = None) -> None:
186
+ record = {
187
+ "event_type": QUARANTINE_EVENT_TYPE,
188
+ "timestamp": timestamp,
189
+ "outcome": decision.outcome,
190
+ "reason": decision.reason,
191
+ "agent_id": decision.agent_id,
192
+ "consecutive_count": decision.consecutive_count,
193
+ "round_id": round_id,
194
+ }
195
+ self.ensure_dir()
196
+ with self.log_path.open("a", encoding="utf-8") as f:
197
+ f.write(json.dumps(record, sort_keys=True) + "\n")