delimit-cli 3.14.27 → 3.14.29

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 (48) hide show
  1. package/bin/delimit-setup.js +19 -2
  2. package/gateway/ai/backends/deploy_bridge.py +56 -2
  3. package/gateway/ai/backends/gateway_core.py +212 -1
  4. package/gateway/ai/backends/generate_bridge.py +84 -13
  5. package/gateway/ai/backends/governance_bridge.py +63 -16
  6. package/gateway/ai/backends/memory_bridge.py +77 -76
  7. package/gateway/ai/backends/ops_bridge.py +76 -6
  8. package/gateway/ai/backends/os_bridge.py +23 -3
  9. package/gateway/ai/backends/repo_bridge.py +156 -17
  10. package/gateway/ai/backends/tools_design.py +116 -9
  11. package/gateway/ai/backends/tools_infra.py +200 -72
  12. package/gateway/ai/backends/tools_real.py +8 -0
  13. package/gateway/ai/backends/ui_bridge.py +115 -5
  14. package/gateway/ai/backends/vault_bridge.py +69 -114
  15. package/gateway/ai/content_engine.py +1276 -0
  16. package/gateway/ai/context_fs.py +193 -0
  17. package/gateway/ai/daemon.py +500 -0
  18. package/gateway/ai/data_plane.py +291 -0
  19. package/gateway/ai/deliberation.py +1033 -6
  20. package/gateway/ai/events.py +39 -0
  21. package/gateway/ai/founding_users.py +162 -0
  22. package/gateway/ai/governance.py +698 -4
  23. package/gateway/ai/inbox_daemon.py +78 -17
  24. package/gateway/ai/integrations/__init__.py +1 -0
  25. package/gateway/ai/integrations/opensage_wrapper.py +288 -0
  26. package/gateway/ai/key_resolver.py +95 -0
  27. package/gateway/ai/ledger_manager.py +289 -1
  28. package/gateway/ai/license.py +62 -4
  29. package/gateway/ai/license_core.py +208 -7
  30. package/gateway/ai/local_server.py +215 -0
  31. package/gateway/ai/loop_engine.py +408 -0
  32. package/gateway/ai/mcp_bridge.py +178 -0
  33. package/gateway/ai/release_sync.py +2 -2
  34. package/gateway/ai/screen_record.py +374 -0
  35. package/gateway/ai/secrets_broker.py +235 -0
  36. package/gateway/ai/social.py +189 -27
  37. package/gateway/ai/social_target.py +1635 -0
  38. package/gateway/ai/supabase_sync.py +190 -0
  39. package/gateway/ai/tracing.py +195 -0
  40. package/gateway/core/contract_ledger.py +1 -1
  41. package/gateway/core/dependency_graph.py +1 -1
  42. package/gateway/core/dependency_manifest.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +272 -78
  44. package/gateway/core/event_backbone.py +2 -2
  45. package/gateway/core/event_schema.py +1 -1
  46. package/gateway/core/impact_analyzer.py +1 -1
  47. package/gateway/core/policy_engine.py +4 -0
  48. package/package.json +1 -1
@@ -4,7 +4,8 @@ Inbox polling daemon for Delimit's email governance system.
4
4
  Polls pro@delimit.ai via IMAP every 5 minutes, auto-classifies emails,
5
5
  forwards owner-action items, and handles draft approval via email replies.
6
6
 
7
- Consensus 116: Standalone daemon, fresh IMAP connections, 10-minute cancel window.
7
+ Consensus 116: Standalone daemon, fresh IMAP connections.
8
+ Auto-posting DISABLED — all approved drafts are emailed for manual posting.
8
9
 
9
10
  Can run as:
10
11
  - Standalone script: python inbox_daemon.py
@@ -127,6 +128,44 @@ _daemon_state = InboxDaemonState()
127
128
 
128
129
  # ── Logging ──────────────────────────────────────────────────────────
129
130
 
