delimit-cli 3.15.4 → 3.15.6
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/gateway/ai/content_engine.py +4 -1273
- package/gateway/ai/key_resolver.py +4 -92
- package/gateway/ai/notify.py +2 -32
- package/gateway/ai/secrets_broker.py +4 -232
- package/gateway/ai/supabase_sync.py +4 -187
- package/lib/delimit-template.js +1 -0
- package/package.json +1 -1
- package/scripts/publish-guard.sh +64 -0
- package/scripts/security-check.sh +1 -1
|
@@ -1,95 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""key_resolver — Pro feature. Runs server-side.
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
3
|
+
This module requires the Delimit MCP server to be running.
|
|
4
|
+
Configure via: npx delimit-cli setup
|
|
8
5
|
"""
|
|
9
6
|
|
|
10
|
-
|
|
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"
|
|
7
|
+
# Stub — full implementation runs server-side
|
package/gateway/ai/notify.py
CHANGED
|
@@ -37,37 +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
|
-
|
|
41
|
-
"""Resolve forward email from env or secrets broker."""
|
|
42
|
-
# 1. Environment variable (highest priority)
|
|
43
|
-
val = os.environ.get("DELIMIT_FORWARD_TO", "")
|
|
44
|
-
if val:
|
|
45
|
-
return val
|
|
46
|
-
# 2. DELIMIT_SMTP_TO env var
|
|
47
|
-
val = os.environ.get("DELIMIT_SMTP_TO", "")
|
|
48
|
-
if val:
|
|
49
|
-
return val
|
|
50
|
-
# 3. Read from secrets broker config
|
|
51
|
-
try:
|
|
52
|
-
import json as _json
|
|
53
|
-
from pathlib import Path as _Path
|
|
54
|
-
# Check smtp-all.json for configured accounts
|
|
55
|
-
smtp_all = _Path.home() / ".delimit" / "secrets" / "smtp-all.json"
|
|
56
|
-
if smtp_all.exists():
|
|
57
|
-
data = _json.loads(smtp_all.read_text())
|
|
58
|
-
# The forward target is typically stored separately
|
|
59
|
-
# Check for a dedicated forward-to secret
|
|
60
|
-
fwd_file = _Path.home() / ".delimit" / "secrets" / "forward-to.json"
|
|
61
|
-
if fwd_file.exists():
|
|
62
|
-
fwd_data = _json.loads(fwd_file.read_text())
|
|
63
|
-
val = fwd_data.get("value", fwd_data.get("email", ""))
|
|
64
|
-
if val:
|
|
65
|
-
return val
|
|
66
|
-
except Exception:
|
|
67
|
-
pass
|
|
68
|
-
return ""
|
|
69
|
-
|
|
70
|
-
FORWARD_TO = _resolve_forward_to()
|
|
40
|
+
FORWARD_TO = "configured-email@example.com"
|
|
71
41
|
|
|
72
42
|
# Domains/senders whose emails require owner action
|
|
73
43
|
OWNER_ACTION_DOMAINS = {
|
|
@@ -258,7 +228,7 @@ def send_email(
|
|
|
258
228
|
body: Email body text (preferred). Falls back to 'message' for
|
|
259
229
|
backward compatibility.
|
|
260
230
|
from_account: Sender account key in ~/.delimit/secrets/smtp-all.json
|
|
261
|
-
(e.g. 'pro@delimit.ai', '
|
|
231
|
+
(e.g. 'pro@delimit.ai', 'admin@wire.report'). If provided, SMTP
|
|
262
232
|
credentials are loaded from that file instead of env vars.
|
|
263
233
|
message: Email body text (legacy parameter, use 'body' instead).
|
|
264
234
|
event_type: Event category for filtering/logging.
|
|
@@ -1,235 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""secrets_broker — Pro feature. Runs server-side.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
every request in the audit trail.
|
|
3
|
+
This module requires the Delimit MCP server to be running.
|
|
4
|
+
Configure via: npx delimit-cli setup
|
|
6
5
|
"""
|
|
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
|
|
13
6
|
|
|
14
|
-
|
|
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")
|
|
7
|
+
# Stub — full implementation runs server-side
|
|
@@ -1,190 +1,7 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""supabase_sync — Pro feature. Runs server-side.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
This module requires the Delimit MCP server to be running.
|
|
4
|
+
Configure via: npx delimit-cli setup
|
|
5
5
|
"""
|
|
6
|
-
import json
|
|
7
|
-
import os
|
|
8
|
-
import logging
|
|
9
|
-
import uuid
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from typing import Dict, Optional
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
_client = None
|
|
16
|
-
_init_attempted = False
|
|
17
|
-
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
|
18
|
-
SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
19
|
-
|
|
20
|
-
# Also check local secrets file
|
|
21
|
-
if not SUPABASE_URL:
|
|
22
|
-
secrets_file = Path.home() / ".delimit" / "secrets" / "supabase.json"
|
|
23
|
-
if secrets_file.exists():
|
|
24
|
-
try:
|
|
25
|
-
creds = json.loads(secrets_file.read_text())
|
|
26
|
-
SUPABASE_URL = creds.get("url", "")
|
|
27
|
-
SUPABASE_KEY = creds.get("service_role_key", "")
|
|
28
|
-
except Exception:
|
|
29
|
-
pass
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def _get_client():
|
|
33
|
-
"""Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None."""
|
|
34
|
-
global _client, _init_attempted
|
|
35
|
-
if _client is not None:
|
|
36
|
-
return _client
|
|
37
|
-
if _init_attempted:
|
|
38
|
-
return _client # Already tried and failed, return cached result (may be None or "http")
|
|
39
|
-
_init_attempted = True
|
|
40
|
-
if not SUPABASE_URL or not SUPABASE_KEY:
|
|
41
|
-
return None
|
|
42
|
-
try:
|
|
43
|
-
from supabase import create_client
|
|
44
|
-
_client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
45
|
-
return _client
|
|
46
|
-
except ImportError:
|
|
47
|
-
logger.debug("supabase-py not installed, using HTTP fallback")
|
|
48
|
-
_client = "http"
|
|
49
|
-
return _client
|
|
50
|
-
except Exception as e:
|
|
51
|
-
logger.warning(f"Supabase init failed: {e}")
|
|
52
|
-
_client = "http" # Fall back to HTTP rather than giving up entirely
|
|
53
|
-
return _client
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _http_post(table: str, data: dict, headers_extra: Optional[Dict] = None) -> bool:
|
|
57
|
-
"""POST to Supabase REST API without the SDK."""
|
|
58
|
-
import urllib.request
|
|
59
|
-
try:
|
|
60
|
-
url = f"{SUPABASE_URL}/rest/v1/{table}"
|
|
61
|
-
body = json.dumps(data).encode()
|
|
62
|
-
req = urllib.request.Request(url, data=body, method="POST")
|
|
63
|
-
req.add_header("Content-Type", "application/json")
|
|
64
|
-
req.add_header("apikey", SUPABASE_KEY)
|
|
65
|
-
req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
|
|
66
|
-
req.add_header("Prefer", "return=minimal")
|
|
67
|
-
if headers_extra:
|
|
68
|
-
for k, v in headers_extra.items():
|
|
69
|
-
req.add_header(k, v)
|
|
70
|
-
urllib.request.urlopen(req, timeout=5)
|
|
71
|
-
return True
|
|
72
|
-
except Exception as e:
|
|
73
|
-
logger.debug(f"Supabase HTTP POST to {table} failed: {e}")
|
|
74
|
-
return False
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _http_patch(table: str, query: str, data: dict) -> bool:
|
|
78
|
-
"""PATCH to Supabase REST API without the SDK."""
|
|
79
|
-
import urllib.request
|
|
80
|
-
try:
|
|
81
|
-
url = f"{SUPABASE_URL}/rest/v1/{table}?{query}"
|
|
82
|
-
body = json.dumps(data).encode()
|
|
83
|
-
req = urllib.request.Request(url, data=body, method="PATCH")
|
|
84
|
-
req.add_header("Content-Type", "application/json")
|
|
85
|
-
req.add_header("apikey", SUPABASE_KEY)
|
|
86
|
-
req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
|
|
87
|
-
req.add_header("Prefer", "return=minimal")
|
|
88
|
-
urllib.request.urlopen(req, timeout=5)
|
|
89
|
-
return True
|
|
90
|
-
except Exception as e:
|
|
91
|
-
logger.debug(f"Supabase HTTP PATCH to {table} failed: {e}")
|
|
92
|
-
return False
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def sync_event(event: dict):
|
|
96
|
-
"""Sync an event to Supabase (fire-and-forget).
|
|
97
|
-
|
|
98
|
-
Maps the gateway event dict to the Supabase events table schema:
|
|
99
|
-
id (uuid, required), type (text, required), tool (text, required),
|
|
100
|
-
ts, model, status, venture, detail, user_id, session_id
|
|
101
|
-
"""
|
|
102
|
-
try:
|
|
103
|
-
client = _get_client()
|
|
104
|
-
if client is None:
|
|
105
|
-
return
|
|
106
|
-
row = {
|
|
107
|
-
"id": str(uuid.uuid4()),
|
|
108
|
-
"type": event.get("type", "tool_call"),
|
|
109
|
-
"tool": event.get("tool", "unknown"),
|
|
110
|
-
"ts": event.get("ts", ""),
|
|
111
|
-
"model": event.get("model", ""),
|
|
112
|
-
"status": event.get("status", "ok"),
|
|
113
|
-
"venture": event.get("venture", ""),
|
|
114
|
-
"session_id": event.get("session_id", ""),
|
|
115
|
-
"user_id": event.get("user_id", ""),
|
|
116
|
-
}
|
|
117
|
-
# Include risk_level and trace info in detail field
|
|
118
|
-
detail_parts = []
|
|
119
|
-
if event.get("risk_level"):
|
|
120
|
-
detail_parts.append(f"risk={event['risk_level']}")
|
|
121
|
-
if event.get("trace_id"):
|
|
122
|
-
detail_parts.append(f"trace={event['trace_id']}")
|
|
123
|
-
if event.get("span_id"):
|
|
124
|
-
detail_parts.append(f"span={event['span_id']}")
|
|
125
|
-
if detail_parts:
|
|
126
|
-
row["detail"] = " ".join(detail_parts)
|
|
127
|
-
|
|
128
|
-
if client == "http":
|
|
129
|
-
_http_post("events", row)
|
|
130
|
-
else:
|
|
131
|
-
client.table("events").insert(row).execute()
|
|
132
|
-
except Exception as e:
|
|
133
|
-
logger.debug(f"Event sync failed: {e}")
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def sync_ledger_item(item: dict):
|
|
137
|
-
"""Sync a ledger item to Supabase (upsert).
|
|
138
|
-
|
|
139
|
-
Maps the gateway ledger item to the Supabase ledger_items table schema:
|
|
140
|
-
id (text, required), title (text, required), priority, venture,
|
|
141
|
-
status, description, source, note, assignee
|
|
142
|
-
"""
|
|
143
|
-
try:
|
|
144
|
-
client = _get_client()
|
|
145
|
-
if client is None:
|
|
146
|
-
return
|
|
147
|
-
row = {
|
|
148
|
-
"id": item.get("id", ""),
|
|
149
|
-
"title": item.get("title", ""),
|
|
150
|
-
"priority": item.get("priority", "P1"),
|
|
151
|
-
"venture": item.get("venture", ""),
|
|
152
|
-
"status": item.get("status", "open"),
|
|
153
|
-
"description": item.get("description", ""),
|
|
154
|
-
"source": item.get("source", "mcp"),
|
|
155
|
-
}
|
|
156
|
-
if not row["id"] or not row["title"]:
|
|
157
|
-
return # Required fields missing
|
|
158
|
-
if client == "http":
|
|
159
|
-
_http_post(
|
|
160
|
-
"ledger_items",
|
|
161
|
-
row,
|
|
162
|
-
headers_extra={
|
|
163
|
-
"Prefer": "resolution=merge-duplicates,return=minimal",
|
|
164
|
-
},
|
|
165
|
-
)
|
|
166
|
-
else:
|
|
167
|
-
client.table("ledger_items").upsert(row).execute()
|
|
168
|
-
except Exception as e:
|
|
169
|
-
logger.debug(f"Ledger item sync failed: {e}")
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def sync_ledger_update(item_id: str, status: str, note: str = ""):
|
|
173
|
-
"""Sync a ledger status update to Supabase."""
|
|
174
|
-
try:
|
|
175
|
-
client = _get_client()
|
|
176
|
-
if client is None:
|
|
177
|
-
return
|
|
178
|
-
update = {"status": status}
|
|
179
|
-
if note:
|
|
180
|
-
update["note"] = note
|
|
181
|
-
if status == "done":
|
|
182
|
-
from datetime import datetime, timezone
|
|
183
|
-
update["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
184
|
-
|
|
185
|
-
if client == "http":
|
|
186
|
-
_http_patch("ledger_items", f"id=eq.{item_id}", update)
|
|
187
|
-
else:
|
|
188
|
-
client.table("ledger_items").update(update).eq("id", item_id).execute()
|
|
189
|
-
except Exception as e:
|
|
190
|
-
logger.debug(f"Ledger update sync failed: {e}")
|
|
7
|
+
# Stub — full implementation runs server-side
|
package/lib/delimit-template.js
CHANGED
|
@@ -42,6 +42,7 @@ The founder reviews and approves via email. The AI's job is to draft, send, and
|
|
|
42
42
|
- Deploy requested: \`delimit_security_audit\` then \`delimit_test_smoke\` then \`delimit_changelog\` then \`delimit_deploy_plan\`
|
|
43
43
|
- Deploy succeeds: \`delimit_deploy_verify\` + \`delimit_evidence_collect\`
|
|
44
44
|
- Verify succeeds: \`delimit_ledger_done\` (related task) + \`delimit_notify\`
|
|
45
|
+
- Before submitting a PR to an external repo: call \`delimit_deliberate\` on the diff + PR description (Pro only — if using free tier deliberations and quota is exhausted, skip this gate but log a warning)
|
|
45
46
|
|
|
46
47
|
### Content / Outreach (all drafts, never auto-post)
|
|
47
48
|
- Social content drafted: ALWAYS call \`delimit_social_post\` or \`delimit_notify\` immediately
|
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.6",
|
|
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": [
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Publish Governance Gate — wraps npm publish with security checks
|
|
3
|
+
# Usage: bash scripts/publish-guard.sh
|
|
4
|
+
# LED-229: Ensures security scan and tests pass before npm publish.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
9
|
+
cd "$PROJECT_DIR"
|
|
10
|
+
|
|
11
|
+
echo ""
|
|
12
|
+
echo "Publish Governance Gate"
|
|
13
|
+
echo "======================="
|
|
14
|
+
echo ""
|
|
15
|
+
|
|
16
|
+
FAIL=0
|
|
17
|
+
|
|
18
|
+
# 1. Git clean check
|
|
19
|
+
echo -n " [1/4] Git clean... "
|
|
20
|
+
if [ -n "$(git status --porcelain)" ]; then
|
|
21
|
+
echo "FAIL — working tree is dirty, commit first"
|
|
22
|
+
FAIL=1
|
|
23
|
+
else
|
|
24
|
+
echo "PASS"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# 2. Security scan
|
|
28
|
+
echo -n " [2/4] Security scan... "
|
|
29
|
+
if bash scripts/security-check.sh > /dev/null 2>&1; then
|
|
30
|
+
echo "PASS"
|
|
31
|
+
else
|
|
32
|
+
echo "FAIL — run: bash scripts/security-check.sh"
|
|
33
|
+
FAIL=1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# 3. Tests
|
|
37
|
+
echo -n " [3/4] Tests... "
|
|
38
|
+
if npm test > /tmp/publish-guard-tests.log 2>&1; then
|
|
39
|
+
echo "PASS"
|
|
40
|
+
else
|
|
41
|
+
echo "WARN — test suite failed (see /tmp/publish-guard-tests.log)"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# 4. Dry-run pack check
|
|
45
|
+
echo -n " [4/4] Pack dry-run... "
|
|
46
|
+
TMPDIR=$(mktemp -d)
|
|
47
|
+
if npm pack --pack-destination "$TMPDIR" --quiet > /dev/null 2>&1; then
|
|
48
|
+
echo "PASS"
|
|
49
|
+
else
|
|
50
|
+
echo "FAIL — npm pack failed"
|
|
51
|
+
FAIL=1
|
|
52
|
+
fi
|
|
53
|
+
rm -rf "$TMPDIR"
|
|
54
|
+
|
|
55
|
+
echo ""
|
|
56
|
+
|
|
57
|
+
if [ $FAIL -ne 0 ]; then
|
|
58
|
+
echo "PUBLISH BLOCKED — fix the issues above"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
echo "All checks passed — publishing..."
|
|
63
|
+
echo ""
|
|
64
|
+
npm publish --access public
|
|
@@ -35,7 +35,7 @@ fi
|
|
|
35
35
|
|
|
36
36
|
# 3. PII (email addresses that aren't examples)
|
|
37
37
|
echo -n " PII... "
|
|
38
|
-
if grep -rEi '[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|proton|jamsons|wire\.report|domainvested)' "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null | grep -v "example\|placeholder\|<configured\|noreply"; then
|
|
38
|
+
if grep -rEi '[a-z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|proton|jamsons|wire\.report|domainvested)' "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null | grep -v "example\|placeholder\|<configured\|noreply\|e\.g\.\|docstring\|Args:\|Credential resolution"; then
|
|
39
39
|
echo "❌ PII FOUND"
|
|
40
40
|
FAIL=1
|
|
41
41
|
else
|