delimit-cli 3.14.16 → 3.14.18

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.
@@ -760,6 +760,9 @@ const hookCmd = program
760
760
  case 'pre-commit':
761
761
  await crossModelHooks.hookPreCommit();
762
762
  break;
763
+ case 'deploy-gate':
764
+ await crossModelHooks.hookDeployGate();
765
+ break;
763
766
  default:
764
767
  // Legacy: fall back to agent-based hook evaluation
765
768
  await ensureAgent();
@@ -0,0 +1,458 @@
1
+ """Multi-agent orchestration — dispatch, track, and govern engineering tasks.
2
+
3
+ Agents are tracked via a local task store. Each dispatched task gets a unique
4
+ ID with status tracking. The dispatcher doesn't spawn actual AI processes
5
+ (that's the host AI's job) — it provides the governance layer:
6
+ assignment, tracking, policy enforcement, and handoff protocol.
7
+
8
+ Storage: ~/.delimit/agents/tasks.json
9
+ Audit trail: ~/.delimit/agents/audit.jsonl
10
+ """
11
+
12
+ import json
13
+ import time
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ AGENTS_DIR = Path.home() / ".delimit" / "agents"
19
+ TASKS_FILE = AGENTS_DIR / "tasks.json"
20
+ AUDIT_FILE = AGENTS_DIR / "audit.jsonl"
21
+
22
+ VALID_PRIORITIES = {"P0", "P1", "P2"}
23
+ VALID_ASSIGNEES = {"claude", "codex", "gemini", "any"}
24
+ VALID_STATUSES = {"dispatched", "in_progress", "done", "handed_off", "failed"}
25
+
26
+
27
+ def _ensure_dir():
28
+ """Create the agents directory if it doesn't exist."""
29
+ AGENTS_DIR.mkdir(parents=True, exist_ok=True)
30
+
31
+
32
+ def _load_tasks() -> Dict[str, Any]:
33
+ """Load all tasks from the tasks file."""
34
+ if not TASKS_FILE.exists():
35
+ return {}
36
+ try:
37
+ return json.loads(TASKS_FILE.read_text())
38
+ except (json.JSONDecodeError, OSError):
39
+ return {}
40
+
41
+
42
+ def _save_tasks(tasks: Dict[str, Any]):
43
+ """Write all tasks back to the tasks file."""
44
+ _ensure_dir()
45
+ TASKS_FILE.write_text(json.dumps(tasks, indent=2))
46
+
47
+
48
+ def _append_audit(entry: Dict[str, Any]):
49
+ """Append an entry to the audit trail."""
50
+ _ensure_dir()
51
+ entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
52
+ with open(AUDIT_FILE, "a") as f:
53
+ f.write(json.dumps(entry) + "\n")
54
+
55
+
56
+ def dispatch_task(
57
+ title: str,
58
+ description: str = "",
59
+ assignee: str = "any",
60
+ priority: str = "P1",
61
+ tools_needed: Optional[List[str]] = None,
62
+ constraints: Optional[List[str]] = None,
63
+ context: str = "",
64
+ ) -> Dict[str, Any]:
65
+ """Create a tracked agent task.
66
+
67
+ Returns:
68
+ Dict with task_id, task details, and a structured prompt for the host AI.
69
+ """
70
+ if not title or not title.strip():
71
+ return {"error": "title is required"}
72
+
73
+ assignee = assignee.lower().strip() if assignee else "any"
74
+ if assignee not in VALID_ASSIGNEES:
75
+ return {"error": f"assignee must be one of: {', '.join(sorted(VALID_ASSIGNEES))}"}
76
+
77
+ priority = priority.upper().strip() if priority else "P1"
78
+ if priority not in VALID_PRIORITIES:
79
+ return {"error": f"priority must be one of: {', '.join(sorted(VALID_PRIORITIES))}"}
80
+
81
+ task_id = f"AGT-{uuid.uuid4().hex[:8].upper()}"
82
+
83
+ task = {
84
+ "id": task_id,
85
+ "title": title.strip(),
86
+ "description": description.strip(),
87
+ "assignee": assignee,
88
+ "priority": priority,
89
+ "tools_needed": tools_needed or [],
90
+ "constraints": constraints or [],
91
+ "context": context.strip(),
92
+ "status": "dispatched",
93
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
94
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
95
+ "files_changed": [],
96
+ "result": "",
97
+ "handoffs": [],
98
+ }
99
+
100
+ tasks = _load_tasks()
101
+ tasks[task_id] = task
102
+ _save_tasks(tasks)
103
+
104
+ _append_audit({
105
+ "action": "dispatch",
106
+ "task_id": task_id,
107
+ "title": title.strip(),
108
+ "assignee": assignee,
109
+ "priority": priority,
110
+ })
111
+
112
+ # Build a structured prompt that the host AI can pass to a subagent
113
+ prompt = _build_agent_prompt(task)
114
+
115
+ return {
116
+ "status": "dispatched",
117
+ "task_id": task_id,
118
+ "task": task,
119
+ "agent_prompt": prompt,
120
+ "message": f"Task {task_id} dispatched to {assignee} ({priority})",
121
+ }
122
+
123
+
124
+ def _build_agent_prompt(task: Dict[str, Any]) -> str:
125
+ """Build a structured prompt for a subagent to execute the task."""
126
+ lines = [
127
+ f"## Agent Task: {task['id']}",
128
+ f"**Title:** {task['title']}",
129
+ ]
130
+ if task.get("description"):
131
+ lines.append(f"**Description:** {task['description']}")
132
+ lines.append(f"**Priority:** {task['priority']}")
133
+ lines.append(f"**Assignee:** {task['assignee']}")
134
+
135
+ if task.get("context"):
136
+ lines.append(f"\n**Context:**\n{task['context']}")
137
+
138
+ if task.get("tools_needed"):
139
+ lines.append(f"\n**Tools needed:** {', '.join(task['tools_needed'])}")
140
+
141
+ if task.get("constraints"):
142
+ lines.append(f"\n**Constraints:**")
143
+ for c in task["constraints"]:
144
+ lines.append(f"- {c}")
145
+
146
+ lines.append(f"\n**When done:** Call `delimit_agent_complete` with task_id='{task['id']}' and your result.")
147
+
148
+ return "\n".join(lines)
149
+
150
+
151
+ def get_agent_status(task_id: str = "") -> Dict[str, Any]:
152
+ """Get the status of a specific task, or list all active tasks."""
153
+ tasks = _load_tasks()
154
+
155
+ if not task_id or not task_id.strip():
156
+ return list_active_agents()
157
+
158
+ task_id = task_id.strip().upper()
159
+ if task_id not in tasks:
160
+ return {"error": f"Task {task_id} not found"}
161
+
162
+ return {
163
+ "status": "ok",
164
+ "task": tasks[task_id],
165
+ }
166
+
167
+
168
+ def list_active_agents() -> Dict[str, Any]:
169
+ """Return all tasks that are not done or failed."""
170
+ tasks = _load_tasks()
171
+ active = {
172
+ tid: t for tid, t in tasks.items()
173
+ if t.get("status") in ("dispatched", "in_progress", "handed_off")
174
+ }
175
+ completed = {
176
+ tid: t for tid, t in tasks.items()
177
+ if t.get("status") in ("done", "failed")
178
+ }
179
+
180
+ return {
181
+ "status": "ok",
182
+ "active_count": len(active),
183
+ "completed_count": len(completed),
184
+ "active_tasks": list(active.values()),
185
+ "summary": [
186
+ {"id": t["id"], "title": t["title"], "status": t["status"],
187
+ "assignee": t["assignee"], "priority": t["priority"]}
188
+ for t in active.values()
189
+ ],
190
+ }
191
+
192
+
193
+ def complete_task(
194
+ task_id: str,
195
+ result: str = "",
196
+ files_changed: Optional[List[str]] = None,
197
+ ) -> Dict[str, Any]:
198
+ """Mark a dispatched task as done."""
199
+ if not task_id or not task_id.strip():
200
+ return {"error": "task_id is required"}
201
+
202
+ task_id = task_id.strip().upper()
203
+ tasks = _load_tasks()
204
+
205
+ if task_id not in tasks:
206
+ return {"error": f"Task {task_id} not found"}
207
+
208
+ task = tasks[task_id]
209
+ if task["status"] == "done":
210
+ return {"error": f"Task {task_id} is already marked done"}
211
+
212
+ task["status"] = "done"
213
+ task["result"] = result.strip()
214
+ task["files_changed"] = files_changed or []
215
+ task["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
216
+ task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
217
+
218
+ tasks[task_id] = task
219
+ _save_tasks(tasks)
220
+
221
+ _append_audit({
222
+ "action": "complete",
223
+ "task_id": task_id,
224
+ "result": result.strip()[:200],
225
+ "files_changed": files_changed or [],
226
+ })
227
+
228
+ return {
229
+ "status": "completed",
230
+ "task_id": task_id,
231
+ "task": task,
232
+ "message": f"Task {task_id} marked as done",
233
+ }
234
+
235
+
236
+ def handoff_task(
237
+ task_id: str,
238
+ to_model: str,
239
+ context: str = "",
240
+ ) -> Dict[str, Any]:
241
+ """Transfer a task from one AI model to another."""
242
+ if not task_id or not task_id.strip():
243
+ return {"error": "task_id is required"}
244
+ if not to_model or not to_model.strip():
245
+ return {"error": "to_model is required"}
246
+
247
+ task_id = task_id.strip().upper()
248
+ to_model = to_model.lower().strip()
249
+
250
+ if to_model not in VALID_ASSIGNEES - {"any"}:
251
+ return {"error": f"to_model must be one of: {', '.join(sorted(VALID_ASSIGNEES - {'any'}))}"}
252
+
253
+ tasks = _load_tasks()
254
+ if task_id not in tasks:
255
+ return {"error": f"Task {task_id} not found"}
256
+
257
+ task = tasks[task_id]
258
+ if task["status"] == "done":
259
+ return {"error": f"Task {task_id} is already done, cannot hand off"}
260
+
261
+ from_model = task["assignee"]
262
+ task["handoffs"].append({
263
+ "from": from_model,
264
+ "to": to_model,
265
+ "context": context.strip(),
266
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
267
+ })
268
+ task["assignee"] = to_model
269
+ task["status"] = "handed_off"
270
+ task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
271
+
272
+ # Rebuild the prompt with handoff context
273
+ if context.strip():
274
+ task["context"] = (task.get("context", "") + "\n\n---\n**Handoff context from " +
275
+ from_model + ":**\n" + context.strip()).strip()
276
+
277
+ tasks[task_id] = task
278
+ _save_tasks(tasks)
279
+
280
+ _append_audit({
281
+ "action": "handoff",
282
+ "task_id": task_id,
283
+ "from": from_model,
284
+ "to": to_model,
285
+ "context_snippet": context.strip()[:200],
286
+ })
287
+
288
+ prompt = _build_agent_prompt(task)
289
+
290
+ return {
291
+ "status": "handed_off",
292
+ "task_id": task_id,
293
+ "from_model": from_model,
294
+ "to_model": to_model,
295
+ "task": task,
296
+ "agent_prompt": prompt,
297
+ "message": f"Task {task_id} handed off from {from_model} to {to_model}",
298
+ }
299
+
300
+
301
+ def enforce_constraints(task_id: str, action: str) -> Dict[str, Any]:
302
+ """Check if an action is allowed given the task's constraints.
303
+
304
+ Returns:
305
+ Dict with 'allowed' (bool) and 'reason' if denied.
306
+ """
307
+ if not task_id or not task_id.strip():
308
+ return {"allowed": True, "reason": "No task_id provided, no constraints to check"}
309
+
310
+ task_id = task_id.strip().upper()
311
+ tasks = _load_tasks()
312
+
313
+ if task_id not in tasks:
314
+ return {"allowed": True, "reason": f"Task {task_id} not found, defaulting to allow"}
315
+
316
+ task = tasks[task_id]
317
+ constraints = task.get("constraints", [])
318
+
319
+ if not constraints:
320
+ return {"allowed": True, "reason": "No constraints on this task"}
321
+
322
+ action_lower = action.lower().strip() if action else ""
323
+
324
+ # Check each constraint against the action
325
+ for constraint in constraints:
326
+ c = constraint.lower().strip()
327
+
328
+ if c == "read-only":
329
+ write_keywords = ["write", "edit", "create", "delete", "modify", "deploy", "push", "commit"]
330
+ if any(kw in action_lower for kw in write_keywords):
331
+ return {
332
+ "allowed": False,
333
+ "reason": f"Constraint 'read-only' blocks action: {action}",
334
+ "constraint": constraint,
335
+ }
336
+
337
+ elif c == "no-deploy" or c == "no-deploys":
338
+ deploy_keywords = ["deploy", "publish", "release", "rollback"]
339
+ if any(kw in action_lower for kw in deploy_keywords):
340
+ return {
341
+ "allowed": False,
342
+ "reason": f"Constraint '{constraint}' blocks action: {action}",
343
+ "constraint": constraint,
344
+ }
345
+
346
+ elif c == "must-lint" or c == "must_lint":
347
+ # This is an affirmative constraint — doesn't block, just flags
348
+ pass
349
+
350
+ elif c.startswith("no-"):
351
+ # Generic "no-X" constraint
352
+ blocked = c[3:]
353
+ if blocked in action_lower:
354
+ return {
355
+ "allowed": False,
356
+ "reason": f"Constraint '{constraint}' blocks action: {action}",
357
+ "constraint": constraint,
358
+ }
359
+
360
+ return {"allowed": True, "reason": "All constraints passed"}
361
+
362
+
363
+ def link_ledger_item(task_id: str, ledger_item_id: str) -> Dict[str, Any]:
364
+ """Link a dispatched agent task to a ledger item (LED-xxx or STR-xxx).
365
+
366
+ This creates a bidirectional relationship so the dashboard can show
367
+ which agent is working on which ledger item.
368
+ """
369
+ if not task_id or not task_id.strip():
370
+ return {"error": "task_id is required"}
371
+ if not ledger_item_id or not ledger_item_id.strip():
372
+ return {"error": "ledger_item_id is required"}
373
+
374
+ task_id = task_id.strip().upper()
375
+ ledger_item_id = ledger_item_id.strip().upper()
376
+
377
+ tasks = _load_tasks()
378
+ if task_id not in tasks:
379
+ return {"error": f"Task {task_id} not found"}
380
+
381
+ task = tasks[task_id]
382
+ linked = task.get("linked_ledger_items", [])
383
+ if ledger_item_id not in linked:
384
+ linked.append(ledger_item_id)
385
+ task["linked_ledger_items"] = linked
386
+ task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
387
+
388
+ tasks[task_id] = task
389
+ _save_tasks(tasks)
390
+
391
+ _append_audit({
392
+ "action": "link_ledger",
393
+ "task_id": task_id,
394
+ "ledger_item_id": ledger_item_id,
395
+ })
396
+
397
+ return {
398
+ "status": "linked",
399
+ "task_id": task_id,
400
+ "ledger_item_id": ledger_item_id,
401
+ "all_linked": linked,
402
+ "message": f"Task {task_id} linked to {ledger_item_id}",
403
+ }
404
+
405
+
406
+ def get_agent_dashboard() -> Dict[str, Any]:
407
+ """Return a full dashboard view of all agent activity.
408
+
409
+ Groups tasks by status and assignee, includes audit trail summary,
410
+ and handoff history.
411
+ """
412
+ tasks = _load_tasks()
413
+
414
+ by_assignee: Dict[str, List[Dict]] = {}
415
+ by_status: Dict[str, int] = {}
416
+ handoff_count = 0
417
+
418
+ for t in tasks.values():
419
+ assignee = t.get("assignee", "unknown")
420
+ status = t.get("status", "unknown")
421
+ by_assignee.setdefault(assignee, []).append(t)
422
+ by_status[status] = by_status.get(status, 0) + 1
423
+ handoff_count += len(t.get("handoffs", []))
424
+
425
+ # Recent audit entries
426
+ recent_audit: List[Dict] = []
427
+ if AUDIT_FILE.exists():
428
+ try:
429
+ lines = AUDIT_FILE.read_text().strip().split("\n")
430
+ for line in lines[-20:]:
431
+ try:
432
+ recent_audit.append(json.loads(line))
433
+ except json.JSONDecodeError:
434
+ pass
435
+ except OSError:
436
+ pass
437
+
438
+ return {
439
+ "status": "ok",
440
+ "total_tasks": len(tasks),
441
+ "by_status": by_status,
442
+ "by_assignee": {
443
+ model: {
444
+ "total": len(model_tasks),
445
+ "active": sum(1 for t in model_tasks if t["status"] in ("dispatched", "in_progress", "handed_off")),
446
+ "done": sum(1 for t in model_tasks if t["status"] == "done"),
447
+ "tasks": [
448
+ {"id": t["id"], "title": t["title"], "status": t["status"],
449
+ "priority": t.get("priority", "P1"),
450
+ "linked_ledger": t.get("linked_ledger_items", [])}
451
+ for t in model_tasks
452
+ ],
453
+ }
454
+ for model, model_tasks in by_assignee.items()
455
+ },
456
+ "handoff_count": handoff_count,
457
+ "recent_audit": recent_audit,
458
+ }
@@ -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
+ }