delimit-cli 3.14.15 → 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.
@@ -0,0 +1,230 @@
1
+ """Per-model policy scoping — agent-level governance.
2
+
3
+ Allows setting different permissions per AI model:
4
+ - Which tools each model can call
5
+ - Read-only vs read-write access to ledger/memory
6
+ - Deploy permissions per model
7
+ - Custom constraints per agent identity
8
+
9
+ Storage: ~/.delimit/agents/policies.json
10
+
11
+ Feedback origin: Accurate_Mistake_398 on r/ClaudeAI (2026-03-28)
12
+ identified that governance was session-level, not agent-level.
13
+ """
14
+
15
+ import json
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ AGENTS_DIR = Path.home() / ".delimit" / "agents"
21
+ POLICIES_FILE = AGENTS_DIR / "policies.json"
22
+
23
+ # Default permissions — what each model gets if no policy is set
24
+ DEFAULT_PERMISSIONS = {
25
+ "ledger": "read-write",
26
+ "memory": "read-write",
27
+ "deploy": False,
28
+ "lint": True,
29
+ "deliberate": True,
30
+ "security_audit": True,
31
+ "evidence": "read-write",
32
+ "secrets": False,
33
+ }
34
+
35
+ VALID_MODELS = {"claude", "codex", "gemini", "cursor", "any"}
36
+ VALID_ACCESS = {"read-only", "read-write", "none"}
37
+
38
+
39
+ def _ensure_dir():
40
+ AGENTS_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+
43
+ def _load_policies() -> Dict[str, Any]:
44
+ if not POLICIES_FILE.exists():
45
+ return {}
46
+ try:
47
+ return json.loads(POLICIES_FILE.read_text())
48
+ except (json.JSONDecodeError, OSError):
49
+ return {}
50
+
51
+
52
+ def _save_policies(policies: Dict[str, Any]):
53
+ _ensure_dir()
54
+ POLICIES_FILE.write_text(json.dumps(policies, indent=2))
55
+
56
+
57
+ def set_agent_policy(
58
+ model: str,
59
+ ledger: str = "",
60
+ memory: str = "",
61
+ deploy: Optional[bool] = None,
62
+ lint: Optional[bool] = None,
63
+ deliberate: Optional[bool] = None,
64
+ security_audit: Optional[bool] = None,
65
+ evidence: str = "",
66
+ secrets: Optional[bool] = None,
67
+ custom_constraints: Optional[List[str]] = None,
68
+ ) -> Dict[str, Any]:
69
+ """Set permissions for a specific AI model.
70
+
71
+ Example: set_agent_policy("codex", ledger="read-only", deploy=False)
72
+ means Codex can read the ledger but not write, and cannot deploy.
73
+ """
74
+ model = model.lower().strip()
75
+ if model not in VALID_MODELS:
76
+ return {"error": f"model must be one of: {', '.join(sorted(VALID_MODELS))}"}
77
+
78
+ policies = _load_policies()
79
+ existing = policies.get(model, dict(DEFAULT_PERMISSIONS))
80
+
81
+ if ledger and ledger in VALID_ACCESS:
82
+ existing["ledger"] = ledger
83
+ if memory and memory in VALID_ACCESS:
84
+ existing["memory"] = memory
85
+ if evidence and evidence in VALID_ACCESS:
86
+ existing["evidence"] = evidence
87
+ if deploy is not None:
88
+ existing["deploy"] = deploy
89
+ if lint is not None:
90
+ existing["lint"] = lint
91
+ if deliberate is not None:
92
+ existing["deliberate"] = deliberate
93
+ if security_audit is not None:
94
+ existing["security_audit"] = security_audit
95
+ if secrets is not None:
96
+ existing["secrets"] = secrets
97
+ if custom_constraints is not None:
98
+ existing["custom_constraints"] = custom_constraints
99
+
100
+ existing["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
101
+ policies[model] = existing
102
+ _save_policies(policies)
103
+
104
+ return {
105
+ "status": "updated",
106
+ "model": model,
107
+ "policy": existing,
108
+ "message": f"Policy updated for {model}",
109
+ }
110
+
111
+
112
+ def get_agent_policy(model: str = "") -> Dict[str, Any]:
113
+ """Get permissions for a specific model, or all models."""
114
+ policies = _load_policies()
115
+
116
+ if not model or not model.strip():
117
+ # Return all policies with defaults filled in
118
+ all_policies = {}
119
+ for m in VALID_MODELS - {"any"}:
120
+ all_policies[m] = policies.get(m, dict(DEFAULT_PERMISSIONS))
121
+ return {
122
+ "status": "ok",
123
+ "policies": all_policies,
124
+ "default": DEFAULT_PERMISSIONS,
125
+ }
126
+
127
+ model = model.lower().strip()
128
+ if model not in VALID_MODELS:
129
+ return {"error": f"model must be one of: {', '.join(sorted(VALID_MODELS))}"}
130
+
131
+ policy = policies.get(model, dict(DEFAULT_PERMISSIONS))
132
+ return {
133
+ "status": "ok",
134
+ "model": model,
135
+ "policy": policy,
136
+ "is_default": model not in policies,
137
+ }
138
+
139
+
140
+ def check_agent_permission(
141
+ model: str,
142
+ action: str,
143
+ resource: str = "",
144
+ ) -> Dict[str, Any]:
145
+ """Check if a model is allowed to perform an action.
146
+
147
+ Actions: ledger_write, ledger_read, memory_write, memory_read,
148
+ deploy, lint, deliberate, security_audit, evidence_write,
149
+ evidence_read, secrets_read, secrets_write.
150
+
151
+ Returns: {"allowed": bool, "reason": str}
152
+ """
153
+ model = model.lower().strip() if model else "any"
154
+ policies = _load_policies()
155
+ policy = policies.get(model, dict(DEFAULT_PERMISSIONS))
156
+
157
+ action = action.lower().strip()
158
+
159
+ # Parse action into category + operation
160
+ if "_" in action:
161
+ parts = action.split("_", 1)
162
+ category = parts[0]
163
+ operation = parts[1] if len(parts) > 1 else "read"
164
+ else:
165
+ category = action
166
+ operation = "read"
167
+
168
+ # Check access-level permissions (ledger, memory, evidence)
169
+ if category in ("ledger", "memory", "evidence"):
170
+ access = policy.get(category, "read-write")
171
+ if access == "none":
172
+ return {
173
+ "allowed": False,
174
+ "model": model,
175
+ "action": action,
176
+ "reason": f"{model} has no access to {category}",
177
+ }
178
+ if operation == "write" and access == "read-only":
179
+ return {
180
+ "allowed": False,
181
+ "model": model,
182
+ "action": action,
183
+ "reason": f"{model} has read-only access to {category}",
184
+ }
185
+ return {"allowed": True, "model": model, "action": action, "reason": "permitted"}
186
+
187
+ # Check boolean permissions (deploy, lint, etc.)
188
+ if category in ("deploy", "lint", "deliberate", "security_audit", "secrets"):
189
+ key = category.replace("security_", "security_")
190
+ allowed = policy.get(key, DEFAULT_PERMISSIONS.get(key, True))
191
+ if not allowed:
192
+ return {
193
+ "allowed": False,
194
+ "model": model,
195
+ "action": action,
196
+ "reason": f"{model} is not permitted to {category}",
197
+ }
198
+ return {"allowed": True, "model": model, "action": action, "reason": "permitted"}
199
+
200
+ # Check custom constraints
201
+ constraints = policy.get("custom_constraints", [])
202
+ for c in constraints:
203
+ c_lower = c.lower().strip()
204
+ if c_lower.startswith("no-") and c_lower[3:] in action:
205
+ return {
206
+ "allowed": False,
207
+ "model": model,
208
+ "action": action,
209
+ "reason": f"Custom constraint: {c}",
210
+ }
211
+
212
+ return {"allowed": True, "model": model, "action": action, "reason": "no restrictions"}
213
+
214
+
215
+ def remove_agent_policy(model: str) -> Dict[str, Any]:
216
+ """Remove custom policy for a model, reverting to defaults."""
217
+ model = model.lower().strip()
218
+ policies = _load_policies()
219
+
220
+ if model not in policies:
221
+ return {"status": "ok", "message": f"No custom policy for {model} (already using defaults)"}
222
+
223
+ del policies[model]
224
+ _save_policies(policies)
225
+
226
+ return {
227
+ "status": "removed",
228
+ "model": model,
229
+ "message": f"Custom policy removed for {model}. Now using defaults.",
230
+ }
@@ -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
+ }