131
+ def get_pending_directives() -> List[Dict]:
132
+ """Get founder directives that haven't been completed yet."""
133
+ directives = []
134
+ if not ROUTING_LOG.exists():
135
+ return directives
136
+ completed = set()
137
+ for line in ROUTING_LOG.read_text().splitlines():
138
+ try:
139
+ entry = json.loads(line)
140
+ if entry.get("event") == "founder_directive_completed":
141
+ completed.add(entry.get("directive_subject", ""))
142
+ elif entry.get("event") == "founder_directive_received":
143
+ directives.append(entry)
144
+ except (json.JSONDecodeError, KeyError):
145
+ continue
146
+ return [d for d in directives if d.get("subject", "") not in completed]
147
+
148
+
149
+ def complete_directive(subject: str, result: str) -> None:
150
+ """Mark a founder directive as completed and send confirmation email."""
151
+ _log_routing({
152
+ "event": "founder_directive_completed",
153
+ "directive_subject": subject,
154
+ "result": result,
155
+ })
156
+ send_email(
157
+ to=FORWARD_TO,
158
+ subject=f"[COMPLETED] {subject}",
159
+ body=(
160
+ f"Your directive has been completed.\n\n"
161
+ f"Subject: {subject}\n"
162
+ f"Result: {result}\n"
163
+ ),
164
+ from_account="pro@delimit.ai",
165
+ event_type="founder_directive_completed",
166
+ )
167
+
168
+
130
169
  def _log_routing(entry: Dict[str, Any]) -> None:
131
170
  """Append a routing decision to the audit trail."""
132
171
  try:
@@ -367,22 +406,18 @@ def poll_once() -> Dict[str, Any]:
367
406
 
368
407
  elif draft_id and detect_approval_keywords(body_text):
369
408
  # Both draft match AND approval keyword required (consensus)
370
- mark_draft_status(draft_id, "approved-pending")
371
- _daemon_state.add_pending_approval(draft_id, {
372
- "approved_at": datetime.now(timezone.utc).isoformat(),
373
- "approved_at_ts": time.time(),
374
- })
375
- # Send cancel-window notification
409
+ # Auto-posting is DISABLED — mark approved and confirm via email
410
+ mark_draft_status(draft_id, "approved")
376
411
  send_email(
377
412
  to=FORWARD_TO,
378
- subject=f"Draft {draft_id} approved - posting in 10 minutes",
413
+ subject=f"Draft {draft_id} approved - ready for manual posting",
379
414
  body=(
380
415
  f"Draft {draft_id} has been approved via email reply.\n\n"
381
- f"It will be posted in 10 minutes.\n\n"
382
- f"Reply CANCEL to this email to stop the post."
416
+ f"Auto-posting is disabled. Please post manually.\n\n"
417
+ f"The draft text was included in the original notification email."
383
418
  ),
384
419
  from_account="pro@delimit.ai",
385
- event_type="draft_approval_window",
420
+ event_type="draft_approval_confirmed",
386
421
  )
387
422
  action_taken = "approval_detected"
388
423
  approvals.append(draft_id)
@@ -390,11 +425,37 @@ def poll_once() -> Dict[str, Any]:
390
425
  imap.store(msg_id, "+FLAGS", "\\Seen")
391
426
 
392
427
  elif classification == "owner-action":
393
- success = _forward_email(msg, smtp_pass)
394
- if success:
428
+ # Emails FROM the founder are decisions/action items — acknowledge and track
429
+ if sender_addr.lower() == FORWARD_TO.lower():
430
+ # Create a ledger item to track the founder's directive
431
+ _directive_summary = subject[:80] if subject else body_text[:80]
432
+ _log_routing({
433
+ "event": "founder_directive_received",
434
+ "subject": subject,
435
+ "body_preview": body_text[:200],
436
+ "timestamp": datetime.now(timezone.utc).isoformat(),
437
+ })
438
+ # Acknowledge receipt
439
+ send_email(
440
+ to=FORWARD_TO,
441
+ subject=f"[RECEIVED] {subject}",
442
+ body=(
443
+ f"Your directive has been received and logged.\n\n"
444
+ f"Subject: {subject}\n"
445
+ f"Status: PENDING — will be processed in the next loop iteration.\n\n"
446
+ f"You'll receive a [COMPLETED] email when this is done."
447
+ ),
448
+ from_account="pro@delimit.ai",
449
+ event_type="founder_directive_ack",
450
+ )
395
451
  imap.store(msg_id, "+FLAGS", "\\Seen")
