cli-mem 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {cli_mem-0.3.2 → cli_mem-0.3.3}/PKG-INFO +1 -1
- {cli_mem-0.3.2 → cli_mem-0.3.3}/pyproject.toml +1 -1
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/__init__.py +1 -1
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/cli.py +13 -5
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/variables.py +122 -5
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_groups.py +140 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/.github/workflows/ci.yml +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/.github/workflows/release.yml +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/.gitignore +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/ARCHITECTURE.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/LICENSE +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/PHILOSOPHY.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/README.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/assets/.gitkeep +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/003-no-daemon.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/004-per-repo-jsonl.md +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/hooks/mem.zsh +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/install.sh +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/_generable.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/capture.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/groups.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/models.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/patterns.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/search.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/storage.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/conftest.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_capture.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_patterns.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_search.py +0 -0
- {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_storage.py +0 -0
|
@@ -431,11 +431,19 @@ def save(
|
|
|
431
431
|
err_console.print(f"\n Detected possible credential: {reason}")
|
|
432
432
|
proposed = command.replace(original_value, f"${suggested_name}")
|
|
433
433
|
err_console.print(f" Suggested: {proposed}")
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
434
|
+
# Prompt for variable name with validation loop
|
|
435
|
+
while True:
|
|
436
|
+
var_name = click.prompt(
|
|
437
|
+
" Variable name",
|
|
438
|
+
default=suggested_name,
|
|
439
|
+
err=True,
|
|
440
|
+
)
|
|
441
|
+
if _re.match(r"^[A-Z][A-Z0-9_]+$", var_name):
|
|
442
|
+
break
|
|
443
|
+
err_console.print(
|
|
444
|
+
f" Invalid name '{var_name}'. "
|
|
445
|
+
"Must be UPPERCASE letters, digits, underscores (min 2 chars)."
|
|
446
|
+
)
|
|
439
447
|
if click.confirm(" Save with variable?", default=True, err=True):
|
|
440
448
|
command = command.replace(original_value, f"${var_name}")
|
|
441
449
|
|
|
@@ -261,6 +261,109 @@ def check_resolution_status(
|
|
|
261
261
|
return result
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
# Minimum token length that could plausibly be a hardcoded credential.
|
|
265
|
+
# Real secrets (JWTs, API keys, base64 strings) are almost always 16+ chars.
|
|
266
|
+
_MIN_CREDENTIAL_TOKEN_LEN = 16
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _command_may_contain_credentials(cmd: str) -> bool:
|
|
270
|
+
"""Quick heuristic to skip AI detection on simple commands.
|
|
271
|
+
|
|
272
|
+
Returns True only if the command plausibly contains hardcoded secrets.
|
|
273
|
+
Avoids sending trivial commands (echo, ls, cd) to the on-device model.
|
|
274
|
+
"""
|
|
275
|
+
import shlex
|
|
276
|
+
|
|
277
|
+
# Known credential-context keywords — if present, always check
|
|
278
|
+
_CREDENTIAL_KEYWORDS = {
|
|
279
|
+
"bearer",
|
|
280
|
+
"authorization",
|
|
281
|
+
"password",
|
|
282
|
+
"token",
|
|
283
|
+
"secret",
|
|
284
|
+
"api_key",
|
|
285
|
+
"apikey",
|
|
286
|
+
"private_key",
|
|
287
|
+
"access_key",
|
|
288
|
+
}
|
|
289
|
+
lower = cmd.lower()
|
|
290
|
+
if any(kw in lower for kw in _CREDENTIAL_KEYWORDS):
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
# Check for any token long enough to be a credential
|
|
294
|
+
try:
|
|
295
|
+
tokens = shlex.split(cmd)
|
|
296
|
+
except ValueError:
|
|
297
|
+
tokens = cmd.split()
|
|
298
|
+
return any(len(t.strip("'\"")) >= _MIN_CREDENTIAL_TOKEN_LEN for t in tokens)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _normalize_var_name(name: str) -> str:
|
|
302
|
+
"""Normalize a suggested variable name to UPPER_SNAKE_CASE.
|
|
303
|
+
|
|
304
|
+
Handles CamelCase, mixed-case, and invalid characters from AI output.
|
|
305
|
+
"""
|
|
306
|
+
# Convert CamelCase to UPPER_SNAKE
|
|
307
|
+
result = re.sub(r"(?<=[a-z])(?=[A-Z])", "_", name)
|
|
308
|
+
result = result.upper()
|
|
309
|
+
# Strip invalid characters
|
|
310
|
+
result = re.sub(r"[^A-Z0-9_]", "_", result)
|
|
311
|
+
# Collapse multiple underscores
|
|
312
|
+
result = re.sub(r"_+", "_", result)
|
|
313
|
+
return result.strip("_")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _deduplicate_detections(
|
|
317
|
+
detections: list[tuple[str, str, str]],
|
|
318
|
+
cmd: str,
|
|
319
|
+
) -> list[tuple[str, str, str]]:
|
|
320
|
+
"""Filter out invalid and duplicate credential detections.
|
|
321
|
+
|
|
322
|
+
Removes:
|
|
323
|
+
- Values not actually present in the command (AI hallucinations)
|
|
324
|
+
- URLs and hostnames (not credentials)
|
|
325
|
+
- Very short values (< 8 chars, not plausible secrets)
|
|
326
|
+
- Duplicate original_value entries
|
|
327
|
+
- Values that are substrings of other detected values
|
|
328
|
+
"""
|
|
329
|
+
result: list[tuple[str, str, str]] = []
|
|
330
|
+
seen_values: set[str] = set()
|
|
331
|
+
|
|
332
|
+
for original_value, suggested_name, reason in detections:
|
|
333
|
+
# Skip hallucinated values not in the command
|
|
334
|
+
if original_value not in cmd:
|
|
335
|
+
continue
|
|
336
|
+
# Skip URLs
|
|
337
|
+
if original_value.startswith(("http://", "https://")):
|
|
338
|
+
continue
|
|
339
|
+
# Skip very short values
|
|
340
|
+
if len(original_value) < 8:
|
|
341
|
+
continue
|
|
342
|
+
# Skip duplicates
|
|
343
|
+
if original_value in seen_values:
|
|
344
|
+
continue
|
|
345
|
+
# Normalize suggested name to UPPER_SNAKE_CASE
|
|
346
|
+
normalized = _normalize_var_name(suggested_name)
|
|
347
|
+
if not re.match(r"^[A-Z][A-Z0-9_]+$", normalized):
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
seen_values.add(original_value)
|
|
351
|
+
result.append((original_value, normalized, reason))
|
|
352
|
+
|
|
353
|
+
# Remove values that are substrings of other detected values
|
|
354
|
+
filtered: list[tuple[str, str, str]] = []
|
|
355
|
+
for i, (val_i, name_i, reason_i) in enumerate(result):
|
|
356
|
+
is_subset = any(
|
|
357
|
+
val_i in val_j and val_i != val_j
|
|
358
|
+
for j, (val_j, _, _) in enumerate(result)
|
|
359
|
+
if i != j
|
|
360
|
+
)
|
|
361
|
+
if not is_subset:
|
|
362
|
+
filtered.append((val_i, name_i, reason_i))
|
|
363
|
+
|
|
364
|
+
return filtered
|
|
365
|
+
|
|
366
|
+
|
|
264
367
|
def _apple_fm_available() -> bool:
|
|
265
368
|
"""Check if Apple Foundation Models SDK is available."""
|
|
266
369
|
try:
|
|
@@ -282,10 +385,19 @@ async def _detect_credentials_async(cmd: str) -> list[tuple[str, str, str]]:
|
|
|
282
385
|
|
|
283
386
|
session = fm.LanguageModelSession()
|
|
284
387
|
prompt = (
|
|
285
|
-
"Analyze this shell command and
|
|
286
|
-
"
|
|
287
|
-
"
|
|
288
|
-
"
|
|
388
|
+
"Analyze this shell command and find ONLY hardcoded sensitive values "
|
|
389
|
+
"that should be replaced with environment variables.\n\n"
|
|
390
|
+
"ONLY flag these types of values:\n"
|
|
391
|
+
"- API tokens or keys (long alphanumeric/base64 strings like eyJhbG..., sk-...)\n"
|
|
392
|
+
"- Passwords passed inline (after --password, -p, etc.)\n"
|
|
393
|
+
"- Bearer tokens in Authorization headers\n"
|
|
394
|
+
"- Database connection strings with embedded passwords\n\n"
|
|
395
|
+
"DO NOT flag:\n"
|
|
396
|
+
"- URLs, hostnames, or IP addresses\n"
|
|
397
|
+
"- The command name or subcommands\n"
|
|
398
|
+
"- Short strings, regular arguments, or file paths\n"
|
|
399
|
+
"- Port numbers or known constants\n\n"
|
|
400
|
+
"If the command contains NO credentials, return an EMPTY list with no entries.\n\n"
|
|
289
401
|
f"Command: {cmd}"
|
|
290
402
|
)
|
|
291
403
|
|
|
@@ -302,9 +414,14 @@ def detect_credentials(cmd: str) -> list[tuple[str, str, str]]:
|
|
|
302
414
|
if not _apple_fm_available():
|
|
303
415
|
return []
|
|
304
416
|
|
|
417
|
+
# Pre-filter: skip commands unlikely to contain credentials
|
|
418
|
+
if not _command_may_contain_credentials(cmd):
|
|
419
|
+
return []
|
|
420
|
+
|
|
305
421
|
try:
|
|
306
422
|
import asyncio
|
|
307
423
|
|
|
308
|
-
|
|
424
|
+
raw = asyncio.run(_detect_credentials_async(cmd))
|
|
425
|
+
return _deduplicate_detections(raw, cmd)
|
|
309
426
|
except Exception:
|
|
310
427
|
return []
|
|
@@ -1718,3 +1718,143 @@ class TestRepoDisplayPath:
|
|
|
1718
1718
|
assert FAKE_REPO in result.output
|
|
1719
1719
|
# Sanitized name should NOT appear
|
|
1720
1720
|
assert repo_name not in result.output
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
# ---------------------------------------------------------------------------
|
|
1724
|
+
# Credential detection helpers (pre-filter, deduplication, normalization)
|
|
1725
|
+
# ---------------------------------------------------------------------------
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
class TestCredentialPreFilter:
|
|
1729
|
+
"""Fix 4: skip AI detection on simple commands."""
|
|
1730
|
+
|
|
1731
|
+
def test_simple_echo_skipped(self):
|
|
1732
|
+
from mem.variables import _command_may_contain_credentials
|
|
1733
|
+
|
|
1734
|
+
assert _command_may_contain_credentials("echo hello") is False
|
|
1735
|
+
|
|
1736
|
+
def test_ls_skipped(self):
|
|
1737
|
+
from mem.variables import _command_may_contain_credentials
|
|
1738
|
+
|
|
1739
|
+
assert _command_may_contain_credentials("ls -la") is False
|
|
1740
|
+
|
|
1741
|
+
def test_short_command_skipped(self):
|
|
1742
|
+
from mem.variables import _command_may_contain_credentials
|
|
1743
|
+
|
|
1744
|
+
assert _command_may_contain_credentials("cd /tmp") is False
|
|
1745
|
+
|
|
1746
|
+
def test_bearer_token_detected(self):
|
|
1747
|
+
from mem.variables import _command_may_contain_credentials
|
|
1748
|
+
|
|
1749
|
+
cmd = "curl -H 'Authorization: Bearer eyJhbGciOi...' https://api.example.com"
|
|
1750
|
+
assert _command_may_contain_credentials(cmd) is True
|
|
1751
|
+
|
|
1752
|
+
def test_long_token_detected(self):
|
|
1753
|
+
from mem.variables import _command_may_contain_credentials
|
|
1754
|
+
|
|
1755
|
+
cmd = "curl -H 'X-Api-Key: abcdefghijklmnopqrstuvwxyz' https://api.example.com"
|
|
1756
|
+
assert _command_may_contain_credentials(cmd) is True
|
|
1757
|
+
|
|
1758
|
+
def test_password_keyword_detected(self):
|
|
1759
|
+
from mem.variables import _command_may_contain_credentials
|
|
1760
|
+
|
|
1761
|
+
cmd = "mysql -u root --password=secret"
|
|
1762
|
+
assert _command_may_contain_credentials(cmd) is True
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
class TestNormalizeVarName:
|
|
1766
|
+
"""AI often suggests CamelCase names — normalize to UPPER_SNAKE_CASE."""
|
|
1767
|
+
|
|
1768
|
+
def test_camel_case(self):
|
|
1769
|
+
from mem.variables import _normalize_var_name
|
|
1770
|
+
|
|
1771
|
+
assert _normalize_var_name("ShellCommandExecution") == "SHELL_COMMAND_EXECUTION"
|
|
1772
|
+
|
|
1773
|
+
def test_already_upper_snake(self):
|
|
1774
|
+
from mem.variables import _normalize_var_name
|
|
1775
|
+
|
|
1776
|
+
assert _normalize_var_name("ACME_API_TOKEN") == "ACME_API_TOKEN"
|
|
1777
|
+
|
|
1778
|
+
def test_lowercase(self):
|
|
1779
|
+
from mem.variables import _normalize_var_name
|
|
1780
|
+
|
|
1781
|
+
assert _normalize_var_name("acme_var") == "ACME_VAR"
|
|
1782
|
+
|
|
1783
|
+
def test_mixed_with_invalid_chars(self):
|
|
1784
|
+
from mem.variables import _normalize_var_name
|
|
1785
|
+
|
|
1786
|
+
assert _normalize_var_name("my-api.key") == "MY_API_KEY"
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
class TestDeduplicateDetections:
|
|
1790
|
+
"""Fix 3: filter out hallucinations, URLs, and overlapping detections."""
|
|
1791
|
+
|
|
1792
|
+
def test_filters_hallucinated_values(self):
|
|
1793
|
+
from mem.variables import _deduplicate_detections
|
|
1794
|
+
|
|
1795
|
+
cmd = "echo hello"
|
|
1796
|
+
detections = [("not_in_command", "SOME_VAR", "hallucination")]
|
|
1797
|
+
assert _deduplicate_detections(detections, cmd) == []
|
|
1798
|
+
|
|
1799
|
+
def test_filters_urls(self):
|
|
1800
|
+
from mem.variables import _deduplicate_detections
|
|
1801
|
+
|
|
1802
|
+
cmd = "curl https://api.example.com/users"
|
|
1803
|
+
detections = [
|
|
1804
|
+
("https://api.example.com/users", "API_URL", "URL endpoint"),
|
|
1805
|
+
]
|
|
1806
|
+
assert _deduplicate_detections(detections, cmd) == []
|
|
1807
|
+
|
|
1808
|
+
def test_filters_short_values(self):
|
|
1809
|
+
from mem.variables import _deduplicate_detections
|
|
1810
|
+
|
|
1811
|
+
cmd = "echo short"
|
|
1812
|
+
detections = [("short", "MY_VAR", "too short")]
|
|
1813
|
+
assert _deduplicate_detections(detections, cmd) == []
|
|
1814
|
+
|
|
1815
|
+
def test_keeps_valid_credential(self):
|
|
1816
|
+
from mem.variables import _deduplicate_detections
|
|
1817
|
+
|
|
1818
|
+
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
1819
|
+
cmd = f"curl -H 'Authorization: Bearer {token}' https://api.example.com"
|
|
1820
|
+
detections = [(token, "ACME_API_TOKEN", "JWT token")]
|
|
1821
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1822
|
+
assert len(result) == 1
|
|
1823
|
+
assert result[0][0] == token
|
|
1824
|
+
assert result[0][1] == "ACME_API_TOKEN"
|
|
1825
|
+
|
|
1826
|
+
def test_removes_subset_detections(self):
|
|
1827
|
+
from mem.variables import _deduplicate_detections
|
|
1828
|
+
|
|
1829
|
+
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
1830
|
+
full_header = f"Bearer {token}"
|
|
1831
|
+
cmd = f"curl -H 'Authorization: {full_header}' https://api.example.com"
|
|
1832
|
+
detections = [
|
|
1833
|
+
(token, "API_TOKEN", "JWT token"),
|
|
1834
|
+
(full_header, "AUTH_HEADER", "Bearer token"),
|
|
1835
|
+
]
|
|
1836
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1837
|
+
# Should keep only the longer one (full_header contains token)
|
|
1838
|
+
assert len(result) == 1
|
|
1839
|
+
assert result[0][0] == full_header
|
|
1840
|
+
|
|
1841
|
+
def test_deduplicates_same_value(self):
|
|
1842
|
+
from mem.variables import _deduplicate_detections
|
|
1843
|
+
|
|
1844
|
+
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
1845
|
+
cmd = f"curl -H 'Authorization: Bearer {token}'"
|
|
1846
|
+
detections = [
|
|
1847
|
+
(token, "TOKEN_A", "reason 1"),
|
|
1848
|
+
(token, "TOKEN_B", "reason 2"),
|
|
1849
|
+
]
|
|
1850
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1851
|
+
assert len(result) == 1
|
|
1852
|
+
|
|
1853
|
+
def test_normalizes_suggested_names(self):
|
|
1854
|
+
from mem.variables import _deduplicate_detections
|
|
1855
|
+
|
|
1856
|
+
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
1857
|
+
cmd = f"curl -H 'Bearer {token}'"
|
|
1858
|
+
detections = [(token, "AcmeApiToken", "JWT")]
|
|
1859
|
+
result = _deduplicate_detections(detections, cmd)
|
|
1860
|
+
assert result[0][1] == "ACME_API_TOKEN"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|