delimit-cli 4.6.0 → 4.6.2
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 +71 -8
- package/bin/delimit-cli.js +59 -9
- package/bin/delimit-setup.js +7 -3
- package/gateway/ai/agent_dispatch.py +5 -0
- package/gateway/ai/backends/gateway_core.py +6 -0
- package/gateway/ai/backends/git_health.py +175 -0
- package/gateway/ai/backends/memory_bridge.py +210 -53
- package/gateway/ai/backends/tools_infra.py +93 -0
- package/gateway/ai/backends/tools_real.py +53 -7
- package/gateway/ai/cli_contract.py +185 -0
- package/gateway/ai/governance.py +181 -0
- package/gateway/ai/heartbeat.py +290 -0
- package/gateway/ai/ledger_manager.py +81 -4
- package/gateway/ai/ledger_proof.py +127 -0
- package/gateway/ai/license.py +132 -47
- package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
- package/gateway/ai/license_core.pyi +1 -1
- package/gateway/ai/outreach_loop_daemon.py +349 -0
- package/gateway/ai/outreach_substantive.py +768 -7
- package/gateway/ai/pro_tools.yaml +167 -0
- package/gateway/ai/reddit_scanner.py +7 -1
- package/gateway/ai/server.py +295 -116
- package/gateway/ai/session_phoenix.py +121 -0
- package/gateway/ai/social_queue.py +166 -10
- package/gateway/ai/tenant_auth.py +329 -0
- package/gateway/ai/tenant_data.py +339 -0
- package/gateway/ai/tenant_paths.py +150 -0
- package/gateway/core/diff_engine_v2.py +517 -54
- package/gateway/core/semver_classifier.py +52 -6
- package/package.json +4 -1
- package/scripts/build-license-core.sh +0 -85
- package/scripts/security-check.sh +0 -66
- package/scripts/test-license-core-so.sh +0 -107
|
@@ -14,11 +14,131 @@ logger = logging.getLogger("delimit.ai.memory_bridge")
|
|
|
14
14
|
|
|
15
15
|
MEMORY_DIR = Path.home() / ".delimit" / "memory"
|
|
16
16
|
|
|
17
|
+
# Legacy CLI store filename. The npm CLI historically wrote memories as
|
|
18
|
+
# newline-delimited JSON (`memories.jsonl`) using a `text`/`created`/`source`
|
|
19
|
+
# schema, while the MCP store writes one `mem-*.json` file per entry using
|
|
20
|
+
# `content`/`created_at`/`context`. The readers below reconcile both so a
|
|
21
|
+
# customer who created memories via the old CLI still sees them through the
|
|
22
|
+
# MCP tools (FIX C — non-destructive; the .jsonl is never rewritten here).
|
|
23
|
+
LEGACY_JSONL_NAME = "memories.jsonl"
|
|
24
|
+
|
|
17
25
|
|
|
18
26
|
def _ensure_dir():
|
|
19
27
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
28
|
|
|
21
29
|
|
|
30
|
+
def _tokenize(query: str) -> List[str]:
|
|
31
|
+
"""Split a search query into lowercased whitespace-delimited tokens.
|
|
32
|
+
|
|
33
|
+
Used by search() for OR-semantics keyword matching: an entry is a hit
|
|
34
|
+
if it contains at least one token. Empty / whitespace-only queries
|
|
35
|
+
yield no tokens (callers preserve their own empty-query behavior).
|
|
36
|
+
"""
|
|
37
|
+
return [t for t in (query or "").lower().split() if t]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize_legacy_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
41
|
+
"""Normalize a legacy `memories.jsonl` record to the MCP entry shape.
|
|
42
|
+
|
|
43
|
+
Legacy CLI schema: {id, text, tags, created, source}
|
|
44
|
+
MCP schema: {id, content, tags, context, created_at, hot_load}
|
|
45
|
+
|
|
46
|
+
Maps text->content and created->created_at without dropping the
|
|
47
|
+
original keys, and synthesizes a context from `source` when absent so
|
|
48
|
+
downstream readers behave uniformly. Mirrors the CLI's readMemories
|
|
49
|
+
normalization (npm-delimit/bin/delimit-cli.js) for cross-tool parity.
|
|
50
|
+
"""
|
|
51
|
+
entry = dict(raw)
|
|
52
|
+
if entry.get("text") and not entry.get("content"):
|
|
53
|
+
entry["content"] = entry["text"]
|
|
54
|
+
if entry.get("content") and not entry.get("text"):
|
|
55
|
+
entry["text"] = entry["content"]
|
|
56
|
+
if entry.get("created") and not entry.get("created_at"):
|
|
57
|
+
entry["created_at"] = entry["created"]
|
|
58
|
+
if entry.get("created_at") and not entry.get("created"):
|
|
59
|
+
entry["created"] = entry["created_at"]
|
|
60
|
+
if not entry.get("context") and entry.get("source"):
|
|
61
|
+
entry["context"] = entry["source"]
|
|
62
|
+
return entry
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_legacy_jsonl() -> List[Dict[str, Any]]:
|
|
66
|
+
"""Read and normalize legacy `memories.jsonl` entries, if present.
|
|
67
|
+
|
|
68
|
+
Defensive by contract: a missing or malformed file yields an empty
|
|
69
|
+
list and never raises. Malformed individual lines are skipped so one
|
|
70
|
+
bad line does not lose the rest of the file.
|
|
71
|
+
"""
|
|
72
|
+
path = MEMORY_DIR / LEGACY_JSONL_NAME
|
|
73
|
+
entries: List[Dict[str, Any]] = []
|
|
74
|
+
try:
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return entries
|
|
77
|
+
text = path.read_text()
|
|
78
|
+
except OSError:
|
|
79
|
+
return entries
|
|
80
|
+
for line in text.splitlines():
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
raw = json.loads(line)
|
|
86
|
+
except (json.JSONDecodeError, ValueError):
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(raw, dict):
|
|
89
|
+
entries.append(_normalize_legacy_entry(raw))
|
|
90
|
+
return entries
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_all_entries() -> List[Dict[str, Any]]:
|
|
94
|
+
"""Load every memory entry from both stores, deduped by id.
|
|
95
|
+
|
|
96
|
+
Reads the per-entry `mem-*.json` files (MCP, primary) and the legacy
|
|
97
|
+
`memories.jsonl` (CLI, backwards-compat). On an id collision the
|
|
98
|
+
`mem-*.json` entry wins — it is the authoritative MCP store and may
|
|
99
|
+
carry fields (e.g. hot_load) the legacy record lacks. Entries are
|
|
100
|
+
returned newest-first by created_at so callers that slice keep the
|
|
101
|
+
most recent. Fully defensive: unreadable files are skipped.
|
|
102
|
+
|
|
103
|
+
FIX C: the legacy `memories.jsonl` is read-only here — never deleted
|
|
104
|
+
or rewritten — preserving a customer's existing CLI-authored memories.
|
|
105
|
+
"""
|
|
106
|
+
by_id: Dict[str, Dict[str, Any]] = {}
|
|
107
|
+
order: List[str] = []
|
|
108
|
+
|
|
109
|
+
def _add(entry: Dict[str, Any], key: str, *, overwrite: bool) -> None:
|
|
110
|
+
if key not in by_id:
|
|
111
|
+
by_id[key] = entry
|
|
112
|
+
order.append(key)
|
|
113
|
+
elif overwrite:
|
|
114
|
+
by_id[key] = entry
|
|
115
|
+
|
|
116
|
+
# Primary store: mem-*.json (authoritative, wins on conflict).
|
|
117
|
+
for f in MEMORY_DIR.glob("*.json"):
|
|
118
|
+
try:
|
|
119
|
+
entry = json.loads(f.read_text())
|
|
120
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
121
|
+
continue
|
|
122
|
+
if not isinstance(entry, dict):
|
|
123
|
+
continue
|
|
124
|
+
entry.setdefault("id", f.stem)
|
|
125
|
+
_add(entry, entry.get("id") or f.stem, overwrite=True)
|
|
126
|
+
|
|
127
|
+
# Legacy jsonl: only fills ids the primary store does not already have.
|
|
128
|
+
for entry in _read_legacy_jsonl():
|
|
129
|
+
key = entry.get("id")
|
|
130
|
+
if not key:
|
|
131
|
+
# No id to dedupe on — keep it, it cannot collide.
|
|
132
|
+
order.append(id(entry)) # unique sentinel key
|
|
133
|
+
by_id[id(entry)] = entry
|
|
134
|
+
continue
|
|
135
|
+
_add(entry, key, overwrite=False)
|
|
136
|
+
|
|
137
|
+
entries = [by_id[k] for k in order]
|
|
138
|
+
entries.sort(key=lambda e: e.get("created_at") or e.get("created") or "", reverse=True)
|
|
139
|
+
return entries
|
|
140
|
+
|
|
141
|
+
|
|
22
142
|
def store(
|
|
23
143
|
content: str,
|
|
24
144
|
tags: Optional[list] = None,
|
|
@@ -68,56 +188,97 @@ def store(
|
|
|
68
188
|
|
|
69
189
|
|
|
70
190
|
def search(query: str, limit: int = 10) -> Dict[str, Any]:
|
|
71
|
-
"""Search memories by keyword matching.
|
|
191
|
+
"""Search memories by keyword matching.
|
|
192
|
+
|
|
193
|
+
FIX A: the query is tokenized on whitespace and matched with OR
|
|
194
|
+
semantics — an entry is a hit if it contains at least one token in its
|
|
195
|
+
content, tags, or context. Previously the entire query had to appear as
|
|
196
|
+
one contiguous substring, so any multi-word query returned zero hits.
|
|
197
|
+
|
|
198
|
+
Results are ranked by the number of distinct query tokens matched
|
|
199
|
+
(descending), tie-broken by recency (created_at descending). The
|
|
200
|
+
`relevance` field is preserved in the return schema and now carries the
|
|
201
|
+
matched-token count, the primary ranking signal.
|
|
202
|
+
|
|
203
|
+
An empty (or whitespace-only) query preserves the previous behavior of
|
|
204
|
+
returning no results.
|
|
205
|
+
|
|
206
|
+
FIX C: reads both the per-entry `mem-*.json` MCP store and the legacy
|
|
207
|
+
`memories.jsonl` CLI store (deduped, MCP wins on id conflict).
|
|
208
|
+
"""
|
|
72
209
|
_ensure_dir()
|
|
73
|
-
|
|
210
|
+
tokens = _tokenize(query)
|
|
74
211
|
results = []
|
|
75
212
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
213
|
+
# Empty / whitespace-only query: preserve prior behavior (no hits).
|
|
214
|
+
if not tokens:
|
|
215
|
+
return {"query": query, "results": results, "count": 0}
|
|
216
|
+
|
|
217
|
+
for entry in _load_all_entries():
|
|
218
|
+
content = (entry.get("content") or "").lower()
|
|
219
|
+
tags = " ".join(entry.get("tags") or []).lower()
|
|
220
|
+
context = (entry.get("context") or "").lower()
|
|
221
|
+
haystacks = (content, tags, context)
|
|
222
|
+
|
|
223
|
+
matched_tokens = 0
|
|
224
|
+
total_occurrences = 0
|
|
225
|
+
for tok in tokens:
|
|
226
|
+
hit = False
|
|
227
|
+
for hay in haystacks:
|
|
228
|
+
c = hay.count(tok)
|
|
229
|
+
if c:
|
|
230
|
+
hit = True
|
|
231
|
+
total_occurrences += c
|
|
232
|
+
if hit:
|
|
233
|
+
matched_tokens += 1
|
|
234
|
+
|
|
235
|
+
if matched_tokens >= 1:
|
|
236
|
+
results.append({
|
|
237
|
+
"id": entry.get("id", ""),
|
|
238
|
+
"content": (entry.get("content") or "")[:500],
|
|
239
|
+
"tags": entry.get("tags") or [],
|
|
240
|
+
"created_at": entry.get("created_at") or entry.get("created") or "",
|
|
241
|
+
# `relevance` preserved in schema; now = matched-token count
|
|
242
|
+
# (primary ranking signal). _occurrences is an internal
|
|
243
|
+
# tie-break aid, dropped before return.
|
|
244
|
+
"relevance": matched_tokens,
|
|
245
|
+
"_occurrences": total_occurrences,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
# Rank: most tokens matched first, then most occurrences, then recency.
|
|
249
|
+
results.sort(
|
|
250
|
+
key=lambda r: (r["relevance"], r["_occurrences"], r.get("created_at") or ""),
|
|
251
|
+
reverse=True,
|
|
252
|
+
)
|
|
253
|
+
for r in results:
|
|
254
|
+
r.pop("_occurrences", None)
|
|
255
|
+
|
|
256
|
+
results = results[:limit]
|
|
99
257
|
return {"query": query, "results": results, "count": len(results)}
|
|
100
258
|
|
|
101
259
|
|
|
102
260
|
def get_recent(limit: int = 5) -> Dict[str, Any]:
|
|
103
|
-
"""Get recent memory entries.
|
|
261
|
+
"""Get recent memory entries.
|
|
262
|
+
|
|
263
|
+
FIX C: reads both the per-entry `mem-*.json` MCP store and the legacy
|
|
264
|
+
`memories.jsonl` CLI store. Entries are deduped by id (MCP wins) and
|
|
265
|
+
ordered newest-first by created_at (legacy `created` is normalized to
|
|
266
|
+
`created_at`). Legacy entries surface `hot_load=False` since the field
|
|
267
|
+
pre-dates that schema.
|
|
268
|
+
"""
|
|
104
269
|
_ensure_dir()
|
|
105
270
|
entries = []
|
|
106
271
|
|
|
107
|
-
for
|
|
272
|
+
for entry in _load_all_entries():
|
|
108
273
|
if len(entries) >= limit:
|
|
109
274
|
break
|
|
110
|
-
|
|
111
|
-
entry
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"hot_load": bool(entry.get("hot_load", False)),
|
|
118
|
-
})
|
|
119
|
-
except Exception:
|
|
120
|
-
pass
|
|
275
|
+
entries.append({
|
|
276
|
+
"id": entry.get("id", ""),
|
|
277
|
+
"content": (entry.get("content") or "")[:500],
|
|
278
|
+
"tags": entry.get("tags") or [],
|
|
279
|
+
"created_at": entry.get("created_at") or entry.get("created") or "",
|
|
280
|
+
"hot_load": bool(entry.get("hot_load", False)),
|
|
281
|
+
})
|
|
121
282
|
|
|
122
283
|
return {"results": entries, "count": len(entries)}
|
|
123
284
|
|
|
@@ -143,23 +304,19 @@ def list_hot(limit: int = 200) -> Dict[str, Any]:
|
|
|
143
304
|
_ensure_dir()
|
|
144
305
|
entries = []
|
|
145
306
|
|
|
146
|
-
for
|
|
307
|
+
for entry in _load_all_entries():
|
|
147
308
|
if len(entries) >= limit:
|
|
148
309
|
break
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"hot_load": True,
|
|
160
|
-
})
|
|
161
|
-
except Exception:
|
|
162
|
-
pass
|
|
310
|
+
if not entry.get("hot_load"):
|
|
311
|
+
continue
|
|
312
|
+
entries.append({
|
|
313
|
+
"id": entry.get("id", ""),
|
|
314
|
+
"content": entry.get("content") or "",
|
|
315
|
+
"tags": entry.get("tags") or [],
|
|
316
|
+
"context": entry.get("context") or "",
|
|
317
|
+
"created_at": entry.get("created_at") or entry.get("created") or "",
|
|
318
|
+
"hot_load": True,
|
|
319
|
+
})
|
|
163
320
|
|
|
164
321
|
return {"results": entries, "count": len(entries)}
|
|
165
322
|
|
|
@@ -72,6 +72,19 @@ _CREDENTIAL_FALSE_POSITIVES = re.compile(
|
|
|
72
72
|
r"_data\[|_result\[|"
|
|
73
73
|
# LED-1278 (b): function-call RHS with leading underscore (e.g. _load_token())
|
|
74
74
|
r"=\s*_\w+\(|"
|
|
75
|
+
# LED-1278 (c) [2026-05-22]: naked function-call RHS without leading
|
|
76
|
+
# underscore. Matches the common shape `const token = readCurrentToken();`
|
|
77
|
+
# in bin/delimit-cli.js — the token is being READ from somewhere, not
|
|
78
|
+
# hardcoded. Tightened with `\s*;?\s*$` to require end-of-statement so
|
|
79
|
+
# we don't suppress `token = realLeak("AKIAIOSFODNN7EXAMPLE")` shapes
|
|
80
|
+
# where the call argument is itself a literal secret.
|
|
81
|
+
r"=\s*\w+\([^)]{0,40}\)\s*;?\s*$|"
|
|
82
|
+
# LED-1278 (c) [2026-05-22]: parenthesized property-access fallback chain
|
|
83
|
+
# like `const token = (options.token || process.env.TOKEN)`. Common shape
|
|
84
|
+
# for CLI option parsing where the RHS reads from a known input source,
|
|
85
|
+
# never a literal. Requires the open-paren to be followed by a word + dot
|
|
86
|
+
# (property access) so we don't match `token = ("AKIA..." || "")` shapes.
|
|
87
|
+
r"=\s*\(\s*\w+\.\w+|"
|
|
75
88
|
# LED-1278 (b): documentation/example placeholders in angle brackets
|
|
76
89
|
r"<[^>]*?(?:long|same|random|your|placeholder|example|secret|token|key)[^>]*?>|"
|
|
77
90
|
# Bare `if not <var>:` and similar control-flow lines that mention
|
|
@@ -149,6 +162,73 @@ KNOWN_DUMMY_PATTERNS = [
|
|
|
149
162
|
]
|
|
150
163
|
|
|
151
164
|
|
|
165
|
+
# LED-2278 [2026-05-27]: positive value-shape gate for generic_secret.
|
|
166
|
+
#
|
|
167
|
+
# The generic_secret regex (`\b(?:secret|password|passwd|token)\b\s*[=:]\s*
|
|
168
|
+
# ['\"]?[^\s'\"]{8,}`) fires on ANY assignment/key whose trigger word is
|
|
169
|
+
# followed by 8+ non-space chars — including ordinary code where the RHS is
|
|
170
|
+
# an identifier, a function call, or a subscript expression, not a hardcoded
|
|
171
|
+
# literal. Examples that recurrently false-positive in this very repo:
|
|
172
|
+
#
|
|
173
|
+
# token = self._unescape_json_pointer_token(raw_token) # method call
|
|
174
|
+
# scheme, token = parts[0].strip().lower(), parts[1] # tuple/subscript
|
|
175
|
+
#
|
|
176
|
+
# The pre-existing `_CREDENTIAL_FALSE_POSITIVES` negative list is whack-a-mole
|
|
177
|
+
# (one alternation per observed shape). This positive gate inverts the logic:
|
|
178
|
+
# a `generic_secret` hit is only credible when the VALUE is a *quoted string
|
|
179
|
+
# literal* with secret-like entropy/length. If the value is an unquoted
|
|
180
|
+
# identifier / call / expression, it is code, not a leaked secret — suppress.
|
|
181
|
+
#
|
|
182
|
+
# Conservative by construction: this gate only ever SUPPRESSES generic_secret
|
|
183
|
+
# hits whose value is non-literal. It never suppresses a quoted literal, so
|
|
184
|
+
# real hardcoded secrets (and all the existing detection tests) still fire.
|
|
185
|
+
# Applies to generic_secret only — aws_secret_key / github_token / etc. keep
|
|
186
|
+
# their own format-specific regexes untouched.
|
|
187
|
+
|
|
188
|
+
# A value (after the = or :) that begins with a quote is a string literal.
|
|
189
|
+
_GENERIC_SECRET_VALUE_RE = re.compile(
|
|
190
|
+
r"""\b(?:secret|password|passwd|token)\b\s*[=:]\s*(?P<q>['\"])(?P<val>[^'\"]*)"""
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _generic_secret_value_is_literal(matched_text: str) -> bool:
|
|
195
|
+
"""True only if the generic_secret match assigns a *quoted string literal*.
|
|
196
|
+
|
|
197
|
+
The generic_secret regex tolerates an optional opening quote, so it also
|
|
198
|
+
matches `token = some_call()` (unquoted RHS). A real hardcoded secret is a
|
|
199
|
+
quoted literal with entropy; an unquoted RHS is an identifier/expression
|
|
200
|
+
(variable ref, function call, subscript, attribute access) and is code, not
|
|
201
|
+
a leak. Return False for the unquoted/expression case so the caller can
|
|
202
|
+
suppress it, True for a credible quoted-literal value.
|
|
203
|
+
"""
|
|
204
|
+
m = _GENERIC_SECRET_VALUE_RE.search(matched_text)
|
|
205
|
+
if not m:
|
|
206
|
+
# No opening quote captured → RHS is a bare identifier / expression
|
|
207
|
+
# (e.g. `token = self._make(...)`, `scheme, token = parts[0]`). Not a
|
|
208
|
+
# hardcoded literal; suppress.
|
|
209
|
+
return False
|
|
210
|
+
val = m.group("val")
|
|
211
|
+
# A quoted literal with too little content is not secret-shaped. The outer
|
|
212
|
+
# regex already required 8+ chars total, but the quote may sit mid-match;
|
|
213
|
+
# require the literal body itself to be reasonably long.
|
|
214
|
+
if len(val) < 6:
|
|
215
|
+
return False
|
|
216
|
+
# Pure-identifier literals inside quotes (e.g. a quoted dict KEY like
|
|
217
|
+
# "access_token") that are all word chars + separators and read like an
|
|
218
|
+
# English/identifier token rather than a high-entropy secret: require at
|
|
219
|
+
# least some character-class mixing OR sufficient length to look secret-y.
|
|
220
|
+
has_lower = any(c.islower() for c in val)
|
|
221
|
+
has_upper = any(c.isupper() for c in val)
|
|
222
|
+
has_digit = any(c.isdigit() for c in val)
|
|
223
|
+
# Treat underscore/hyphen as word chars (not entropy): a quoted
|
|
224
|
+
# identifier-shaped value like "access_token" should NOT count as a
|
|
225
|
+
# multi-class high-entropy secret on the strength of its separators alone.
|
|
226
|
+
has_symbol = any(not c.isalnum() and c not in (" ", "_", "-") for c in val)
|
|
227
|
+
classes = sum([has_lower, has_upper, has_digit, has_symbol])
|
|
228
|
+
# Credible secret: multi-class entropy, OR a long single-class blob.
|
|
229
|
+
return classes >= 2 or len(val) >= 16
|
|
230
|
+
|
|
231
|
+
|
|
152
232
|
def _looks_like_known_dummy(secret_name: str, matched_text: str) -> Optional[str]:
|
|
153
233
|
"""Return a label if matched_text is a known-dummy/fixture value, else None.
|
|
154
234
|
|
|
@@ -422,6 +502,19 @@ def security_audit(target: str = ".", include_tests: bool = False) -> Dict[str,
|
|
|
422
502
|
# Skip false positives only for generic patterns (not specific token formats)
|
|
423
503
|
if secret_name in _FP_FILTERED and _CREDENTIAL_FALSE_POSITIVES.search(matched_text):
|
|
424
504
|
continue
|
|
505
|
+
# LED-2278: positive value-shape gate for generic_secret. Only
|
|
506
|
+
# flag when the assigned value is a quoted string literal with
|
|
507
|
+
# secret-like entropy; an unquoted identifier/call/expression
|
|
508
|
+
# RHS (`token = self._make(...)`, `scheme, token = parts[0]`)
|
|
509
|
+
# is code, not a leaked secret. Conservative: never suppresses
|
|
510
|
+
# a quoted literal, so real hardcoded secrets still fire.
|
|
511
|
+
if secret_name == "generic_secret" and not _generic_secret_value_is_literal(matched_text):
|
|
512
|
+
continue
|
|
513
|
+
# LED-2278: the scanner's own source embeds the trigger words in
|
|
514
|
+
# regex/doc comments (e.g. the `token = realLeak(...)` example in
|
|
515
|
+
# this module). Those are pattern DEFINITIONS, not secrets.
|
|
516
|
+
if secret_name == "generic_secret" and rel.endswith("ai/backends/tools_infra.py"):
|
|
517
|
+
continue
|
|
425
518
|
line_num = content[:match.start()].count("\n") + 1
|
|
426
519
|
# LED-1278 (b): well-known dummy/placeholder values get
|
|
427
520
|
# suppressed to info-level rather than raised as critical.
|
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import logging
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
|
+
import shutil
|
|
13
14
|
import subprocess
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
from pathlib import Path
|
|
@@ -364,18 +365,63 @@ def test_smoke(project_path: str, test_suite: Optional[str] = None) -> Dict[str,
|
|
|
364
365
|
return {"tool": "test.smoke", "status": "error", "error": f"Invalid test_suite: {test_suite}"}
|
|
365
366
|
cmd_list.append(test_suite)
|
|
366
367
|
|
|
367
|
-
# Detect the right Python executable
|
|
368
|
+
# Detect the right Python executable.
|
|
369
|
+
#
|
|
370
|
+
# Resolution order (LED-1564 follow-up, 2026-05-22):
|
|
371
|
+
# 1. Project's own venv (most isolated; honors project's own deps).
|
|
372
|
+
# 2. System python3 on PATH — where projects typically install deps
|
|
373
|
+
# when they don't ship a local venv. Tested for pytest availability
|
|
374
|
+
# so we don't fall through to a Python that can't run pytest.
|
|
375
|
+
# 3. sys.executable (= MCP server's runner venv) as last resort.
|
|
376
|
+
#
|
|
377
|
+
# The pre-fix order was (1) → (3), which broke for projects that have
|
|
378
|
+
# their deps installed system-wide but no project-local venv: pytest
|
|
379
|
+
# itself might exist in the delimit venv, but project-specific imports
|
|
380
|
+
# like `pika` (caught by codex against wirereport 2026-05-22) raise
|
|
381
|
+
# ModuleNotFoundError because the delimit venv is stripped to the MCP
|
|
382
|
+
# server's deps only.
|
|
368
383
|
if framework == "pytest":
|
|
369
|
-
|
|
384
|
+
import sys as _sys
|
|
385
|
+
|
|
386
|
+
chosen = None
|
|
387
|
+
# (1) Project-local venv.
|
|
370
388
|
for venv_dir in ["venv", ".venv", "env"]:
|
|
371
389
|
venv_python = project / venv_dir / "bin" / "python"
|
|
372
390
|
if venv_python.exists():
|
|
373
|
-
|
|
374
|
-
python_found = True
|
|
391
|
+
chosen = str(venv_python)
|
|
375
392
|
break
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
393
|
+
|
|
394
|
+
# (2) System python3 if it has pytest. Probe with a fast import-
|
|
395
|
+
# check so we don't pick a python that can't actually run pytest.
|
|
396
|
+
if chosen is None:
|
|
397
|
+
for candidate in ("python3", "python"):
|
|
398
|
+
exe = shutil.which(candidate)
|
|
399
|
+
if not exe:
|
|
400
|
+
continue
|
|
401
|
+
# Skip only when the candidate path is literally the same
|
|
402
|
+
# interpreter entrypoint as the MCP runner. In deployments
|
|
403
|
+
# where the venv python is a symlink to /usr/bin/python3,
|
|
404
|
+
# comparing resolved paths collapses the system interpreter
|
|
405
|
+
# and the venv interpreter into the same target and prevents
|
|
406
|
+
# the intended fallback to system python3.
|
|
407
|
+
if Path(exe) == Path(_sys.executable):
|
|
408
|
+
continue
|
|
409
|
+
try:
|
|
410
|
+
probe = subprocess.run(
|
|
411
|
+
[exe, "-c", "import pytest"],
|
|
412
|
+
capture_output=True, timeout=10,
|
|
413
|
+
)
|
|
414
|
+
if probe.returncode == 0:
|
|
415
|
+
chosen = exe
|
|
416
|
+
break
|
|
417
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
# (3) sys.executable (= MCP server's runner venv) as last resort.
|
|
421
|
+
if chosen is None:
|
|
422
|
+
chosen = _sys.executable
|
|
423
|
+
|
|
424
|
+
cmd_list[0] = chosen
|
|
379
425
|
|
|
380
426
|
try:
|
|
381
427
|
result = subprocess.run(
|