delimit-cli 4.5.1 → 4.5.3

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 (55) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +15 -5
  3. package/bin/delimit-cli.js +109 -24
  4. package/gateway/ai/content_engine.py +3 -4
  5. package/gateway/ai/inbox_classifier.py +215 -0
  6. package/gateway/ai/integrations/opensage_wrapper.py +4 -1
  7. package/gateway/ai/ledger_manager.py +218 -38
  8. package/gateway/ai/license.py +26 -0
  9. package/gateway/ai/notify.py +68 -3
  10. package/gateway/ai/reddit_proxy.py +93 -15
  11. package/gateway/ai/reddit_scanner.py +36 -18
  12. package/gateway/ai/remote_resolve.py +422 -0
  13. package/gateway/ai/server.py +301 -117
  14. package/gateway/ai/social_capability/__init__.py +6 -0
  15. package/gateway/ai/social_capability/capability_validator.py +367 -0
  16. package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
  17. package/gateway/ai/social_capability/fit_floor.py +360 -0
  18. package/gateway/ai/social_queue.py +307 -0
  19. package/gateway/ai/supabase_sync.py +14 -2
  20. package/gateway/ai/swarm.py +29 -11
  21. package/gateway/ai/tui.py +6 -2
  22. package/gateway/ai/vendor_news/__init__.py +14 -0
  23. package/gateway/ai/vendor_news/drafter.py +562 -0
  24. package/gateway/ai/vendor_news/sensor.py +509 -0
  25. package/gateway/ai/vendor_news/watchlist.yaml +71 -0
  26. package/gateway/ai/x_ranker.py +417 -0
  27. package/lib/attest-mcp.js +487 -0
  28. package/lib/attest-telemetry.js +48 -0
  29. package/lib/delimit-home.js +35 -0
  30. package/lib/delimit-template.js +14 -0
  31. package/package.json +25 -3
  32. package/scripts/postinstall.js +89 -40
  33. package/adapters/codex-security.js +0 -64
  34. package/adapters/codex-skill.js +0 -78
  35. package/gateway/ai/content_grounding/__init__.py +0 -98
  36. package/gateway/ai/content_grounding/build.py +0 -350
  37. package/gateway/ai/content_grounding/consume.py +0 -280
  38. package/gateway/ai/content_grounding/features.py +0 -218
  39. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
  40. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
  41. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
  42. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
  43. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
  44. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
  45. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
  46. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
  47. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
  48. package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
  49. package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
  50. package/gateway/ai/content_grounding/schemas.py +0 -276
  51. package/gateway/ai/content_grounding/telemetry.py +0 -221
  52. package/gateway/ai/inbox_drafts/__init__.py +0 -61
  53. package/gateway/ai/inbox_drafts/registry.py +0 -412
  54. package/gateway/ai/inbox_drafts/schema.py +0 -374
  55. package/gateway/ai/inbox_executor.py +0 -565