396
- forwarded += 1
397
- action_taken = "forwarded" if success else "forward_failed"
452
+ action_taken = "founder_directive_acked"
453
+ else:
454
+ success = _forward_email(msg, smtp_pass)
455
+ if success:
456
+ imap.store(msg_id, "+FLAGS", "\\Seen")
457
+ forwarded += 1
458
+ action_taken = "forwarded" if success else "forward_failed"
398
459
 
399
460
  else:
400
461
  # Non-owner, mark as seen
@@ -414,8 +475,8 @@ def poll_once() -> Dict[str, Any]:
414
475
 
415
476
  imap.logout()
416
477
 
417
- # Process pending approval windows (post drafts past the cancel window)
418
- posted_drafts = _process_pending_approvals()
478
+ # Auto-posting disabled no cancel window processing
479
+ posted_drafts = []
419
480
 
420
481
  _daemon_state.record_success(processed, forwarded)
421
482
 
@@ -0,0 +1 @@
1
+ """Delimit integrations with external agent frameworks."""
@@ -0,0 +1,288 @@
1
+ """
2
+ Delimit GovernanceWrapper for OpenSage Agent Framework.
3
+
4
+ Middleware that intercepts OpenSage agent tool calls and routes them
5
+ through Delimit's policy kernel for governance enforcement.
6
+
7
+ Usage:
8
+ from delimit.integrations.opensage import GovernanceWrapper
9
+
10
+ # Wrap an OpenSage agent with governance
11
+ agent = OpenSageAgent(config)
12
+ governed_agent = GovernanceWrapper(agent)
13
+
14
+ # Or use as a feature plugin
15
+ agent = OpenSageAgent(config, features=[GovernanceWrapper()])
16
+
17
+ Integration points:
18
+ 1. Pre-tool: validate tool call against policy before execution
19
+ 2. Post-tool: audit trail + ledger tracking
20
+ 3. Tool creation: validate agent-created tools before registration
21
+ 4. Session: persistent context across agent runs
22
+
23
+ Requires: delimit-mcp >= 3.2.1
24
+ """
25
+
26
+ import json
27
+ import logging
28
+ import time
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any, Callable, Dict, List, Optional
32
+
33
+ logger = logging.getLogger("delimit.integrations.opensage")
34
+
35
+ DELIMIT_HOME = Path.home() / ".delimit"
36
+ AUDIT_DIR = DELIMIT_HOME / "audit"
37
+ POLICY_FILE = DELIMIT_HOME / "enforcement_mode"
38
+
39
+
40
+ def _get_mode() -> str:
41
+ """Read current enforcement mode."""
42
+ try:
43
+ return POLICY_FILE.read_text().strip()
44
+ except Exception:
45
+ return "guarded"
46
+
47
+
48
+ def _classify_tool_risk(tool_name: str, args: dict) -> str:
49
+ """Classify risk level of a tool call."""
50
+ HIGH_RISK_PATTERNS = [
51
+ "deploy", "publish", "push", "delete", "remove", "drop",
52
+ "exec", "shell", "run_command", "write_file",
53
+ ]
54
+ CRITICAL_PATTERNS = [
55
+ "rm_rf", "force_push", "drop_table", "revoke",
56
+ ]
57
+
58
+ name_lower = tool_name.lower()
59
+ for pattern in CRITICAL_PATTERNS:
60
+ if pattern in name_lower:
61
+ return "critical"
62
+ for pattern in HIGH_RISK_PATTERNS:
63
+ if pattern in name_lower:
64
+ return "high"
65
+ return "low"
66
+
67
+
68
+ def _check_sensitive_paths(args: dict) -> Optional[str]:
69
+ """Check if any argument references a sensitive path."""
70
+ SENSITIVE = ["/etc/", "/.ssh/", "/.aws/", "/credentials/", "/.env"]
71
+ for key, value in args.items():
72
+ if isinstance(value, str):
73
+ for pattern in SENSITIVE:
74
+ if pattern in value:
75
+ return f"Argument '{key}' references sensitive path: {pattern}"
76
+ return None
77
+
78
+
79
+ def _audit_log(entry: dict, audit_dir: Optional[Path] = None) -> None:
80
+ """Append to audit trail."""
81
+ try:
82
+ d = audit_dir or AUDIT_DIR
83
+ d.mkdir(parents=True, exist_ok=True)
84
+ date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
85
+ audit_file = d / f"opensage-{date}.jsonl"
86
+ with open(audit_file, "a") as f:
87
+ f.write(json.dumps(entry, default=str) + "\n")
88
+ except Exception:
89
+ pass
90
+
91
+
92
+ class GovernancePolicy:
93
+ """Policy rules for OpenSage tool governance."""
94
+
95
+ def __init__(self, rules: Optional[List[dict]] = None, mode: Optional[str] = None):
96
+ self.rules = rules or []
97
+ self._mode_override = mode
98
+
99
+ def check(self, tool_name: str, args: dict) -> dict:
100
+ """Check a tool call against all policy rules.
101
+
102
+ Returns:
103
+ {"allowed": True} or {"allowed": False, "reason": "...", "rule": "..."}
104
+ """
105
+ mode = self._mode_override or _get_mode()
106
+
107
+ if mode == "advisory":
108
+ return {"allowed": True, "mode": mode}
109
+
110
+ # Check sensitive paths
111
+ path_issue = _check_sensitive_paths(args)
112
+ if path_issue:
113
+ if mode == "enforce":
114
+ return {"allowed": False, "reason": path_issue, "mode": mode}
115
+ logger.warning("Governance warning: %s", path_issue)
116
+
117
+ # Check risk level
118
+ risk = _classify_tool_risk(tool_name, args)
119
+
120
+ if risk == "critical":
121
+ return {
122
+ "allowed": False,
123
+ "reason": f"Critical action '{tool_name}' requires approval",
124
+ "risk": risk,
125
+ "mode": mode,
126
+ }
127
+
128
+ if risk == "high" and mode == "enforce":
129
+ return {
130
+ "allowed": False,
131
+ "reason": f"High-risk action '{tool_name}' blocked in enforce mode",
132
+ "risk": risk,
133
+ "mode": mode,
134
+ }
135
+
136
+ # Check custom rules
137
+ for rule in self.rules:
138
+ if rule.get("tool_pattern") and rule["tool_pattern"] in tool_name:
139
+ if rule.get("action") == "forbid":
140
+ return {
141
+ "allowed": False,
142
+ "reason": rule.get("message", f"Rule '{rule.get('name')}' blocks this"),
143
+ "rule": rule.get("name"),
144
+ }
145
+
146
+ return {"allowed": True, "risk": risk, "mode": mode}
147
+
148
+
149
+ class GovernanceWrapper:
150
+ """Wraps OpenSage agent tool execution with Delimit governance.
151
+
152
+ Can be used as:
153
+ 1. A wrapper around an existing agent
154
+ 2. A standalone policy checker
155
+ 3. A feature plugin for OpenSage
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ policy: Optional[GovernancePolicy] = None,
161
+ audit: bool = True,
162
+ ledger_tracking: bool = True,
163
+ audit_dir: Optional[Path] = None,
164
+ ):
165
+ self.policy = policy or GovernancePolicy()
166
+ self.audit = audit
167
+ self.ledger_tracking = ledger_tracking
168
+ self.audit_dir = audit_dir
169
+ self._call_count = 0
170
+ self._blocked_count = 0
171
+
172
+ def pre_tool_call(self, tool_name: str, args: dict) -> dict:
173
+ """Check policy before a tool executes.
174
+
175
+ Returns:
176
+ {"proceed": True} or {"proceed": False, "reason": "..."}
177
+ """
178
+ self._call_count += 1
179
+ check = self.policy.check(tool_name, args)
180
+
181
+ if self.audit:
182
+ _audit_log({
183
+ "event": "pre_tool_call",
184
+ "tool": tool_name,
185
+ "args_summary": {k: str(v)[:100] for k, v in args.items()},
186
+ "decision": "allowed" if check["allowed"] else "blocked",
187
+ "reason": check.get("reason"),
188
+ "risk": check.get("risk", "low"),
189
+ "mode": check.get("mode", "unknown"),
190
+ "timestamp": datetime.now(timezone.utc).isoformat(),
191
+ }, audit_dir=self.audit_dir)
192
+
193
+ if not check["allowed"]:
194
+ self._blocked_count += 1
195
+ return {"proceed": False, "reason": check["reason"]}
196
+
197
+ return {"proceed": True, "risk": check.get("risk", "low")}
198
+
199
+ def post_tool_call(self, tool_name: str, args: dict, result: Any, duration_ms: int) -> None:
200
+ """Record tool execution in audit trail."""
201
+ if self.audit:
202
+ _audit_log({
203
+ "event": "post_tool_call",
204
+ "tool": tool_name,
205
+ "duration_ms": duration_ms,
206
+ "success": not isinstance(result, Exception),
207
+ "timestamp": datetime.now(timezone.utc).isoformat(),
208
+ }, audit_dir=self.audit_dir)
209
+
210
+ def validate_tool_creation(self, tool_name: str, tool_schema: dict) -> dict:
211
+ """Validate an agent-created tool before registration.
212
+
213
+ Checks:
214
+ - Tool name doesn't conflict with system tools
215
+ - Schema doesn't expose sensitive parameters
216
+ - Tool doesn't request unrestricted permissions
217
+
218
+ Returns:
219
+ {"valid": True} or {"valid": False, "reason": "..."}
220
+ """
221
+ RESERVED_PREFIXES = ["system_", "admin_", "delimit_", "opensage_"]
222
+
223
+ for prefix in RESERVED_PREFIXES:
224
+ if tool_name.startswith(prefix):
225
+ return {
226
+ "valid": False,
227
+ "reason": f"Tool name '{tool_name}' uses reserved prefix '{prefix}'",
228
+ }
229
+
230
+ # Check for sensitive parameter names
231
+ params = tool_schema.get("parameters", {}).get("properties", {})
232
+ SENSITIVE_PARAMS = ["password", "secret", "token", "api_key", "private_key"]
233
+ for param_name in params:
234
+ if any(s in param_name.lower() for s in SENSITIVE_PARAMS):
235
+ return {
236
+ "valid": False,
237
+ "reason": f"Tool parameter '{param_name}' exposes sensitive data",
238
+ }
239
+
240
+ if self.audit:
241
+ _audit_log({
242
+ "event": "tool_creation_validated",
243
+ "tool": tool_name,
244
+ "schema_keys": list(tool_schema.keys()),
245
+ "param_count": len(params),
246
+ "timestamp": datetime.now(timezone.utc).isoformat(),
247
+ }, audit_dir=self.audit_dir)
248
+
249
+ return {"valid": True}
250
+
251
+ def wrap_tool(self, tool_fn: Callable) -> Callable:
252
+ """Wrap a tool function with governance checks.
253
+
254
+ Usage:
255
+ original_tool = agent.get_tool("my_tool")
256
+ governed_tool = wrapper.wrap_tool(original_tool)
257
+ """
258
+ def governed_tool(*args, **kwargs):
259
+ tool_name = getattr(tool_fn, "__name__", "unknown")
260
+ check = self.pre_tool_call(tool_name, kwargs)
261
+
262
+ if not check["proceed"]:
263
+ return {"error": "governance_blocked", "reason": check["reason"]}
264
+
265
+ start = time.time()
266
+ try:
267
+ result = tool_fn(*args, **kwargs)
268
+ duration_ms = int((time.time() - start) * 1000)
269
+ self.post_tool_call(tool_name, kwargs, result, duration_ms)
270
+ return result
271
+ except Exception as e:
272
+ duration_ms = int((time.time() - start) * 1000)
273
+ self.post_tool_call(tool_name, kwargs, e, duration_ms)
274
+ raise
275
+
276
+ governed_tool.__name__ = getattr(tool_fn, "__name__", "unknown")
277
+ governed_tool.__doc__ = getattr(tool_fn, "__doc__", "")
278
+ governed_tool._governance_wrapped = True
279
+ return governed_tool
280
+
281
+ def get_stats(self) -> dict:
282
+ """Return governance statistics for this session."""
283
+ return {
284
+ "total_calls": self._call_count,
285
+ "blocked_calls": self._blocked_count,
286
+ "block_rate": f"{(self._blocked_count / max(self._call_count, 1)) * 100:.1f}%",
287
+ "mode": _get_mode(),
288
+ }
@@ -0,0 +1,95 @@
1
+ """Auto-resolve API keys from multiple sources.
2
+
3
+ Priority: env var -> secrets broker -> return None (free fallback).
4
+
5
+ Every MCP tool that depends on an external service should use this module
6
+ so it works out of the box without API keys, with enhanced functionality
7
+ unlocked when keys are available.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Optional, Tuple
17
+
18
+ logger = logging.getLogger("delimit.ai.key_resolver")
19
+
20
+ SECRETS_DIR = Path.home() / ".delimit" / "secrets"
21
+
22
+
23
+ def get_key(name: str, env_var: str = "", _secrets_dir: Optional[Path] = None) -> Tuple[Optional[str], str]:
24
+ """Get an API key. Returns (key, source) or (None, "not_found").
25
+
26
+ Sources checked in order:
27
+ 1. Environment variable (explicit *env_var*, then common conventions)
28
+ 2. ~/.delimit/secrets/{name}.json
29
+ 3. None (free fallback)
30
+ """
31
+ # 1. Env var — explicit, then common patterns
32
+ candidates = [env_var] if env_var else []
33
+ candidates += [
34
+ f"{name.upper()}_TOKEN",
35
+ f"{name.upper()}_API_KEY",
36
+ f"{name.upper()}_KEY",
37
+ ]
38
+ for var in candidates:
39
+ if not var:
40
+ continue
41
+ val = os.environ.get(var)
42
+ if val:
43
+ return val, "env"
44
+
45
+ # 2. Secrets broker
46
+ secrets_dir = _secrets_dir if _secrets_dir is not None else SECRETS_DIR
47
+ secrets_file = secrets_dir / f"{name.lower()}.json"
48
+ if secrets_file.exists():
49
+ try:
50
+ data = json.loads(secrets_file.read_text())
51
+ for field in ("value", "api_key", "token", "key"):
52
+ if data.get(field):
53
+ return data[field], "secrets_broker"
54
+ except Exception:
55
+ logger.debug("Failed to read secrets file %s", secrets_file)
56
+
57
+ # 3. Not found
58
+ return None, "not_found"
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Convenience wrappers
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def get_figma_token() -> Tuple[Optional[str], str]:
66
+ """Resolve Figma API token."""
67
+ return get_key("figma", "FIGMA_TOKEN")
68
+
69
+
70
+ def get_trivy_path() -> Tuple[Optional[str], str]:
71
+ """Check if Trivy binary is available on PATH."""
72
+ path = shutil.which("trivy")
73
+ return (path, "installed") if path else (None, "not_found")
74
+
75
+
76
+ def get_playwright() -> Tuple[bool, str]:
77
+ """Check whether Playwright is usable (Python package installed)."""
78
+ try:
79
+ import playwright # noqa: F401
80
+ return True, "installed"
81
+ except ImportError:
82
+ return False, "not_found"
83
+
84
+
85
+ def get_puppeteer() -> Tuple[bool, str]:
86
+ """Check whether puppeteer (npx) is available for screenshot fallback."""
87
+ try:
88
+ result = subprocess.run(
89
+ ["npx", "puppeteer", "--version"],
90
+ capture_output=True,
91
+ timeout=15,
92
+ )
93
+ return (True, "installed") if result.returncode == 0 else (False, "not_found")
94
+ except Exception:
95
+ return False, "not_found"