delimit-cli 3.14.16 → 3.14.17
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.
- package/bin/delimit-cli.js +3 -0
- package/gateway/ai/agent_dispatch.py +458 -0
- package/gateway/ai/agent_policy.py +230 -0
- package/gateway/ai/drift_monitor.py +246 -0
- package/gateway/ai/server.py +791 -38
- package/lib/cross-model-hooks.js +95 -3
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Continuous drift/compliance monitoring — detects API spec changes without governance review.
|
|
2
|
+
|
|
3
|
+
Compares the current spec against the last known baseline and flags:
|
|
4
|
+
- Spec changed without a governance lint run
|
|
5
|
+
- Policy violations that accumulated since last review
|
|
6
|
+
- New endpoints or schemas without documentation
|
|
7
|
+
- Baseline staleness (hasn't been updated in N days)
|
|
8
|
+
|
|
9
|
+
Storage: ~/.delimit/drift/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import hashlib
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
DRIFT_DIR = Path.home() / ".delimit" / "drift"
|
|
19
|
+
HISTORY_FILE = DRIFT_DIR / "history.jsonl"
|
|
20
|
+
BASELINE_FILE = DRIFT_DIR / "baseline_hash.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ensure_dir():
|
|
24
|
+
DRIFT_DIR.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hash_file(path: str) -> str:
|
|
28
|
+
"""SHA256 of a file's contents."""
|
|
29
|
+
try:
|
|
30
|
+
content = Path(path).read_bytes()
|
|
31
|
+
return hashlib.sha256(content).hexdigest()
|
|
32
|
+
except (OSError, FileNotFoundError):
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_baselines() -> Dict[str, Any]:
|
|
37
|
+
if not BASELINE_FILE.exists():
|
|
38
|
+
return {}
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(BASELINE_FILE.read_text())
|
|
41
|
+
except (json.JSONDecodeError, OSError):
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save_baselines(baselines: Dict[str, Any]):
|
|
46
|
+
_ensure_dir()
|
|
47
|
+
BASELINE_FILE.write_text(json.dumps(baselines, indent=2))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _append_history(entry: Dict[str, Any]):
|
|
51
|
+
_ensure_dir()
|
|
52
|
+
entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
53
|
+
with open(HISTORY_FILE, "a") as f:
|
|
54
|
+
f.write(json.dumps(entry) + "\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_drift(
|
|
58
|
+
spec_path: str = "",
|
|
59
|
+
project_path: str = ".",
|
|
60
|
+
staleness_days: int = 7,
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""Check for API spec drift against the last governance baseline.
|
|
63
|
+
|
|
64
|
+
Returns drift findings and compliance status.
|
|
65
|
+
"""
|
|
66
|
+
from pathlib import Path as P
|
|
67
|
+
|
|
68
|
+
project = P(project_path).resolve()
|
|
69
|
+
findings: List[Dict[str, str]] = []
|
|
70
|
+
baselines = _load_baselines()
|
|
71
|
+
|
|
72
|
+
# Auto-detect spec if not provided
|
|
73
|
+
if not spec_path:
|
|
74
|
+
spec_patterns = [
|
|
75
|
+
"openapi.yaml", "openapi.yml", "openapi.json",
|
|
76
|
+
"swagger.yaml", "swagger.yml", "swagger.json",
|
|
77
|
+
"api.yaml", "api.yml", "api.json",
|
|
78
|
+
]
|
|
79
|
+
for pattern in spec_patterns:
|
|
80
|
+
candidate = project / pattern
|
|
81
|
+
if candidate.exists():
|
|
82
|
+
spec_path = str(candidate)
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
# Check .delimit/baseline.yaml (zero-spec)
|
|
86
|
+
if not spec_path:
|
|
87
|
+
baseline_candidate = project / ".delimit" / "baseline.yaml"
|
|
88
|
+
if baseline_candidate.exists():
|
|
89
|
+
spec_path = str(baseline_candidate)
|
|
90
|
+
|
|
91
|
+
if not spec_path:
|
|
92
|
+
return {
|
|
93
|
+
"status": "no_spec",
|
|
94
|
+
"message": "No OpenAPI spec found. Run `delimit init` to set up governance.",
|
|
95
|
+
"drift_detected": False,
|
|
96
|
+
"findings": [],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Check if spec has changed since last baseline
|
|
100
|
+
current_hash = _hash_file(spec_path)
|
|
101
|
+
spec_key = str(P(spec_path).resolve())
|
|
102
|
+
baseline = baselines.get(spec_key, {})
|
|
103
|
+
last_hash = baseline.get("hash", "")
|
|
104
|
+
last_reviewed = baseline.get("last_reviewed", "")
|
|
105
|
+
last_lint_pass = baseline.get("last_lint_pass", False)
|
|
106
|
+
|
|
107
|
+
drift_detected = False
|
|
108
|
+
|
|
109
|
+
if not last_hash:
|
|
110
|
+
# First run — save baseline
|
|
111
|
+
findings.append({
|
|
112
|
+
"type": "new_baseline",
|
|
113
|
+
"severity": "info",
|
|
114
|
+
"message": "First drift check — baseline recorded.",
|
|
115
|
+
})
|
|
116
|
+
baselines[spec_key] = {
|
|
117
|
+
"hash": current_hash,
|
|
118
|
+
"last_reviewed": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
119
|
+
"last_lint_pass": True,
|
|
120
|
+
"spec_path": spec_path,
|
|
121
|
+
}
|
|
122
|
+
_save_baselines(baselines)
|
|
123
|
+
elif current_hash != last_hash:
|
|
124
|
+
drift_detected = True
|
|
125
|
+
findings.append({
|
|
126
|
+
"type": "spec_changed",
|
|
127
|
+
"severity": "warning",
|
|
128
|
+
"message": f"API spec changed since last governance review ({last_reviewed or 'unknown'})",
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
# Check staleness
|
|
132
|
+
if last_reviewed:
|
|
133
|
+
try:
|
|
134
|
+
reviewed_ts = time.mktime(time.strptime(last_reviewed, "%Y-%m-%dT%H:%M:%SZ"))
|
|
135
|
+
age_days = (time.time() - reviewed_ts) / 86400
|
|
136
|
+
if age_days > staleness_days:
|
|
137
|
+
drift_detected = True
|
|
138
|
+
findings.append({
|
|
139
|
+
"type": "stale_baseline",
|
|
140
|
+
"severity": "warning",
|
|
141
|
+
"message": f"Baseline is {int(age_days)} days old (threshold: {staleness_days} days)",
|
|
142
|
+
})
|
|
143
|
+
except (ValueError, OverflowError):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# Check for .delimit/policies.yml existence
|
|
147
|
+
policy_file = project / ".delimit" / "policies.yml"
|
|
148
|
+
if not policy_file.exists():
|
|
149
|
+
findings.append({
|
|
150
|
+
"type": "no_policy",
|
|
151
|
+
"severity": "warning",
|
|
152
|
+
"message": "No policy file found. Run `delimit init` to configure governance.",
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
# Check for evidence directory
|
|
156
|
+
evidence_dir = project / ".delimit" / "evidence"
|
|
157
|
+
if not evidence_dir.exists():
|
|
158
|
+
findings.append({
|
|
159
|
+
"type": "no_evidence",
|
|
160
|
+
"severity": "info",
|
|
161
|
+
"message": "No evidence directory. First governance run will create it.",
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
# Record drift check in history
|
|
165
|
+
_append_history({
|
|
166
|
+
"action": "drift_check",
|
|
167
|
+
"spec_path": spec_path,
|
|
168
|
+
"drift_detected": drift_detected,
|
|
169
|
+
"findings_count": len(findings),
|
|
170
|
+
"current_hash": current_hash[:16],
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"status": "drift" if drift_detected else "clean",
|
|
175
|
+
"drift_detected": drift_detected,
|
|
176
|
+
"spec_path": spec_path,
|
|
177
|
+
"findings": findings,
|
|
178
|
+
"baseline": {
|
|
179
|
+
"hash": last_hash[:16] if last_hash else None,
|
|
180
|
+
"last_reviewed": last_reviewed,
|
|
181
|
+
"current_hash": current_hash[:16],
|
|
182
|
+
},
|
|
183
|
+
"recommendation": (
|
|
184
|
+
"Run `delimit lint` to review spec changes and update the governance baseline."
|
|
185
|
+
if drift_detected else
|
|
186
|
+
"No drift detected. Governance baseline is current."
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def update_baseline(spec_path: str, lint_passed: bool = True) -> Dict[str, Any]:
|
|
192
|
+
"""Update the drift baseline after a successful governance review."""
|
|
193
|
+
from pathlib import Path as P
|
|
194
|
+
|
|
195
|
+
if not spec_path:
|
|
196
|
+
return {"error": "spec_path is required"}
|
|
197
|
+
|
|
198
|
+
current_hash = _hash_file(spec_path)
|
|
199
|
+
if not current_hash:
|
|
200
|
+
return {"error": f"Cannot read spec at {spec_path}"}
|
|
201
|
+
|
|
202
|
+
spec_key = str(P(spec_path).resolve())
|
|
203
|
+
baselines = _load_baselines()
|
|
204
|
+
|
|
205
|
+
baselines[spec_key] = {
|
|
206
|
+
"hash": current_hash,
|
|
207
|
+
"last_reviewed": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
208
|
+
"last_lint_pass": lint_passed,
|
|
209
|
+
"spec_path": spec_path,
|
|
210
|
+
}
|
|
211
|
+
_save_baselines(baselines)
|
|
212
|
+
|
|
213
|
+
_append_history({
|
|
214
|
+
"action": "baseline_update",
|
|
215
|
+
"spec_path": spec_path,
|
|
216
|
+
"hash": current_hash[:16],
|
|
217
|
+
"lint_passed": lint_passed,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"status": "updated",
|
|
222
|
+
"spec_path": spec_path,
|
|
223
|
+
"hash": current_hash[:16],
|
|
224
|
+
"message": "Drift baseline updated.",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_drift_history(limit: int = 20) -> Dict[str, Any]:
|
|
229
|
+
"""Return recent drift check history."""
|
|
230
|
+
entries: List[Dict] = []
|
|
231
|
+
if HISTORY_FILE.exists():
|
|
232
|
+
try:
|
|
233
|
+
lines = HISTORY_FILE.read_text().strip().split("\n")
|
|
234
|
+
for line in lines[-limit:]:
|
|
235
|
+
try:
|
|
236
|
+
entries.append(json.loads(line))
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
pass
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"status": "ok",
|
|
244
|
+
"entries": entries,
|
|
245
|
+
"total": len(entries),
|
|
246
|
+
}
|