@@ -0,0 +1,422 @@
1
+ """Remote-input resolution helpers for MCP tools (LED-1237).
2
+
3
+ Tools like delimit_repo_analyze and delimit_lint historically only
4
+ accepted local filesystem paths. When a multi-model deliberation panel
5
+ emits ``[TOOL: delimit_repo_analyze target="calcom/cal.com"]`` the
6
+ target was resolved against the cwd and silently returned an empty
7
+ analysis (total_files=0).
8
+
9
+ This module adds **additive** remote-input handling:
10
+
11
+ * ``resolve_repo_target(target)`` — context manager that accepts either
12
+ a local path, a ``<owner>/<repo>`` shorthand, or a full
13
+ https/ssh GitHub URL, and yields ``(local_path, metadata)``.
14
+
15
+ * ``resolve_spec_input(spec)`` — context manager that accepts either a
16
+ local path or an http(s) URL, fetches the URL into a tempfile with
17
+ the right extension, and yields ``(local_path, metadata)``.
18
+
19
+ Both helpers are no-ops on local input (passthrough). All cleanup is
20
+ handled automatically. Network/clone failures raise
21
+ ``RemoteResolveError`` which callers should convert into a clean
22
+ ``{"error": "...", ...}`` response.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import ipaddress
29
+ import logging
30
+ import os
31
+ import re
32
+ import shutil
33
+ import socket
34
+ import subprocess
35
+ import tempfile
36
+ from pathlib import Path
37
+ from typing import Any, Dict, Iterator, Optional, Tuple
38
+ from urllib.parse import urlparse
39
+
40
+ logger = logging.getLogger("delimit.ai.remote_resolve")
41
+
42
+ # ─── Constants ───────────────────────────────────────────────────────────
43
+
44
+ GIT_CLONE_TIMEOUT_S = 120
45
+ HTTP_FETCH_TIMEOUT_S = 60
46
+ HTTP_FETCH_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
47
+
48
+ # <owner>/<repo> regex: each segment is a non-empty token without
49
+ # whitespace, slashes (other than the single separator), or path
50
+ # traversal. We deliberately keep this conservative — anything fancy
51
+ # falls back to local-path semantics.
52
+ _OWNER_REPO_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)/([A-Za-z0-9][A-Za-z0-9._-]*)$")
53
+
54
+ _GITHUB_URL_PREFIXES = (
55
+ "https://github.com/",
56
+ "http://github.com/",
57
+ "git@github.com:",
58
+ "ssh://git@github.com/",
59
+ )
60
+
61
+
62
+ # ─── Exceptions ──────────────────────────────────────────────────────────
63
+
64
+
65
+ class RemoteResolveError(Exception):
66
+ """Raised when a remote target cannot be resolved.
67
+
68
+ Carries an ``error`` code and a human-readable detail. Callers
69
+ should translate this into a structured response dict — never let
70
+ it bubble up as a stack trace into an MCP response.
71
+ """
72
+
73
+ def __init__(self, error: str, detail: str, target: Optional[str] = None) -> None:
74
+ super().__init__(detail)
75
+ self.error = error
76
+ self.detail = detail
77
+ self.target = target
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ out: Dict[str, Any] = {"error": self.error, "detail": self.detail}
81
+ if self.target is not None:
82
+ out["target"] = self.target
83
+ return out
84
+
85
+
86
+ # ─── Repo target classification ──────────────────────────────────────────
87
+
88
+
89
+ def _is_github_url(target: str) -> bool:
90
+ return any(target.startswith(p) for p in _GITHUB_URL_PREFIXES)
91
+
92
+
93
+ def _normalize_github_url(target: str) -> str:
94
+ """Convert any accepted GitHub URL form to an https clone URL.
95
+
96
+ Handles ``git@github.com:owner/repo(.git)`` and
97
+ ``ssh://git@github.com/owner/repo(.git)`` plus pre-formed
98
+ ``https://github.com/owner/repo``.
99
+ """
100
+ if target.startswith("git@github.com:"):
101
+ path = target[len("git@github.com:"):]
102
+ return "https://github.com/" + path.rstrip("/")
103
+ if target.startswith("ssh://git@github.com/"):
104
+ path = target[len("ssh://git@github.com/"):]
105
+ return "https://github.com/" + path.rstrip("/")
106
+ if target.startswith("http://github.com/"):
107
+ return "https://" + target[len("http://"):]
108
+ return target.rstrip("/")
109
+
110
+
111
+ def _looks_like_owner_repo(target: str) -> bool:
112
+ """Detect ``owner/repo`` shorthand.
113
+
114
+ Required:
115
+ * exactly one '/' separator
116
+ * both halves non-empty and match a conservative slug pattern
117
+ * no path traversal ('..'); does not start with '/' or '.'
118
+ * the literal ``./<owner>/<repo>`` does not exist on disk
119
+ (backwards-compat guard so a real local subdir wins)
120
+ """
121
+ if not target or target.startswith(("/", ".")):
122
+ return False
123
+ if ".." in target.split("/"):
124
+ return False
125
+ if not _OWNER_REPO_RE.match(target):
126
+ return False
127
+ if Path(target).exists():
128
+ # A real local dir/file with this name exists — treat as local.
129
+ return False
130
+ return True
131
+
132
+
133
+ def classify_repo_target(target: str) -> str:
134
+ """Return one of: ``"github_url"``, ``"owner_repo"``, ``"local"``.
135
+
136
+ Pure function, no I/O beyond a single ``Path.exists`` check for the
137
+ backwards-compat guard. Exposed for tests.
138
+ """
139
+ if _is_github_url(target):
140
+ return "github_url"
141
+ if _looks_like_owner_repo(target):
142
+ return "owner_repo"
143
+ return "local"
144
+
145
+
146
+ # ─── Git clone ───────────────────────────────────────────────────────────
147
+
148
+
149
+ def _git_clone_shallow(url: str, dest: str) -> None:
150
+ """Run ``git clone --depth 1 <url> <dest>``.
151
+
152
+ Raises ``RemoteResolveError`` on failure (non-zero exit, timeout, or
153
+ git binary missing).
154
+ """
155
+ try:
156
+ proc = subprocess.run(
157
+ ["git", "clone", "--depth", "1", url, dest],
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=GIT_CLONE_TIMEOUT_S,
161
+ )
162
+ except FileNotFoundError as e:
163
+ raise RemoteResolveError(
164
+ "clone_failed",
165
+ f"git binary not found: {e}",
166
+ target=url,
167
+ ) from e
168
+ except subprocess.TimeoutExpired as e:
169
+ raise RemoteResolveError(
170
+ "clone_failed",
171
+ f"git clone timed out after {GIT_CLONE_TIMEOUT_S}s",
172
+ target=url,
173
+ ) from e
174
+ except Exception as e: # pragma: no cover - defensive
175
+ raise RemoteResolveError(
176
+ "clone_failed",
177
+ f"git clone failed: {e}",
178
+ target=url,
179
+ ) from e
180
+
181
+ if proc.returncode != 0:
182
+ # stderr tends to carry the useful "Repository not found" /
183
+ # "Authentication failed" message. Trim it so we don't dump
184
+ # huge output into MCP responses.
185
+ stderr = (proc.stderr or "").strip()
186
+ if len(stderr) > 500:
187
+ stderr = stderr[:500] + "..."
188
+ raise RemoteResolveError(
189
+ "clone_failed",
190
+ stderr or f"git clone exited with code {proc.returncode}",
191
+ target=url,
192
+ )
193
+
194
+
195
+ @contextlib.contextmanager
196
+ def resolve_repo_target(target: str) -> Iterator[Tuple[str, Dict[str, Any]]]:
197
+ """Resolve a repo target to a local path.
198
+
199
+ Yields ``(path, metadata)``. Metadata always includes
200
+ ``resolved_from`` (one of ``"local"``, ``"remote_clone"``) and may
201
+ include ``upstream_url`` for remote clones.
202
+
203
+ Cleanup of any temporary clone happens automatically when the
204
+ ``with`` block exits, even on exception inside the block.
205
+
206
+ Raises ``RemoteResolveError`` if the input cannot be resolved
207
+ (clone failure, malformed URL). Local-path targets always succeed
208
+ here — the existence check stays the responsibility of the
209
+ downstream backend so that ``analyze("nonexistent/")`` keeps its
210
+ pre-existing behavior.
211
+ """
212
+ kind = classify_repo_target(target)
213
+
214
+ if kind == "local":
215
+ yield target, {"resolved_from": "local"}
216
+ return
217
+
218
+ # Remote: normalize to an https URL.
219
+ if kind == "github_url":
220
+ url = _normalize_github_url(target)
221
+ else: # owner_repo
222
+ url = f"https://github.com/{target}"
223
+
224
+ tmp = tempfile.mkdtemp(prefix="delimit-repo-")
225
+ # mkdtemp creates the dir; git clone refuses to clone into an
226
+ # existing non-empty dir, so we point it at a child path.
227
+ clone_dir = os.path.join(tmp, "repo")
228
+ try:
229
+ _git_clone_shallow(url, clone_dir)
230
+ meta: Dict[str, Any] = {
231
+ "resolved_from": "remote_clone",
232
+ "upstream_url": url,
233
+ }
234
+ yield clone_dir, meta
235
+ finally:
236
+ shutil.rmtree(tmp, ignore_errors=True)
237
+
238
+
239
+ # ─── Spec (URL) input resolution ─────────────────────────────────────────
240
+
241
+
242
+ _ALLOWED_SPEC_SCHEMES = ("http", "https")
243
+
244
+
245
+ def _classify_spec_input(spec: str) -> str:
246
+ """Return ``"url"`` or ``"local"``.
247
+
248
+ Anything starting with ``http://`` or ``https://`` is a URL.
249
+ Everything else is a local path. Unknown schemes (file://, ftp://,
250
+ javascript:) are rejected up front — but only if they actually look
251
+ like a URL, otherwise a local path beginning with ``foo:`` would
252
+ misclassify on Windows-style drives etc. We're strict: any
253
+ ``<scheme>://`` prefix that isn't http(s) is rejected.
254
+ """
255
+ if spec.startswith(("http://", "https://")):
256
+ return "url"
257
+ # Reject other URL-ish schemes outright.
258
+ m = re.match(r"^([a-zA-Z][a-zA-Z0-9+\-.]*)://", spec)
259
+ if m:
260
+ raise RemoteResolveError(
261
+ "invalid_scheme",
262
+ f"only http(s) URLs are accepted; got scheme '{m.group(1)}'",
263
+ target=spec,
264
+ )
265
+ # javascript: / data: — no '//' after the colon, treat as local
266
+ # path candidate (will fail downstream if invalid).
267
+ return "local"
268
+
269
+
270
+ def _ensure_public_host(url: str) -> None:
271
+ """SSRF guard: reject URLs that resolve to private/loopback IPs.
272
+
273
+ Resolves the hostname and checks the resulting IP against the
274
+ standard private/loopback/link-local/reserved ranges. Raises
275
+ ``RemoteResolveError`` on any disallowed host.
276
+ """
277
+ parsed = urlparse(url)
278
+ host = parsed.hostname
279
+ if not host:
280
+ raise RemoteResolveError(
281
+ "invalid_url",
282
+ "URL is missing a hostname",
283
+ target=url,
284
+ )
285
+ try:
286
+ ip_str = socket.gethostbyname(host)
287
+ except OSError as e:
288
+ raise RemoteResolveError(
289
+ "fetch_failed",
290
+ f"DNS resolution failed for {host}: {e}",
291
+ target=url,
292
+ ) from e
293
+ try:
294
+ ip = ipaddress.ip_address(ip_str)
295
+ except ValueError as e: # pragma: no cover - defensive
296
+ raise RemoteResolveError(
297
+ "fetch_failed",
298
+ f"could not parse resolved IP {ip_str}: {e}",
299
+ target=url,
300
+ ) from e
301
+ if (
302
+ ip.is_private
303
+ or ip.is_loopback
304
+ or ip.is_link_local
305
+ or ip.is_reserved
306
+ or ip.is_multicast
307
+ or ip.is_unspecified
308
+ ):
309
+ raise RemoteResolveError(
310
+ "blocked_host",
311
+ f"refusing to fetch from non-public host {host} ({ip_str})",
312
+ target=url,
313
+ )
314
+
315
+
316
+ def _spec_extension_for(url: str, content_type: str) -> str:
317
+ """Pick a tempfile extension based on URL or response Content-Type."""
318
+ parsed = urlparse(url)
319
+ path = parsed.path.lower()
320
+ for ext in (".yaml", ".yml", ".json"):
321
+ if path.endswith(ext):
322
+ return ext
323
+ ct = (content_type or "").lower()
324
+ if "yaml" in ct or "yml" in ct:
325
+ return ".yaml"
326
+ if "json" in ct:
327
+ return ".json"
328
+ # Fall back: many OpenAPI specs are served with text/plain — use
329
+ # .json because the loader auto-detects, and json parsers fail
330
+ # fast and clean on yaml.
331
+ return ".json"
332
+
333
+
334
+ def _http_fetch(url: str) -> Tuple[bytes, str]:
335
+ """Fetch ``url`` with size/timeout caps. Returns (body, content_type).
336
+
337
+ Raises ``RemoteResolveError`` on any failure (network, timeout,
338
+ oversize body, non-2xx status).
339
+ """
340
+ # Lazy import so test envs without urllib quirks don't choke at
341
+ # module import time.
342
+ from urllib.error import HTTPError, URLError
343
+ from urllib.request import Request, urlopen
344
+
345
+ req = Request(url, headers={"User-Agent": "delimit-gateway-remote-resolve/1"})
346
+ try:
347
+ with urlopen(req, timeout=HTTP_FETCH_TIMEOUT_S) as resp:
348
+ content_type = resp.headers.get("Content-Type", "")
349
+ # Read up to MAX+1 to detect oversize.
350
+ body = resp.read(HTTP_FETCH_MAX_BYTES + 1)
351
+ except HTTPError as e:
352
+ raise RemoteResolveError(
353
+ "fetch_failed",
354
+ f"HTTP {e.code}: {e.reason}",
355
+ target=url,
356
+ ) from e
357
+ except URLError as e:
358
+ raise RemoteResolveError(
359
+ "fetch_failed",
360
+ f"network error: {e.reason}",
361
+ target=url,
362
+ ) from e
363
+ except Exception as e:
364
+ raise RemoteResolveError(
365
+ "fetch_failed",
366
+ f"fetch failed: {e}",
367
+ target=url,
368
+ ) from e
369
+
370
+ if len(body) > HTTP_FETCH_MAX_BYTES:
371
+ raise RemoteResolveError(
372
+ "fetch_failed",
373
+ f"response body exceeds {HTTP_FETCH_MAX_BYTES} bytes",
374
+ target=url,
375
+ )
376
+ return body, content_type
377
+
378
+
379
+ @contextlib.contextmanager
380
+ def resolve_spec_input(spec: str) -> Iterator[Tuple[str, Dict[str, Any]]]:
381
+ """Resolve a spec input (local path or URL) to a local path.
382
+
383
+ Yields ``(path, metadata)``. Metadata always includes
384
+ ``resolved_from`` (``"local"`` or ``"url"``) and, for URLs,
385
+ ``upstream_url`` and ``content_type``.
386
+
387
+ Tempfiles are cleaned up automatically when the ``with`` block
388
+ exits.
389
+ """
390
+ kind = _classify_spec_input(spec)
391
+
392
+ if kind == "local":
393
+ yield spec, {"resolved_from": "local"}
394
+ return
395
+
396
+ # URL path: SSRF guard, fetch, write to tempfile.
397
+ _ensure_public_host(spec)
398
+ body, content_type = _http_fetch(spec)
399
+ ext = _spec_extension_for(spec, content_type)
400
+ fd, tmp_path = tempfile.mkstemp(prefix="delimit-spec-", suffix=ext)
401
+ try:
402
+ with os.fdopen(fd, "wb") as f:
403
+ f.write(body)
404
+ meta: Dict[str, Any] = {
405
+ "resolved_from": "url",
406
+ "upstream_url": spec,
407
+ "content_type": content_type,
408
+ }
409
+ yield tmp_path, meta
410
+ finally:
411
+ try:
412
+ os.unlink(tmp_path)
413
+ except OSError:
414
+ pass
415
+
416
+
417
+ __all__ = [
418
+ "RemoteResolveError",
419
+ "classify_repo_target",
420
+ "resolve_repo_target",
421
+ "resolve_spec_input",
422
+ ]