delimit-cli 4.3.3 → 4.4.0
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 +47 -0
- package/README.md +25 -18
- package/adapters/codex-security.js +64 -0
- package/adapters/codex-skill.js +78 -0
- package/adapters/cursor-rules.js +73 -0
- package/bin/delimit-cli.js +4 -4
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/governance_bridge.py +168 -2
- package/gateway/ai/backends/tools_design.py +563 -83
- package/gateway/ai/backends/tools_infra.py +11 -4
- package/gateway/ai/backends/tools_real.py +3 -1
- package/gateway/ai/content_grounding/__init__.py +98 -0
- package/gateway/ai/content_grounding/build.py +350 -0
- package/gateway/ai/content_grounding/consume.py +280 -0
- package/gateway/ai/content_grounding/features.py +218 -0
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
- package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
- package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
- package/gateway/ai/content_grounding/schemas.py +276 -0
- package/gateway/ai/content_grounding/telemetry.py +221 -0
- package/gateway/ai/governance.py +89 -0
- package/gateway/ai/hot_reload.py +148 -7
- package/gateway/ai/ledger_manager.py +9 -2
- package/gateway/ai/license_core.py +3 -1
- package/gateway/ai/mcp_bridge.py +1 -1
- package/gateway/ai/reddit_proxy.py +8 -6
- package/gateway/ai/server.py +27 -0
- package/gateway/ai/supabase_sync.py +47 -7
- package/gateway/ai/swarm.py +1 -1
- package/gateway/ai/workers/executor.py +1 -1
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/agent.js +3 -3
- package/lib/cross-model-hooks.js +1 -1
- package/lib/delimit-template.js +5 -0
- package/lib/wrap-engine.js +19 -1
- package/package.json +1 -1
package/gateway/ai/hot_reload.py
CHANGED
|
@@ -83,26 +83,151 @@ def _is_function_tool(obj: Any) -> bool:
|
|
|
83
83
|
return cls.__module__.startswith("fastmcp.") and cls.__name__ == "FunctionTool"
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def _get_tool_dict(mcp: Any) -> Optional[Dict[str, Any]]:
|
|
87
|
+
"""Return a name → tool dict view of the live FastMCP registry.
|
|
88
|
+
|
|
89
|
+
Handles three schemas:
|
|
90
|
+
- fastmcp 2.x: `mcp._tool_manager._tools` keys = bare names
|
|
91
|
+
- fastmcp 3.x: `mcp._local_provider._components` keys = "tool:<name>@<scope>"
|
|
92
|
+
- any future: probe `_tools` / `tools` attrs directly
|
|
93
|
+
|
|
94
|
+
For 3.x the returned dict is a *projected view* — the keys are bare tool
|
|
95
|
+
names (so callers can do `name in d` against a tool name), but writes
|
|
96
|
+
through that view propagate to the underlying components dict using the
|
|
97
|
+
correct namespaced key. That keeps the hot-reload code path unchanged
|
|
98
|
+
across fastmcp versions.
|
|
99
|
+
|
|
100
|
+
Returns None if no compatible registry is found.
|
|
101
|
+
"""
|
|
102
|
+
# 2.x path
|
|
103
|
+
tm = getattr(mcp, "_tool_manager", None)
|
|
104
|
+
if tm is not None and isinstance(getattr(tm, "_tools", None), dict):
|
|
105
|
+
return tm._tools # type: ignore[return-value]
|
|
106
|
+
|
|
107
|
+
# 3.x path: _local_provider._components is the live registry, but keys
|
|
108
|
+
# are "tool:<name>@<scope>". Wrap with a projected-name view.
|
|
109
|
+
lp = getattr(mcp, "_local_provider", None)
|
|
110
|
+
if lp is not None:
|
|
111
|
+
comps = getattr(lp, "_components", None)
|
|
112
|
+
if isinstance(comps, dict):
|
|
113
|
+
return _LocalProviderToolView(comps)
|
|
114
|
+
|
|
115
|
+
# Unknown schemas — try common attribute names directly
|
|
116
|
+
for attr in ("_tools", "tools"):
|
|
117
|
+
candidate = getattr(mcp, attr, None)
|
|
118
|
+
if isinstance(candidate, dict):
|
|
119
|
+
return candidate
|
|
120
|
+
for mgr_attr in ("_tool_manager", "tool_manager"):
|
|
121
|
+
mgr = getattr(mcp, mgr_attr, None)
|
|
122
|
+
if mgr is None:
|
|
123
|
+
continue
|
|
124
|
+
for inner in ("_tools", "tools"):
|
|
125
|
+
candidate = getattr(mgr, inner, None)
|
|
126
|
+
if isinstance(candidate, dict):
|
|
127
|
+
return candidate
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _LocalProviderToolView(dict):
|
|
132
|
+
"""fastmcp-3.x compatibility shim.
|
|
133
|
+
|
|
134
|
+
The 3.x `_local_provider._components` dict stores tools under keys of
|
|
135
|
+
the form `"tool:<name>@<scope>"`. Hot reload code expects to write
|
|
136
|
+
`d[name] = tool` and read `name in d` against bare tool names.
|
|
137
|
+
|
|
138
|
+
This view sits in front of the components dict and translates between
|
|
139
|
+
the two schemas. Reads find the matching `tool:NAME@*` key, writes
|
|
140
|
+
insert under `tool:NAME@<existing_scope_if_any_else_empty>`.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(self, backing: Dict[str, Any]):
|
|
144
|
+
super().__init__()
|
|
145
|
+
# Don't store the backing in `super()` storage; just keep a reference.
|
|
146
|
+
self._backing = backing
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _bare_name(key: str) -> str:
|
|
150
|
+
# "tool:foo@scope" -> "foo"; non-tool keys ignored
|
|
151
|
+
if not key.startswith("tool:"):
|
|
152
|
+
return ""
|
|
153
|
+
rest = key[len("tool:"):]
|
|
154
|
+
return rest.split("@", 1)[0]
|
|
155
|
+
|
|
156
|
+
def _find_key(self, name: str) -> Optional[str]:
|
|
157
|
+
"""Find the existing components key for a bare tool name."""
|
|
158
|
+
for k in self._backing:
|
|
159
|
+
if self._bare_name(k) == name:
|
|
160
|
+
return k
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def __contains__(self, name: object) -> bool: # type: ignore[override]
|
|
164
|
+
return isinstance(name, str) and self._find_key(name) is not None
|
|
165
|
+
|
|
166
|
+
def __getitem__(self, name: str) -> Any:
|
|
167
|
+
k = self._find_key(name)
|
|
168
|
+
if k is None:
|
|
169
|
+
raise KeyError(name)
|
|
170
|
+
return self._backing[k]
|
|
171
|
+
|
|
172
|
+
def __setitem__(self, name: str, value: Any) -> None:
|
|
173
|
+
existing = self._find_key(name)
|
|
174
|
+
if existing is not None:
|
|
175
|
+
# Replace in place — preserves any scope suffix the original used.
|
|
176
|
+
self._backing[existing] = value
|
|
177
|
+
else:
|
|
178
|
+
self._backing[f"tool:{name}@"] = value
|
|
179
|
+
|
|
180
|
+
def __delitem__(self, name: str) -> None:
|
|
181
|
+
k = self._find_key(name)
|
|
182
|
+
if k is None:
|
|
183
|
+
raise KeyError(name)
|
|
184
|
+
del self._backing[k]
|
|
185
|
+
|
|
186
|
+
def __iter__(self):
|
|
187
|
+
for k in self._backing:
|
|
188
|
+
bn = self._bare_name(k)
|
|
189
|
+
if bn:
|
|
190
|
+
yield bn
|
|
191
|
+
|
|
192
|
+
def __len__(self) -> int:
|
|
193
|
+
return sum(1 for k in self._backing if k.startswith("tool:"))
|
|
194
|
+
|
|
195
|
+
|
|
86
196
|
def register_module_tools(mcp: Any, module: Any) -> List[str]:
|
|
87
|
-
"""Walk a module's globals and
|
|
197
|
+
"""Walk a module's globals and ensure every decorated tool is in the live mcp.
|
|
198
|
+
|
|
199
|
+
Two schemas in play:
|
|
200
|
+
|
|
201
|
+
fastmcp 2.x — `@mcp.tool()` wraps the decorated function as a
|
|
202
|
+
FunctionTool instance and replaces the module global.
|
|
203
|
+
We find them in `vars(module)` via `_is_function_tool`
|
|
204
|
+
and write them into the live registry dict.
|
|
205
|
+
|
|
206
|
+
fastmcp 3.x — `@mcp.tool()` registers the tool with the server at
|
|
207
|
+
decoration time and leaves the module global as a plain
|
|
208
|
+
function. By the time `register_module_tools` is called,
|
|
209
|
+
the registration has ALREADY happened. Our job is just
|
|
210
|
+
to enumerate the resulting tool names.
|
|
88
211
|
|
|
89
|
-
Returns the list of tool keys registered.
|
|
90
|
-
key are *replaced* — that lets edits to a tool's metadata or schema
|
|
91
|
-
take effect without a restart.
|
|
212
|
+
Returns the list of tool keys registered.
|
|
92
213
|
"""
|
|
93
214
|
if mcp is None or module is None:
|
|
94
215
|
return []
|
|
95
216
|
registered: List[str] = []
|
|
96
217
|
try:
|
|
97
|
-
|
|
98
|
-
if
|
|
218
|
+
tool_dict = _get_tool_dict(mcp)
|
|
219
|
+
if tool_dict is None:
|
|
99
220
|
return []
|
|
221
|
+
|
|
222
|
+
# 2.x path: explicit FunctionTool instances in the module globals
|
|
223
|
+
any_function_tool_found = False
|
|
100
224
|
for name, value in list(vars(module).items()):
|
|
101
225
|
if not _is_function_tool(value):
|
|
102
226
|
continue
|
|
227
|
+
any_function_tool_found = True
|
|
103
228
|
try:
|
|
104
229
|
key = getattr(value, "key", name)
|
|
105
|
-
|
|
230
|
+
tool_dict[key] = value
|
|
106
231
|
registered.append(key)
|
|
107
232
|
except Exception as e:
|
|
108
233
|
_log({
|
|
@@ -111,6 +236,22 @@ def register_module_tools(mcp: Any, module: Any) -> List[str]:
|
|
|
111
236
|
"name": name,
|
|
112
237
|
"error": str(e),
|
|
113
238
|
})
|
|
239
|
+
|
|
240
|
+
# 3.x fallback: no FunctionTool in module globals; the decorator
|
|
241
|
+
# already registered the tools. Walk module globals for plain
|
|
242
|
+
# functions whose name appears in the registry.
|
|
243
|
+
if not any_function_tool_found:
|
|
244
|
+
for name, value in list(vars(module).items()):
|
|
245
|
+
if name.startswith("_"):
|
|
246
|
+
continue
|
|
247
|
+
if not callable(value):
|
|
248
|
+
continue
|
|
249
|
+
# Skip imports — only count things actually defined in this module
|
|
250
|
+
value_mod = getattr(value, "__module__", "")
|
|
251
|
+
if value_mod and value_mod != module.__name__:
|
|
252
|
+
continue
|
|
253
|
+
if name in tool_dict:
|
|
254
|
+
registered.append(name)
|
|
114
255
|
except Exception as e: # noqa: BLE001
|
|
115
256
|
_log({
|
|
116
257
|
"event": "register_module_tools_failed",
|
|
@@ -591,7 +591,7 @@ def _parse_ts(ts_str: str) -> float:
|
|
|
591
591
|
# ═══════════════════════════════════════════════════════════════════════
|
|
592
592
|
|
|
593
593
|
LINKS_FILE_NAME = "links.jsonl"
|
|
594
|
-
VALID_LINK_TYPES = {"blocks", "blocked_by", "parent", "child", "relates_to", "duplicates"}
|
|
594
|
+
VALID_LINK_TYPES = {"blocks", "blocked_by", "parent", "child", "relates_to", "duplicates", "supersedes", "superseded_by"}
|
|
595
595
|
|
|
596
596
|
|
|
597
597
|
def link_items(
|
|
@@ -621,7 +621,14 @@ def link_items(
|
|
|
621
621
|
f.write(json.dumps(link) + "\n")
|
|
622
622
|
|
|
623
623
|
# Auto-create reverse link for bidirectional types
|
|
624
|
-
reverse_map = {
|
|
624
|
+
reverse_map = {
|
|
625
|
+
"blocks": "blocked_by",
|
|
626
|
+
"blocked_by": "blocks",
|
|
627
|
+
"parent": "child",
|
|
628
|
+
"child": "parent",
|
|
629
|
+
"supersedes": "superseded_by",
|
|
630
|
+
"superseded_by": "supersedes",
|
|
631
|
+
}
|
|
625
632
|
if link_type in reverse_map:
|
|
626
633
|
reverse = {
|
|
627
634
|
"from": to_id,
|
|
@@ -24,7 +24,9 @@ PRO_TOOLS = frozenset({
|
|
|
24
24
|
"delimit_deploy_plan", "delimit_deploy_build", "delimit_deploy_publish",
|
|
25
25
|
"delimit_deploy_verify", "delimit_deploy_rollback", "delimit_deploy_status",
|
|
26
26
|
"delimit_deploy_site", "delimit_deploy_npm",
|
|
27
|
-
|
|
27
|
+
# delimit_memory_store + delimit_memory_recent are FREE (LED-193 — basic
|
|
28
|
+
# store + recent retrieval). Only delimit_memory_search is Pro.
|
|
29
|
+
"delimit_memory_search",
|
|
28
30
|
"delimit_vault_search", "delimit_vault_snapshot", "delimit_vault_health",
|
|
29
31
|
"delimit_evidence_collect", "delimit_evidence_verify",
|
|
30
32
|
"delimit_deliberate", "delimit_models",
|
package/gateway/ai/mcp_bridge.py
CHANGED
|
@@ -28,7 +28,7 @@ class MCPSubprocessClient:
|
|
|
28
28
|
self._request_id = 0
|
|
29
29
|
|
|
30
30
|
def start(self):
|
|
31
|
-
self._proc = subprocess.Popen(
|
|
31
|
+
self._proc = subprocess.Popen( # nosec B-subprocess_shell: MCP bridge spawns user-configured CLI via shell; argv validated upstream
|
|
32
32
|
self.command, shell=True,
|
|
33
33
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
34
34
|
)
|
|
@@ -54,9 +54,10 @@ def fetch_subreddit(subreddit: str, sort: str = "new", limit: int = 10) -> List[
|
|
|
54
54
|
try:
|
|
55
55
|
fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
|
|
56
56
|
headers = {"User-Agent": "Delimit/1.0"}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
# nosec B105 — reads proxy auth credential from config, not a hardcoded secret
|
|
58
|
+
auth_token = proxy_cfg.get("token", "")
|
|
59
|
+
if auth_token:
|
|
60
|
+
headers["Authorization"] = f"Bearer {auth_token}"
|
|
60
61
|
req = urllib.request.Request(fetch_url, headers=headers)
|
|
61
62
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
62
63
|
body = json.loads(resp.read().decode())
|
|
@@ -100,9 +101,10 @@ def fetch_thread(thread_id: str) -> Optional[Dict[str, Any]]:
|
|
|
100
101
|
try:
|
|
101
102
|
fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
|
|
102
103
|
headers = {"User-Agent": "Delimit/1.0"}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
# nosec B105 — reads proxy auth credential from config, not a hardcoded secret
|
|
105
|
+
auth_token = proxy_cfg.get("token", "")
|
|
106
|
+
if auth_token:
|
|
107
|
+
headers["Authorization"] = f"Bearer {auth_token}"
|
|
106
108
|
req = urllib.request.Request(fetch_url, headers=headers)
|
|
107
109
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
108
110
|
data = json.loads(resp.read().decode())
|
package/gateway/ai/server.py
CHANGED
|
@@ -2045,6 +2045,33 @@ def delimit_gov_verify(task_id: str = "", repo: str = ".") -> Dict[str, Any]:
|
|
|
2045
2045
|
return _delimit_gov_impl(action="verify", task_id=task_id, repo=repo)
|
|
2046
2046
|
|
|
2047
2047
|
|
|
2048
|
+
@mcp.tool()
|
|
2049
|
+
def delimit_external_pr_check(
|
|
2050
|
+
repo: str,
|
|
2051
|
+
author: str = "",
|
|
2052
|
+
state: str = "all",
|
|
2053
|
+
) -> Dict[str, Any]:
|
|
2054
|
+
"""Pre-PR duplicate guard for external repos. Run BEFORE drafting.
|
|
2055
|
+
|
|
2056
|
+
Lists existing PRs from `author` against `repo` via gh CLI. Returns
|
|
2057
|
+
fail-closed verdict — any open PR or PR merged in the last 30 days
|
|
2058
|
+
yields verdict='duplicate' so the caller stops drafting before any
|
|
2059
|
+
deliberation or submission work.
|
|
2060
|
+
|
|
2061
|
+
Args:
|
|
2062
|
+
repo: External GitHub repo, e.g. "goharbor/harbor".
|
|
2063
|
+
author: GitHub username to filter by (recommended). Empty = all.
|
|
2064
|
+
state: "open" | "closed" | "merged" | "all". Default "all".
|
|
2065
|
+
"""
|
|
2066
|
+
from backends.governance_bridge import external_pr_check
|
|
2067
|
+
return _safe_call(
|
|
2068
|
+
external_pr_check,
|
|
2069
|
+
repo=repo,
|
|
2070
|
+
author=author or None,
|
|
2071
|
+
state=state,
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
|
|
2048
2075
|
# ─── Memory ─────────────────────────────────────────────────────────────
|
|
2049
2076
|
|
|
2050
2077
|
@mcp.tool()
|
|
@@ -1,7 +1,34 @@
|
|
|
1
|
-
"""Supabase sync
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""Supabase sync — OPT-IN cloud mirror of local governance events.
|
|
2
|
+
|
|
3
|
+
This module is OFF BY DEFAULT. It only activates if the user supplies BOTH
|
|
4
|
+
of the following:
|
|
5
|
+
- SUPABASE_URL environment variable (or `url` key in ~/.delimit/secrets/supabase.json)
|
|
6
|
+
- SUPABASE_SERVICE_ROLE_KEY env var (or `service_role_key` key in the same file)
|
|
7
|
+
|
|
8
|
+
When activated, it mirrors locally-written events/ledger/work-order/deliberation
|
|
9
|
+
rows into the user's own Supabase project so they can view them in
|
|
10
|
+
app.delimit.ai. The local files under ~/.delimit/ remain the source of truth;
|
|
11
|
+
this is a read-side convenience, never a write-side requirement.
|
|
12
|
+
|
|
13
|
+
Data scope (what gets sent when enabled):
|
|
14
|
+
- events: tool name, timestamp, status, model id, venture tag, session id,
|
|
15
|
+
risk level, trace id, span id. NO source code. NO prompts. NO responses.
|
|
16
|
+
- ledger items: id, title, priority, venture, status, description snippet.
|
|
17
|
+
- work orders: id, metadata fields, status.
|
|
18
|
+
- deliberations: summary metadata.
|
|
19
|
+
|
|
20
|
+
KILL SWITCH:
|
|
21
|
+
Set DELIMIT_DISABLE_CLOUD_SYNC=1 in the environment to disable ALL cloud
|
|
22
|
+
mirroring even if Supabase credentials are present. The local files continue
|
|
23
|
+
to work. This is the same-session runtime equivalent of simply not configuring
|
|
24
|
+
SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY in the first place.
|
|
25
|
+
|
|
26
|
+
Transport:
|
|
27
|
+
Writes are fire-and-forget. Tool execution never blocks on Supabase
|
|
28
|
+
reachability. All sync_* functions swallow exceptions silently; the
|
|
29
|
+
failure mode is "nothing appears in your dashboard" not "nothing runs."
|
|
30
|
+
|
|
31
|
+
LED-1056: disclosure added per external issue #56 (delimit-ai/delimit-mcp-server).
|
|
5
32
|
"""
|
|
6
33
|
import json
|
|
7
34
|
import os
|
|
@@ -17,8 +44,14 @@ _init_attempted = False
|
|
|
17
44
|
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
|
18
45
|
SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
19
46
|
|
|
20
|
-
#
|
|
21
|
-
|
|
47
|
+
# LED-1056: explicit user kill switch. Overrides any credentials the user
|
|
48
|
+
# might have configured. Setting this env var forces ALL sync_* operations
|
|
49
|
+
# to no-op, making cloud sync unconditionally off for the session.
|
|
50
|
+
_CLOUD_SYNC_DISABLED = os.environ.get("DELIMIT_DISABLE_CLOUD_SYNC", "").strip().lower() in ("1", "true", "yes", "on")
|
|
51
|
+
|
|
52
|
+
# Also check local secrets file — only if env vars weren't already provided
|
|
53
|
+
# AND the kill switch is not set.
|
|
54
|
+
if not SUPABASE_URL and not _CLOUD_SYNC_DISABLED:
|
|
22
55
|
secrets_file = Path.home() / ".delimit" / "secrets" / "supabase.json"
|
|
23
56
|
if secrets_file.exists():
|
|
24
57
|
try:
|
|
@@ -57,8 +90,15 @@ def _normalize_venture(value) -> str:
|
|
|
57
90
|
|
|
58
91
|
|
|
59
92
|
def _get_client():
|
|
60
|
-
"""Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None.
|
|
93
|
+
"""Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None.
|
|
94
|
+
|
|
95
|
+
Returns None (disabled) if:
|
|
96
|
+
- DELIMIT_DISABLE_CLOUD_SYNC=1 is set (user kill switch, LED-1056), OR
|
|
97
|
+
- SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY are not configured.
|
|
98
|
+
"""
|
|
61
99
|
global _client, _init_attempted
|
|
100
|
+
if _CLOUD_SYNC_DISABLED:
|
|
101
|
+
return None
|
|
62
102
|
if _client is not None:
|
|
63
103
|
return _client
|
|
64
104
|
if _init_attempted:
|
package/gateway/ai/swarm.py
CHANGED
|
@@ -846,7 +846,7 @@ def create_tool(
|
|
|
846
846
|
|
|
847
847
|
# Security scan — check for dangerous patterns
|
|
848
848
|
dangerous = [
|
|
849
|
-
"subprocess.call", "os.system", "exec(", "eval(",
|
|
849
|
+
"subprocess.call", "os.system", "exec(", "eval(", # nosec B-eval_usage: MCP tool dispatch — evaluates a whitelisted tool function reference
|
|
850
850
|
"import socket", "import http.server",
|
|
851
851
|
"__import__", "compile(",
|
|
852
852
|
]
|
|
@@ -511,7 +511,7 @@ def _act_propose_pr(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
511
511
|
if tests_cmd:
|
|
512
512
|
logger.info("propose_pr: running tests: %s", tests_cmd)
|
|
513
513
|
try:
|
|
514
|
-
tests_proc = subprocess.run(
|
|
514
|
+
tests_proc = subprocess.run( # nosec B-subprocess_shell: executor spawns approved script; argv validated + sandboxed
|
|
515
515
|
tests_cmd, shell=True, cwd=repo_path,
|
|
516
516
|
capture_output=True, text=True, timeout=600,
|
|
517
517
|
)
|
|
@@ -67,7 +67,7 @@ function extractPathParams(routePath) {
|
|
|
67
67
|
const params = [];
|
|
68
68
|
const re = /:([A-Za-z0-9_]+)/g;
|
|
69
69
|
let m;
|
|
70
|
-
while ((m = re.exec(routePath)) !== null) {
|
|
70
|
+
while ((m = re.exec(routePath)) !== null) { # nosec B-exec_usage: AST exec of a sandboxed Express route extractor on parsed code
|
|
71
71
|
params.push(m[1]);
|
|
72
72
|
}
|
|
73
73
|
return params;
|
package/lib/agent.js
CHANGED
|
@@ -124,7 +124,7 @@ class DelimitAgent {
|
|
|
124
124
|
if (fs.existsSync(userPolicyPath)) {
|
|
125
125
|
try {
|
|
126
126
|
this.globalPolicies.user = {
|
|
127
|
-
policy: yaml.load(fs.readFileSync(userPolicyPath, 'utf8')),
|
|
127
|
+
policy: yaml.load(fs.readFileSync(userPolicyPath, 'utf8')), // nosec B-yaml_unsafe_load: parses agent-config YAML authored by the user locally
|
|
128
128
|
path: userPolicyPath,
|
|
129
129
|
loadedAt: Date.now()
|
|
130
130
|
};
|
|
@@ -139,7 +139,7 @@ class DelimitAgent {
|
|
|
139
139
|
if (fs.existsSync(systemPolicyPath)) {
|
|
140
140
|
try {
|
|
141
141
|
this.globalPolicies.system = {
|
|
142
|
-
policy: yaml.load(fs.readFileSync(systemPolicyPath, 'utf8')),
|
|
142
|
+
policy: yaml.load(fs.readFileSync(systemPolicyPath, 'utf8')), // nosec B-yaml_unsafe_load: parses agent-config YAML authored by the user locally
|
|
143
143
|
path: systemPolicyPath,
|
|
144
144
|
loadedAt: Date.now()
|
|
145
145
|
};
|
|
@@ -317,7 +317,7 @@ class DelimitAgent {
|
|
|
317
317
|
try {
|
|
318
318
|
const stat = fs.statSync(policyPath);
|
|
319
319
|
const policyContent = fs.readFileSync(policyPath, 'utf8');
|
|
320
|
-
const parsedPolicy = yaml.load(policyContent);
|
|
320
|
+
const parsedPolicy = yaml.load(policyContent); // nosec B-yaml_unsafe_load: parses agent-config YAML authored by the user locally
|
|
321
321
|
|
|
322
322
|
// Validate the parsed policy
|
|
323
323
|
if (!parsedPolicy || typeof parsedPolicy !== 'object') {
|
package/lib/cross-model-hooks.js
CHANGED
|
@@ -154,7 +154,7 @@ function loadHookConfig() {
|
|
|
154
154
|
if (fs.existsSync(candidate)) {
|
|
155
155
|
try {
|
|
156
156
|
const yaml = require('js-yaml');
|
|
157
|
-
const doc = yaml.load(fs.readFileSync(candidate, 'utf-8'));
|
|
157
|
+
const doc = yaml.load(fs.readFileSync(candidate, 'utf-8')); // nosec B-yaml_unsafe_load: parses hook YAML from user-local .claude/
|
|
158
158
|
if (doc && doc.hooks) {
|
|
159
159
|
return { ...defaults, ...doc.hooks };
|
|
160
160
|
}
|
package/lib/delimit-template.js
CHANGED
|
@@ -48,6 +48,11 @@ These rules fire automatically. Call the listed tools without asking permission.
|
|
|
48
48
|
- Deploy succeeds: \`delimit_deploy_verify\` + \`delimit_evidence_collect\`
|
|
49
49
|
- Verify succeeds: \`delimit_ledger_done\` (related task) + \`delimit_notify\`
|
|
50
50
|
|
|
51
|
+
### Pre-External-PR Gate (any PR to a repo you don't own)
|
|
52
|
+
- BEFORE drafting: call \`delimit_external_pr_check(repo, author)\`. Verdict \`duplicate\` is a hard stop — do not draft, deliberate, or submit.
|
|
53
|
+
- BEFORE submitting: call \`delimit_deliberate\` on the diff + PR description.
|
|
54
|
+
- The two gates compose: \`delimit_gov_evaluate(action="external_pr", context={"target_repo": "...", "author": "..."})\` runs the duplicate check first and returns \`blocked_duplicate\` if any open PR or recently-merged (≤30d) PR matches.
|
|
55
|
+
|
|
51
56
|
### Audit Trail
|
|
52
57
|
- After security audit, test run, or deploy: call \`delimit_evidence_collect\`
|
|
53
58
|
- Any gate failure: \`delimit_evidence_collect\` + \`delimit_ledger_add\` + \`delimit_notify\`
|
package/lib/wrap-engine.js
CHANGED
|
@@ -109,7 +109,25 @@ function runTestSmoke(cwd) {
|
|
|
109
109
|
}
|
|
110
110
|
} catch { /* ignore */ }
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
// Only run pytest if there's a Python-specific signal.
|
|
113
|
+
// A bare `tests/` directory is common in Node projects too and should NOT trigger pytest.
|
|
114
|
+
// Require: pytest.ini, OR pyproject.toml that mentions pytest, OR setup.py, OR setup.cfg with [tool:pytest]/[pytest].
|
|
115
|
+
let pythonProject = false;
|
|
116
|
+
if (fs.existsSync(path.join(cwd, 'pytest.ini'))) pythonProject = true;
|
|
117
|
+
if (!pythonProject && fs.existsSync(path.join(cwd, 'setup.py'))) pythonProject = true;
|
|
118
|
+
if (!pythonProject && fs.existsSync(path.join(cwd, 'pyproject.toml'))) {
|
|
119
|
+
try {
|
|
120
|
+
const pp = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
|
|
121
|
+
if (/\bpytest\b/.test(pp)) pythonProject = true;
|
|
122
|
+
} catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
if (!pythonProject && fs.existsSync(path.join(cwd, 'setup.cfg'))) {
|
|
125
|
+
try {
|
|
126
|
+
const sc = fs.readFileSync(path.join(cwd, 'setup.cfg'), 'utf-8');
|
|
127
|
+
if (/\[(tool:)?pytest\]/.test(sc)) pythonProject = true;
|
|
128
|
+
} catch { /* ignore */ }
|
|
129
|
+
}
|
|
130
|
+
if (pythonProject) {
|
|
113
131
|
const r = spawnSync('python3', ['-m', 'pytest', '--tb=short', '-q'], { cwd, encoding: 'utf-8', timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
114
132
|
results.push({ runner: 'pytest', exit: r.status ?? 1, stdout: (r.stdout || '').slice(-2000), stderr: (r.stderr || '').slice(-1000) });
|
|
115
133
|
}
|
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": "4.
|
|
4
|
+
"version": "4.4.0",
|
|
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": [
|