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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- 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
|
devtorch_core/signing.py
ADDED
|
@@ -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")
|