delimit-cli 3.15.12 → 3.15.14
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/CHANGELOG.md +13 -0
- package/gateway/ai/activate_helpers.py +210 -0
- package/gateway/ai/content_engine.py +2 -7
- package/gateway/ai/key_resolver.py +2 -7
- package/gateway/ai/notify.py +4 -4
- package/gateway/ai/secrets_broker.py +232 -4
- package/gateway/ai/server.py +11 -10
- package/gateway/ai/supabase_sync.py +2 -7
- package/gateway/ai/swarm.py +106 -0
- package/gateway/ai/tool_metadata.py +34 -6
- package/package.json +7 -1
- package/scripts/security-check.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.15.13] - 2026-03-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Self-extending swarm**: Architect and Senior Dev agents can create new MCP tools at runtime
|
|
7
|
+
- **Tool security scan**: Block dangerous patterns (subprocess, exec, eval, socket) in custom tools
|
|
8
|
+
- **8 new modules**: activate_helpers, cross_model_audit, github_scanner, handoff_receipts, reddit_scanner, session_phoenix, social_target, toolcard_cache
|
|
9
|
+
- **Reviewer approval gate**: Custom tools require reviewer sign-off before activation
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Swarm actions expanded: create_tool, list_tools now available via delimit_swarm
|
|
13
|
+
- Inbox daemon: enhanced email classification and approval routing
|
|
14
|
+
- Social pipeline: improved content generation and scheduling
|
|
15
|
+
|
|
3
16
|
## [3.15.9] - 2026-03-30
|
|
4
17
|
|
|
5
18
|
### Added
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""LED-269 / LED-270: Activation checklist helpers.
|
|
2
|
+
|
|
3
|
+
Extracted so they can be tested independently of ai.server (which has
|
|
4
|
+
heavy MCP decorator dependencies).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def activate_auto_permissions(auto_permissions: bool) -> dict:
|
|
14
|
+
"""LED-269: Detect AI assistant and auto-configure Delimit tool permissions.
|
|
15
|
+
|
|
16
|
+
Returns a checklist entry dict with item/status/detail.
|
|
17
|
+
"""
|
|
18
|
+
home = Path.home()
|
|
19
|
+
assistant = None
|
|
20
|
+
config_path = None
|
|
21
|
+
|
|
22
|
+
# Detect which assistant is running
|
|
23
|
+
if os.environ.get("CLAUDE_CODE") or (home / ".claude").is_dir():
|
|
24
|
+
assistant = "claude_code"
|
|
25
|
+
config_path = home / ".claude" / "settings.json"
|
|
26
|
+
elif (home / ".codex" / "config.toml").exists():
|
|
27
|
+
assistant = "codex"
|
|
28
|
+
config_path = home / ".codex" / "config.toml"
|
|
29
|
+
elif (home / ".gemini" / "settings.json").exists():
|
|
30
|
+
assistant = "gemini"
|
|
31
|
+
config_path = home / ".gemini" / "settings.json"
|
|
32
|
+
else:
|
|
33
|
+
if os.environ.get("CODEX_CLI"):
|
|
34
|
+
assistant = "codex"
|
|
35
|
+
config_path = home / ".codex" / "config.toml"
|
|
36
|
+
|
|
37
|
+
if not assistant:
|
|
38
|
+
return {"item": "Permissions", "status": "Skip (no assistant)", "detail": "No AI assistant detected"}
|
|
39
|
+
|
|
40
|
+
if not auto_permissions:
|
|
41
|
+
return {"item": "Permissions", "status": "Skip (manual)", "detail": f"Detected {assistant} — auto-config disabled"}
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if assistant == "claude_code":
|
|
45
|
+
return configure_claude_code_permissions(config_path)
|
|
46
|
+
elif assistant == "codex":
|
|
47
|
+
return configure_codex_permissions(config_path)
|
|
48
|
+
elif assistant == "gemini":
|
|
49
|
+
return {"item": "Permissions", "status": "Skip (manual)", "detail": "Gemini CLI — configure permissions manually"}
|
|
50
|
+
except Exception as e:
|
|
51
|
+
return {"item": "Permissions", "status": "Fail", "detail": f"Auto-config failed: {e}"}
|
|
52
|
+
|
|
53
|
+
return {"item": "Permissions", "status": "Skip (manual)", "detail": f"Detected {assistant}"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def configure_claude_code_permissions(config_path: Path) -> dict:
|
|
57
|
+
"""Add mcp__delimit__* to Claude Code permissions.allow if not present."""
|
|
58
|
+
permission_pattern = "mcp__delimit__*"
|
|
59
|
+
|
|
60
|
+
if config_path.exists():
|
|
61
|
+
try:
|
|
62
|
+
data = json.loads(config_path.read_text())
|
|
63
|
+
except (json.JSONDecodeError, OSError):
|
|
64
|
+
data = {}
|
|
65
|
+
else:
|
|
66
|
+
data = {}
|
|
67
|
+
|
|
68
|
+
permissions = data.setdefault("permissions", {})
|
|
69
|
+
allow_list = permissions.setdefault("allow", [])
|
|
70
|
+
|
|
71
|
+
if any(permission_pattern in str(entry) for entry in allow_list):
|
|
72
|
+
return {"item": "Permissions", "status": "Pass", "detail": f"Claude Code: {permission_pattern} already in settings"}
|
|
73
|
+
|
|
74
|
+
allow_list.append(permission_pattern)
|
|
75
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
config_path.write_text(json.dumps(data, indent=2))
|
|
77
|
+
return {"item": "Permissions", "status": "Pass", "detail": f"Claude Code: added {permission_pattern} to {config_path}"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def configure_codex_permissions(config_path: Path) -> dict:
|
|
81
|
+
"""Set trust_level to trusted for Delimit in Codex config.toml."""
|
|
82
|
+
if config_path.exists():
|
|
83
|
+
content = config_path.read_text()
|
|
84
|
+
if "trust_level" in content and "trusted" in content:
|
|
85
|
+
return {"item": "Permissions", "status": "Pass", "detail": "Codex: already trusted"}
|
|
86
|
+
else:
|
|
87
|
+
content = ""
|
|
88
|
+
|
|
89
|
+
if "[delimit]" not in content:
|
|
90
|
+
addition = '\n[delimit]\ntrust_level = "trusted"\n'
|
|
91
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with open(config_path, "a") as f:
|
|
93
|
+
f.write(addition)
|
|
94
|
+
return {"item": "Permissions", "status": "Pass", "detail": f"Codex: added trust_level=trusted to {config_path}"}
|
|
95
|
+
else:
|
|
96
|
+
return {"item": "Permissions", "status": "Pass", "detail": "Codex: delimit section exists"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_checklist(
|
|
100
|
+
license_key: str,
|
|
101
|
+
project_path: str,
|
|
102
|
+
auto_permissions: bool,
|
|
103
|
+
) -> Dict[str, Any]:
|
|
104
|
+
"""Build the activation checklist. Core logic extracted from delimit_activate.
|
|
105
|
+
|
|
106
|
+
Returns the result dict (without next_steps wrapping).
|
|
107
|
+
"""
|
|
108
|
+
from ai.license import activate_license, get_license, is_premium, require_premium
|
|
109
|
+
|
|
110
|
+
checklist: list = []
|
|
111
|
+
p = Path(project_path).resolve()
|
|
112
|
+
|
|
113
|
+
# --- Step 1: License activation (if key provided) ---
|
|
114
|
+
if license_key:
|
|
115
|
+
lic_result = activate_license(license_key)
|
|
116
|
+
if lic_result.get("status") == "activated":
|
|
117
|
+
checklist.append({"item": "License activation", "status": "Pass", "detail": f"Tier: {lic_result.get('tier', 'pro')}"})
|
|
118
|
+
else:
|
|
119
|
+
checklist.append({"item": "License activation", "status": "Fail", "detail": lic_result.get("error", "Unknown error")})
|
|
120
|
+
else:
|
|
121
|
+
lic = get_license()
|
|
122
|
+
tier = lic.get("tier", "free")
|
|
123
|
+
checklist.append({"item": "License status", "status": "Pass", "detail": f"Tier: {tier}"})
|
|
124
|
+
|
|
125
|
+
# --- Step 2: MCP server reachable ---
|
|
126
|
+
checklist.append({"item": "MCP server", "status": "Pass", "detail": "Server responding"})
|
|
127
|
+
|
|
128
|
+
# --- Step 3: Python dependencies ---
|
|
129
|
+
dep_ok = True
|
|
130
|
+
for pkg in ["yaml", "pydantic", "packaging", "fastmcp"]:
|
|
131
|
+
try:
|
|
132
|
+
__import__(pkg)
|
|
133
|
+
except ImportError:
|
|
134
|
+
dep_ok = False
|
|
135
|
+
checklist.append({"item": f"Dependency: {pkg}", "status": "Fail", "detail": f"pip install {pkg}"})
|
|
136
|
+
if dep_ok:
|
|
137
|
+
checklist.append({"item": "Dependencies", "status": "Pass", "detail": "All required packages installed"})
|
|
138
|
+
|
|
139
|
+
# --- Step 4: Governance initialized ---
|
|
140
|
+
delimit_dir = p / ".delimit"
|
|
141
|
+
policies = delimit_dir / "policies.yml"
|
|
142
|
+
if delimit_dir.is_dir() and policies.is_file():
|
|
143
|
+
checklist.append({"item": "Governance", "status": "Pass", "detail": f"Initialized at {delimit_dir}"})
|
|
144
|
+
elif delimit_dir.is_dir():
|
|
145
|
+
checklist.append({"item": "Governance", "status": "Fail", "detail": "Missing policies.yml — run delimit_init"})
|
|
146
|
+
else:
|
|
147
|
+
checklist.append({"item": "Governance", "status": "Fail", "detail": "Not initialized — run delimit_init"})
|
|
148
|
+
|
|
149
|
+
# --- Step 5: Test smoke (skip if no framework) ---
|
|
150
|
+
try:
|
|
151
|
+
from backends.tools_real import test_smoke as _test_smoke_fn
|
|
152
|
+
smoke = _test_smoke_fn(project_path=str(p))
|
|
153
|
+
if smoke.get("status") == "no_framework":
|
|
154
|
+
checklist.append({"item": "Test smoke", "status": "Skip (no tests)", "detail": "No test framework detected"})
|
|
155
|
+
elif smoke.get("error"):
|
|
156
|
+
checklist.append({"item": "Test smoke", "status": "Fail", "detail": smoke.get("error", "")})
|
|
157
|
+
else:
|
|
158
|
+
passed_count = smoke.get("passed", 0)
|
|
159
|
+
failed_count = smoke.get("failed", 0)
|
|
160
|
+
if failed_count == 0:
|
|
161
|
+
checklist.append({"item": "Test smoke", "status": "Pass", "detail": f"{passed_count} tests passed"})
|
|
162
|
+
else:
|
|
163
|
+
checklist.append({"item": "Test smoke", "status": "Fail", "detail": f"{passed_count} passed, {failed_count} failed"})
|
|
164
|
+
except Exception as e:
|
|
165
|
+
checklist.append({"item": "Test smoke", "status": "Skip (no tests)", "detail": f"Could not run: {e}"})
|
|
166
|
+
|
|
167
|
+
# --- Step 6: AI assistant detection + permission auto-config (LED-269) ---
|
|
168
|
+
perm_result = activate_auto_permissions(auto_permissions)
|
|
169
|
+
checklist.append(perm_result)
|
|
170
|
+
|
|
171
|
+
# --- Step 7: Premium feature checks (skip on free tier) ---
|
|
172
|
+
premium_checks = [
|
|
173
|
+
("Deliberation (multi-model)", "delimit_deliberate"),
|
|
174
|
+
("Security audit", "delimit_security_ingest"),
|
|
175
|
+
("Deploy pipeline", "delimit_deploy_plan"),
|
|
176
|
+
("Cost analysis", "delimit_cost_analyze"),
|
|
177
|
+
("Release management", "delimit_release_plan"),
|
|
178
|
+
("Agent orchestration", "delimit_agent_dispatch"),
|
|
179
|
+
]
|
|
180
|
+
for label, tool_name in premium_checks:
|
|
181
|
+
gate = require_premium(tool_name)
|
|
182
|
+
if gate is None:
|
|
183
|
+
checklist.append({"item": label, "status": "Pass", "detail": "Pro feature unlocked"})
|
|
184
|
+
else:
|
|
185
|
+
checklist.append({"item": label, "status": "Skip (Pro)", "detail": "Requires Delimit Pro"})
|
|
186
|
+
|
|
187
|
+
# --- Score: only count applicable checks (exclude skips) ---
|
|
188
|
+
applicable = [c for c in checklist if not c["status"].startswith("Skip")]
|
|
189
|
+
passed_total = sum(1 for c in applicable if c["status"] == "Pass")
|
|
190
|
+
total = len(applicable)
|
|
191
|
+
score = f"{passed_total}/{total}"
|
|
192
|
+
|
|
193
|
+
result: Dict[str, Any] = {
|
|
194
|
+
"tool": "activate",
|
|
195
|
+
"status": "complete",
|
|
196
|
+
"score": score,
|
|
197
|
+
"passed": passed_total,
|
|
198
|
+
"total": total,
|
|
199
|
+
"skipped": len(checklist) - total,
|
|
200
|
+
"checklist": checklist,
|
|
201
|
+
"tier": get_license().get("tier", "free"),
|
|
202
|
+
"project": str(p),
|
|
203
|
+
}
|
|
204
|
+
if passed_total == total and total > 0:
|
|
205
|
+
result["message"] = f"All {total} checks passed. Delimit is fully operational."
|
|
206
|
+
elif passed_total < total:
|
|
207
|
+
failed_items = [c["item"] for c in applicable if c["status"] == "Fail"]
|
|
208
|
+
result["message"] = f"{passed_total}/{total} checks passed. Fix: {', '.join(failed_items)}"
|
|
209
|
+
|
|
210
|
+
return result
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
This module requires the Delimit MCP server to be running.
|
|
4
|
-
Configure via: npx delimit-cli setup
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
# Stub — full implementation runs server-side
|
|
1
|
+
# content_engine — Pro module (stubbed in npm package)
|
|
2
|
+
# Full implementation available on delimit.ai server
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
This module requires the Delimit MCP server to be running.
|
|
4
|
-
Configure via: npx delimit-cli setup
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
# Stub — full implementation runs server-side
|
|
1
|
+
# key_resolver — Pro module (stubbed in npm package)
|
|
2
|
+
# Full implementation available on delimit.ai server
|
package/gateway/ai/notify.py
CHANGED
|
@@ -37,7 +37,7 @@ INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
|
|
|
37
37
|
IMAP_HOST = "mail.spacemail.com"
|
|
38
38
|
IMAP_PORT = 993
|
|
39
39
|
IMAP_USER = "pro@delimit.ai"
|
|
40
|
-
FORWARD_TO = "
|
|
40
|
+
FORWARD_TO = "owner@example.com"
|
|
41
41
|
|
|
42
42
|
# Domains/senders whose emails require owner action
|
|
43
43
|
OWNER_ACTION_DOMAINS = {
|
|
@@ -61,7 +61,7 @@ OWNER_ACTION_DOMAINS = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
OWNER_ACTION_SENDERS = {
|
|
64
|
-
"
|
|
64
|
+
"owner@example.com",
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
# Subject patterns that indicate owner-action (compiled once)
|
|
@@ -223,7 +223,7 @@ def send_email(
|
|
|
223
223
|
|
|
224
224
|
Args:
|
|
225
225
|
to: Recipient email address. Falls back to DELIMIT_SMTP_TO or
|
|
226
|
-
|
|
226
|
+
owner@example.com.
|
|
227
227
|
subject: Email subject line.
|
|
228
228
|
body: Email body text (preferred). Falls back to 'message' for
|
|
229
229
|
backward compatibility.
|
|
@@ -258,7 +258,7 @@ def send_email(
|
|
|
258
258
|
smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
|
|
259
259
|
smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
|
|
260
260
|
|
|
261
|
-
smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "
|
|
261
|
+
smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "owner@example.com")
|
|
262
262
|
|
|
263
263
|
if not all([smtp_host, smtp_from, smtp_to]):
|
|
264
264
|
record = {
|
|
@@ -1,7 +1,235 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Secrets broker — JIT credential access with audit (STR-049).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Agents request credentials through this broker instead of accessing API keys
|
|
4
|
+
directly. The broker validates scope, issues time-limited access, and logs
|
|
5
|
+
every request in the audit trail.
|
|
5
6
|
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import base64
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Dict, List, Optional
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
SECRETS_DIR = Path.home() / ".delimit" / "secrets"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def store_secret(
|
|
18
|
+
name: str,
|
|
19
|
+
value: str,
|
|
20
|
+
scope: str = "all",
|
|
21
|
+
description: str = "",
|
|
22
|
+
created_by: str = "",
|
|
23
|
+
) -> Dict:
|
|
24
|
+
"""Store a secret locally.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Unique identifier for the secret.
|
|
28
|
+
value: The secret value (will be base64-encoded at rest).
|
|
29
|
+
scope: Comma-separated list of tools/agents allowed access, or 'all'.
|
|
30
|
+
description: Human-readable description.
|
|
31
|
+
created_by: Identity of the creator.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Confirmation dict with the stored secret name.
|
|
35
|
+
"""
|
|
36
|
+
if not name or not name.strip():
|
|
37
|
+
return {"error": "Secret name is required"}
|
|
38
|
+
if not value:
|
|
39
|
+
return {"error": "Secret value is required"}
|
|
40
|
+
|
|
41
|
+
# Sanitise name for filesystem safety
|
|
42
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
43
|
+
|
|
44
|
+
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
encoded = base64.b64encode(value.encode()).decode()
|
|
46
|
+
secret = {
|
|
47
|
+
"name": safe_name,
|
|
48
|
+
"encrypted_value": encoded,
|
|
49
|
+
"scope": scope,
|
|
50
|
+
"description": description,
|
|
51
|
+
"created_by": created_by,
|
|
52
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"last_accessed_at": None,
|
|
54
|
+
"access_count": 0,
|
|
55
|
+
"revoked": False,
|
|
56
|
+
}
|
|
57
|
+
(SECRETS_DIR / f"{safe_name}.json").write_text(json.dumps(secret, indent=2))
|
|
58
|
+
return {"stored": safe_name}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_secret(
|
|
62
|
+
name: str,
|
|
63
|
+
agent_type: str = "",
|
|
64
|
+
tool: str = "",
|
|
65
|
+
) -> Dict:
|
|
66
|
+
"""Request access to a secret. Returns value if authorised.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: The secret name to retrieve.
|
|
70
|
+
agent_type: Identity of the requesting agent (e.g. 'claude', 'codex').
|
|
71
|
+
tool: Which MCP tool is requesting access.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with 'value' and 'granted': True on success, or 'error' and
|
|
75
|
+
'granted': False on failure.
|
|
76
|
+
"""
|
|
77
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
78
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
79
|
+
if not path.exists():
|
|
80
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="not_found")
|
|
81
|
+
return {"error": f"Secret '{safe_name}' not found", "granted": False}
|
|
82
|
+
|
|
83
|
+
secret = json.loads(path.read_text())
|
|
84
|
+
|
|
85
|
+
if secret.get("revoked"):
|
|
86
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="revoked")
|
|
87
|
+
return {"error": f"Secret '{safe_name}' has been revoked", "granted": False}
|
|
88
|
+
|
|
89
|
+
# Scope check — 'all' allows any requester, otherwise match tool or agent_type
|
|
90
|
+
scope = secret.get("scope", "all")
|
|
91
|
+
if scope != "all":
|
|
92
|
+
allowed = {s.strip().lower() for s in scope.split(",")}
|
|
93
|
+
requester_ids = {agent_type.lower(), tool.lower()} - {""}
|
|
94
|
+
if requester_ids and not requester_ids & allowed:
|
|
95
|
+
_log_access(
|
|
96
|
+
safe_name, agent_type, tool,
|
|
97
|
+
granted=False,
|
|
98
|
+
reason=f"scope_denied: required={scope}, got agent_type={agent_type}, tool={tool}",
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
"error": f"Access denied: scope '{scope}' does not include '{agent_type or tool}'",
|
|
102
|
+
"granted": False,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Log successful access
|
|
106
|
+
_log_access(safe_name, agent_type, tool, granted=True, reason="")
|
|
107
|
+
|
|
108
|
+
# Update access metadata
|
|
109
|
+
secret["access_count"] = secret.get("access_count", 0) + 1
|
|
110
|
+
secret["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
|
|
111
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
112
|
+
|
|
113
|
+
value = base64.b64decode(secret["encrypted_value"]).decode()
|
|
114
|
+
return {"value": value, "granted": True, "name": safe_name}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_secrets() -> List[Dict]:
|
|
118
|
+
"""List all secrets (metadata only, never values).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of dicts with name, scope, description, access_count, etc.
|
|
122
|
+
"""
|
|
123
|
+
if not SECRETS_DIR.exists():
|
|
124
|
+
return []
|
|
125
|
+
secrets = []
|
|
126
|
+
for f in sorted(SECRETS_DIR.glob("*.json")):
|
|
127
|
+
try:
|
|
128
|
+
s = json.loads(f.read_text())
|
|
129
|
+
secrets.append({
|
|
130
|
+
"name": s["name"],
|
|
131
|
+
"scope": s.get("scope", "all"),
|
|
132
|
+
"description": s.get("description", ""),
|
|
133
|
+
"created_by": s.get("created_by", ""),
|
|
134
|
+
"access_count": s.get("access_count", 0),
|
|
135
|
+
"last_accessed_at": s.get("last_accessed_at"),
|
|
136
|
+
"revoked": s.get("revoked", False),
|
|
137
|
+
"created_at": s.get("created_at", ""),
|
|
138
|
+
})
|
|
139
|
+
except (json.JSONDecodeError, KeyError):
|
|
140
|
+
pass
|
|
141
|
+
return secrets
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def revoke_secret(name: str) -> Dict:
|
|
145
|
+
"""Revoke a secret, preventing future access.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name: The secret name to revoke.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Confirmation dict or error.
|
|
152
|
+
"""
|
|
153
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
154
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
157
|
+
secret = json.loads(path.read_text())
|
|
158
|
+
secret["revoked"] = True
|
|
159
|
+
secret["revoked_at"] = datetime.now(timezone.utc).isoformat()
|
|
160
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
161
|
+
_log_access(safe_name, "", "", granted=True, reason="revoked_by_user")
|
|
162
|
+
return {"revoked": safe_name}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_access_log(name: Optional[str] = None) -> List[Dict]:
|
|
166
|
+
"""Return access log entries, optionally filtered by secret name.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
name: If provided, only return entries for this secret.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of access log entries (newest first).
|
|
173
|
+
"""
|
|
174
|
+
log_path = SECRETS_DIR / "access_log" / "log.jsonl"
|
|
175
|
+
if not log_path.exists():
|
|
176
|
+
return []
|
|
177
|
+
entries = []
|
|
178
|
+
for line in log_path.read_text().strip().split("\n"):
|
|
179
|
+
if not line.strip():
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
entry = json.loads(line)
|
|
183
|
+
if name and entry.get("secret_name") != name:
|
|
184
|
+
continue
|
|
185
|
+
entries.append(entry)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
pass
|
|
188
|
+
# Newest first
|
|
189
|
+
entries.reverse()
|
|
190
|
+
return entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_secret(name: str) -> Dict:
|
|
194
|
+
"""Permanently delete a secret file.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: The secret name to delete.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Confirmation dict or error.
|
|
201
|
+
"""
|
|
202
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
203
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
206
|
+
path.unlink()
|
|
207
|
+
_log_access(safe_name, "", "", granted=True, reason="deleted_by_user")
|
|
208
|
+
return {"deleted": safe_name}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Internal helpers
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _log_access(
|
|
217
|
+
secret_name: str,
|
|
218
|
+
agent_type: str,
|
|
219
|
+
tool: str,
|
|
220
|
+
granted: bool,
|
|
221
|
+
reason: str,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Append an entry to the JSONL access log."""
|
|
224
|
+
log_dir = SECRETS_DIR / "access_log"
|
|
225
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
entry = {
|
|
227
|
+
"secret_name": secret_name,
|
|
228
|
+
"agent_type": agent_type,
|
|
229
|
+
"tool": tool,
|
|
230
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
231
|
+
"granted": granted,
|
|
232
|
+
"reason": reason,
|
|
233
|
+
}
|
|
234
|
+
with open(log_dir / "log.jsonl", "a") as f:
|
|
235
|
+
f.write(json.dumps(entry) + "\n")
|
package/gateway/ai/server.py
CHANGED
|
@@ -3697,6 +3697,14 @@ def delimit_swarm(action: str = "status", venture: str = "",
|
|
|
3697
3697
|
return _with_next_steps("swarm", _safe_call(
|
|
3698
3698
|
check_docs_freshness, project_path=repo_path or ".",
|
|
3699
3699
|
))
|
|
3700
|
+
if action == "create_tool":
|
|
3701
|
+
from ai.swarm import create_tool
|
|
3702
|
+
return _with_next_steps("swarm", _safe_call(
|
|
3703
|
+
create_tool, name=target_path, code=repo_path, venture=venture, agent_id=agent_id,
|
|
3704
|
+
))
|
|
3705
|
+
if action == "list_tools":
|
|
3706
|
+
from ai.swarm import list_custom_tools
|
|
3707
|
+
return _with_next_steps("swarm", _safe_call(list_custom_tools, venture=venture))
|
|
3700
3708
|
return _with_next_steps("swarm", _safe_call(get_swarm_status))
|
|
3701
3709
|
|
|
3702
3710
|
|
|
@@ -5692,14 +5700,7 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
5692
5700
|
Every post provides value — tips, insights, governance wisdom.
|
|
5693
5701
|
Max 2 posts per day to stay authentic.
|
|
5694
5702
|
|
|
5695
|
-
|
|
5696
|
-
- Twitter: confident technical brand. Direct, professional, ALWAYS POSITIVE.
|
|
5697
|
-
Celebrate wins and progress. Never complain or air gaps publicly.
|
|
5698
|
-
No em dashes or en dashes. Include install commands when relevant.
|
|
5699
|
-
- Reddit: proud builder posting as u/delimitdev. Casual, typed-on-phone energy.
|
|
5700
|
-
ALWAYS POSITIVE. Mention Delimit ONLY when genuinely helpful.
|
|
5701
|
-
NO bullet points/lists/bold/em dashes. 2-3 sentences max.
|
|
5702
|
-
- LinkedIn: professional hook + insight + CTA
|
|
5703
|
+
Platform tone and posting guidelines are loaded from user config at runtime.
|
|
5703
5704
|
|
|
5704
5705
|
Args:
|
|
5705
5706
|
text: Tweet text. Leave empty to auto-generate.
|
|
@@ -5736,7 +5737,7 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
5736
5737
|
from ai.social import store_draft_message_id
|
|
5737
5738
|
|
|
5738
5739
|
# Build contextual email body so the founder knows exactly what to do
|
|
5739
|
-
_acct = account or ("
|
|
5740
|
+
_acct = account or os.environ.get("DELIMIT_SOCIAL_REDDIT_ACCOUNT", "delimit") if platform == "reddit" else account or os.environ.get("DELIMIT_SOCIAL_TWITTER_ACCOUNT", "delimit_ai")
|
|
5740
5741
|
_lines = []
|
|
5741
5742
|
|
|
5742
5743
|
if platform == "reddit":
|
|
@@ -6513,7 +6514,7 @@ def delimit_notify(channel: str = "webhook", message: str = "",
|
|
|
6513
6514
|
to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
|
|
6514
6515
|
Send to any address — leave empty for default (owner@example.com).
|
|
6515
6516
|
from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
|
|
6516
|
-
(e.g. 'pro@
|
|
6517
|
+
(e.g. 'pro@delimit.ai', 'admin@wire.report'). Email only.
|
|
6517
6518
|
"""
|
|
6518
6519
|
from ai.notify import send_notification
|
|
6519
6520
|
return _with_next_steps("notify", _safe_call(
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
This module requires the Delimit MCP server to be running.
|
|
4
|
-
Configure via: npx delimit-cli setup
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
# Stub — full implementation runs server-side
|
|
1
|
+
# supabase_sync — Pro module (stubbed in npm package)
|
|
2
|
+
# Full implementation available on delimit.ai server
|
package/gateway/ai/swarm.py
CHANGED
|
@@ -590,3 +590,109 @@ def check_docs_freshness(
|
|
|
590
590
|
"stale": stale,
|
|
591
591
|
"message": f"{len(findings)} doc issue(s) found" if findings else "Documentation is up to date",
|
|
592
592
|
}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
596
|
+
# LED-279: Self-Extending Swarm — Founder Mode
|
|
597
|
+
# Agents can create new MCP tools when authorized
|
|
598
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
599
|
+
|
|
600
|
+
TOOLS_DIR = Path.home() / ".delimit" / "swarm" / "custom_tools"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def create_tool(
|
|
604
|
+
name: str,
|
|
605
|
+
code: str,
|
|
606
|
+
venture: str,
|
|
607
|
+
agent_id: str = "",
|
|
608
|
+
description: str = "",
|
|
609
|
+
) -> Dict[str, Any]:
|
|
610
|
+
"""Create a new MCP tool (founder mode only).
|
|
611
|
+
|
|
612
|
+
Writes a Python module that can be loaded by the MCP server.
|
|
613
|
+
Requires reviewer approval before activation.
|
|
614
|
+
"""
|
|
615
|
+
if not name or not code:
|
|
616
|
+
return {"error": "name and code are required"}
|
|
617
|
+
|
|
618
|
+
# Verify agent has creation authority
|
|
619
|
+
registry = _load_registry()
|
|
620
|
+
agent = registry["agents"].get(agent_id, {})
|
|
621
|
+
role = agent.get("role", "")
|
|
622
|
+
if role not in ("architect", "senior_dev"):
|
|
623
|
+
return {
|
|
624
|
+
"error": f"Role '{role}' cannot create tools. Only architect and senior_dev have creation authority.",
|
|
625
|
+
"agent_id": agent_id,
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
# Verify venture namespace
|
|
629
|
+
if agent.get("venture", "") != venture:
|
|
630
|
+
return {"error": f"Agent '{agent_id}' cannot create tools for venture '{venture}'"}
|
|
631
|
+
|
|
632
|
+
# Security scan — check for dangerous patterns
|
|
633
|
+
dangerous = [
|
|
634
|
+
"subprocess.call", "os.system", "exec(", "eval(",
|
|
635
|
+
"import socket", "import http.server",
|
|
636
|
+
"__import__", "compile(",
|
|
637
|
+
]
|
|
638
|
+
for pattern in dangerous:
|
|
639
|
+
if pattern in code:
|
|
640
|
+
return {
|
|
641
|
+
"error": f"Security violation: '{pattern}' is not allowed in custom tools",
|
|
642
|
+
"blocked_pattern": pattern,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
# Write tool module
|
|
646
|
+
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
647
|
+
venture_dir = TOOLS_DIR / venture
|
|
648
|
+
venture_dir.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
|
|
650
|
+
safe_name = name.lower().replace("-", "_").replace(" ", "_")
|
|
651
|
+
tool_path = venture_dir / f"{safe_name}.py"
|
|
652
|
+
tool_path.write_text(code)
|
|
653
|
+
|
|
654
|
+
# Log creation
|
|
655
|
+
_log({
|
|
656
|
+
"action": "tool_created",
|
|
657
|
+
"tool_name": safe_name,
|
|
658
|
+
"venture": venture,
|
|
659
|
+
"agent_id": agent_id,
|
|
660
|
+
"path": str(tool_path),
|
|
661
|
+
"lines": len(code.split("\n")),
|
|
662
|
+
"status": "pending_review",
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
"status": "created",
|
|
667
|
+
"tool_name": safe_name,
|
|
668
|
+
"path": str(tool_path),
|
|
669
|
+
"venture": venture,
|
|
670
|
+
"created_by": agent_id,
|
|
671
|
+
"lines": len(code.split("\n")),
|
|
672
|
+
"next_step": "Reviewer agent must approve before tool is activated",
|
|
673
|
+
"message": f"Tool '{safe_name}' created for {venture}. Pending reviewer approval.",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def list_custom_tools(venture: str = "") -> Dict[str, Any]:
|
|
678
|
+
"""List custom tools created by agents."""
|
|
679
|
+
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
680
|
+
tools = []
|
|
681
|
+
|
|
682
|
+
search_dirs = [TOOLS_DIR / venture] if venture else list(TOOLS_DIR.iterdir())
|
|
683
|
+
for d in search_dirs:
|
|
684
|
+
if d.is_dir():
|
|
685
|
+
for f in sorted(d.glob("*.py")):
|
|
686
|
+
tools.append({
|
|
687
|
+
"name": f.stem,
|
|
688
|
+
"venture": d.name,
|
|
689
|
+
"path": str(f),
|
|
690
|
+
"lines": len(f.read_text().split("\n")),
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
"status": "ok",
|
|
695
|
+
"tools": tools,
|
|
696
|
+
"total": len(tools),
|
|
697
|
+
"venture_filter": venture or "all",
|
|
698
|
+
}
|
|
@@ -14,7 +14,35 @@ Reference: Consensus 118/119/120 — Tool Segmentation Architecture.
|
|
|
14
14
|
|
|
15
15
|
from typing import Dict, Literal
|
|
16
16
|
|
|
17
|
-
Tier = Literal["public", "ops_pack", "internal", "experimental"]
|
|
17
|
+
Tier = Literal["core", "public", "ops_pack", "internal", "experimental"]
|
|
18
|
+
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
# CORE WORKFLOWS — the 5 outcomes first users should see
|
|
21
|
+
#
|
|
22
|
+
# 1. Govern: catch breaking changes before deploy
|
|
23
|
+
# 2. Remember: persistent memory across models and sessions
|
|
24
|
+
# 3. Handoff: switch models without losing context
|
|
25
|
+
# 4. Lint: validate API specs for drift
|
|
26
|
+
# 5. Track: ledger for tasks that survive context resets
|
|
27
|
+
#
|
|
28
|
+
# Per consensus (STR-040): sell 5 workflows, not 162 tools
|
|
29
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
CORE_TOOLS = {
|
|
32
|
+
# Govern
|
|
33
|
+
"delimit_lint", "delimit_scan", "delimit_gov_health", "delimit_drift_check",
|
|
34
|
+
# Remember
|
|
35
|
+
"delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
|
|
36
|
+
# Handoff
|
|
37
|
+
"delimit_session_handoff", "delimit_session_history",
|
|
38
|
+
# Lint + Diff
|
|
39
|
+
"delimit_diff", "delimit_semver", "delimit_explain",
|
|
40
|
+
# Track
|
|
41
|
+
"delimit_ledger_context", "delimit_ledger_add", "delimit_ledger_done",
|
|
42
|
+
"delimit_ledger_list", "delimit_ledger_update",
|
|
43
|
+
# Setup
|
|
44
|
+
"delimit_init", "delimit_scan", "delimit_version", "delimit_help",
|
|
45
|
+
}
|
|
18
46
|
|
|
19
47
|
# ─────────────────────────────────────────────────────────────────────
|
|
20
48
|
# TOOL_TIERS: canonical tier assignment for every registered tool.
|
|
@@ -31,12 +59,12 @@ Tier = Literal["public", "ops_pack", "internal", "experimental"]
|
|
|
31
59
|
# ─────────────────────────────────────────────────────────────────────
|
|
32
60
|
|
|
33
61
|
TOOL_TIERS: Dict[str, Tier] = {
|
|
34
|
-
# === Govern domain
|
|
35
|
-
"delimit_lint": "
|
|
36
|
-
"delimit_diff": "
|
|
62
|
+
# === Govern domain ===
|
|
63
|
+
"delimit_lint": "core",
|
|
64
|
+
"delimit_diff": "core",
|
|
37
65
|
"delimit_policy": "public",
|
|
38
|
-
"delimit_semver": "
|
|
39
|
-
"delimit_explain": "
|
|
66
|
+
"delimit_semver": "core",
|
|
67
|
+
"delimit_explain": "core",
|
|
40
68
|
"delimit_zero_spec": "public",
|
|
41
69
|
"delimit_init": "public",
|
|
42
70
|
"delimit_gov_health": "public",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delimit-cli",
|
|
3
3
|
"mcpName": "io.github.delimit-ai/delimit-mcp-server",
|
|
4
|
-
"version": "3.15.
|
|
4
|
+
"version": "3.15.14",
|
|
5
5
|
"description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
"!gateway/ai/founding_users.py",
|
|
15
15
|
"!gateway/ai/inbox_daemon.py",
|
|
16
16
|
"!gateway/ai/deliberation.py",
|
|
17
|
+
"!gateway/ai/reddit_scanner.py",
|
|
18
|
+
"!gateway/ai/github_scanner.py",
|
|
19
|
+
"!gateway/ai/cross_model_audit.py",
|
|
20
|
+
"!gateway/ai/session_phoenix.py",
|
|
21
|
+
"!gateway/ai/handoff_receipts.py",
|
|
22
|
+
"!gateway/ai/toolcard_cache.py",
|
|
17
23
|
"scripts/",
|
|
18
24
|
"server.json",
|
|
19
25
|
"README.md",
|
|
@@ -25,7 +25,7 @@ fi
|
|
|
25
25
|
|
|
26
26
|
# 2. Blocklist terms
|
|
27
27
|
echo -n " Blocklist... "
|
|
28
|
-
BLOCKLIST="jamsonsholdings|Bladabah|Domainvested26|Delimit26|home/jamsons|infracore|crypttrx|\.wr_env"
|
|
28
|
+
BLOCKLIST="jamsonsholdings|Bladabah|Domainvested26|Delimit26|home/jamsons|infracore|crypttrx|\.wr_env|delimitdev|typed-on-phone|em dash.*ai tell|PAIN_CATEGORIES|VENTURE_CONFIG|VENTURE_SUBREDDITS|karma_building"
|
|
29
29
|
if grep -rEi "$BLOCKLIST" "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null; then
|
|
30
30
|
echo "❌ BLOCKED TERMS FOUND"
|
|
31
31
|
FAIL=1
|
|
@@ -44,7 +44,7 @@ fi
|
|
|
44
44
|
|
|
45
45
|
# 4. Proprietary files that shouldn't ship
|
|
46
46
|
echo -n " Proprietary files... "
|
|
47
|
-
PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py"
|
|
47
|
+
PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py|reddit_scanner\.py|github_scanner\.py|cross_model_audit\.py|session_phoenix\.py|handoff_receipts\.py|toolcard_cache\.py"
|
|
48
48
|
if find "$TMPDIR/package/" -name "*.py" | grep -Ei "$PROPRIETARY" 2>/dev/null; then
|
|
49
49
|
echo "❌ PROPRIETARY FILES IN PACKAGE"
|
|
50
50
|
FAIL=1
|