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.
- package/bin/delimit-setup.js +19 -2
- package/gateway/ai/backends/deploy_bridge.py +56 -2
- package/gateway/ai/backends/gateway_core.py +212 -1
- package/gateway/ai/backends/generate_bridge.py +84 -13
- package/gateway/ai/backends/governance_bridge.py +63 -16
- package/gateway/ai/backends/memory_bridge.py +77 -76
- package/gateway/ai/backends/ops_bridge.py +76 -6
- package/gateway/ai/backends/os_bridge.py +23 -3
- package/gateway/ai/backends/repo_bridge.py +156 -17
- package/gateway/ai/backends/tools_design.py +116 -9
- package/gateway/ai/backends/tools_infra.py +200 -72
- package/gateway/ai/backends/tools_real.py +8 -0
- package/gateway/ai/backends/ui_bridge.py +115 -5
- package/gateway/ai/backends/vault_bridge.py +69 -114
- package/gateway/ai/content_engine.py +1276 -0
- package/gateway/ai/context_fs.py +193 -0
- package/gateway/ai/daemon.py +500 -0
- package/gateway/ai/data_plane.py +291 -0
- package/gateway/ai/deliberation.py +1033 -6
- package/gateway/ai/events.py +39 -0
- package/gateway/ai/founding_users.py +162 -0
- package/gateway/ai/governance.py +698 -4
- package/gateway/ai/inbox_daemon.py +78 -17
- package/gateway/ai/integrations/__init__.py +1 -0
- package/gateway/ai/integrations/opensage_wrapper.py +288 -0
- package/gateway/ai/key_resolver.py +95 -0
- package/gateway/ai/ledger_manager.py +289 -1
- package/gateway/ai/license.py +62 -4
- package/gateway/ai/license_core.py +208 -7
- package/gateway/ai/local_server.py +215 -0
- package/gateway/ai/loop_engine.py +408 -0
- package/gateway/ai/mcp_bridge.py +178 -0
- package/gateway/ai/release_sync.py +2 -2
- package/gateway/ai/screen_record.py +374 -0
- package/gateway/ai/secrets_broker.py +235 -0
- package/gateway/ai/social.py +189 -27
- package/gateway/ai/social_target.py +1635 -0
- package/gateway/ai/supabase_sync.py +190 -0
- package/gateway/ai/tracing.py +195 -0
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/diff_engine_v2.py +272 -78
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/gateway/core/policy_engine.py +4 -0
- 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
|
|
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
|
-
|
|
371
|
-
|
|
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 -
|
|
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"
|
|
382
|
-
f"
|
|
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="
|
|
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
|
-
|
|
394
|
-
if
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
#
|
|
418
|
-
posted_drafts =
|
|
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"
|