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.
Files changed (32) hide show
  1. {cli_mem-0.3.2 → cli_mem-0.3.3}/PKG-INFO +1 -1
  2. {cli_mem-0.3.2 → cli_mem-0.3.3}/pyproject.toml +1 -1
  3. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/__init__.py +1 -1
  4. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/cli.py +13 -5
  5. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/variables.py +122 -5
  6. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_groups.py +140 -0
  7. {cli_mem-0.3.2 → cli_mem-0.3.3}/.github/workflows/ci.yml +0 -0
  8. {cli_mem-0.3.2 → cli_mem-0.3.3}/.github/workflows/release.yml +0 -0
  9. {cli_mem-0.3.2 → cli_mem-0.3.3}/.gitignore +0 -0
  10. {cli_mem-0.3.2 → cli_mem-0.3.3}/ARCHITECTURE.md +0 -0
  11. {cli_mem-0.3.2 → cli_mem-0.3.3}/LICENSE +0 -0
  12. {cli_mem-0.3.2 → cli_mem-0.3.3}/PHILOSOPHY.md +0 -0
  13. {cli_mem-0.3.2 → cli_mem-0.3.3}/README.md +0 -0
  14. {cli_mem-0.3.2 → cli_mem-0.3.3}/assets/.gitkeep +0 -0
  15. {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
  16. {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
  17. {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/003-no-daemon.md +0 -0
  18. {cli_mem-0.3.2 → cli_mem-0.3.3}/docs/decisions/004-per-repo-jsonl.md +0 -0
  19. {cli_mem-0.3.2 → cli_mem-0.3.3}/hooks/mem.zsh +0 -0
  20. {cli_mem-0.3.2 → cli_mem-0.3.3}/install.sh +0 -0
  21. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/_generable.py +0 -0
  22. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/capture.py +0 -0
  23. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/groups.py +0 -0
  24. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/models.py +0 -0
  25. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/patterns.py +0 -0
  26. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/search.py +0 -0
  27. {cli_mem-0.3.2 → cli_mem-0.3.3}/src/mem/storage.py +0 -0
  28. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/conftest.py +0 -0
  29. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_capture.py +0 -0
  30. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_patterns.py +0 -0
  31. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_search.py +0 -0
  32. {cli_mem-0.3.2 → cli_mem-0.3.3}/tests/test_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-mem
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Privacy-first CLI that turns shell history into searchable memory
5
5
  Project-URL: GitHub, https://github.com/matinsaurralde/mem
6
6
  Author: Matias Insaurralde
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-mem"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Privacy-first CLI that turns shell history into searchable memory"
9
9
  license = "MIT"
10
10
  readme = "README.md"
@@ -1,3 +1,3 @@
1
1
  """mem — your shell history, understood."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
@@ -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
- var_name = click.prompt(
435
- " Variable name",
436
- default=suggested_name,
437
- err=True,
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 identify any sensitive values "
286
- "(API tokens, passwords, secrets, keys, long base64 strings). "
287
- "For each one, suggest a descriptive variable name based on the "
288
- "context of the command.\n\n"
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
- return asyncio.run(_detect_credentials_async(cmd))
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