cli-mem 0.3.4__tar.gz → 0.3.5__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.4 → cli_mem-0.3.5}/PKG-INFO +1 -1
  2. {cli_mem-0.3.4 → cli_mem-0.3.5}/pyproject.toml +1 -1
  3. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/__init__.py +1 -1
  4. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/variables.py +66 -13
  5. {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_groups.py +107 -0
  6. {cli_mem-0.3.4 → cli_mem-0.3.5}/.github/workflows/ci.yml +0 -0
  7. {cli_mem-0.3.4 → cli_mem-0.3.5}/.github/workflows/release.yml +0 -0
  8. {cli_mem-0.3.4 → cli_mem-0.3.5}/.gitignore +0 -0
  9. {cli_mem-0.3.4 → cli_mem-0.3.5}/ARCHITECTURE.md +0 -0
  10. {cli_mem-0.3.4 → cli_mem-0.3.5}/LICENSE +0 -0
  11. {cli_mem-0.3.4 → cli_mem-0.3.5}/PHILOSOPHY.md +0 -0
  12. {cli_mem-0.3.4 → cli_mem-0.3.5}/README.md +0 -0
  13. {cli_mem-0.3.4 → cli_mem-0.3.5}/assets/.gitkeep +0 -0
  14. {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/001-jsonl-over-sqlite.md +0 -0
  15. {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/002-apple-fm-sdk-for-patterns.md +0 -0
  16. {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/003-no-daemon.md +0 -0
  17. {cli_mem-0.3.4 → cli_mem-0.3.5}/docs/decisions/004-per-repo-jsonl.md +0 -0
  18. {cli_mem-0.3.4 → cli_mem-0.3.5}/hooks/mem.zsh +0 -0
  19. {cli_mem-0.3.4 → cli_mem-0.3.5}/install.sh +0 -0
  20. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/_generable.py +0 -0
  21. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/capture.py +0 -0
  22. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/cli.py +0 -0
  23. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/groups.py +0 -0
  24. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/models.py +0 -0
  25. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/patterns.py +0 -0
  26. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/search.py +0 -0
  27. {cli_mem-0.3.4 → cli_mem-0.3.5}/src/mem/storage.py +0 -0
  28. {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/conftest.py +0 -0
  29. {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_capture.py +0 -0
  30. {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_patterns.py +0 -0
  31. {cli_mem-0.3.4 → cli_mem-0.3.5}/tests/test_search.py +0 -0
  32. {cli_mem-0.3.4 → cli_mem-0.3.5}/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.4
3
+ Version: 0.3.5
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.4"
7
+ version = "0.3.5"
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.4"
3
+ __version__ = "0.3.5"
@@ -313,6 +313,41 @@ def _normalize_var_name(name: str) -> str:
313
313
  return result.strip("_")
314
314
 
315
315
 
316
+ def _extract_value_from_syntax(original_value: str, cmd: str) -> str:
317
+ """Extract the actual secret value from flag=value or KEY=value syntax.
318
+
319
+ The AI sometimes returns 'GITHUB_TOKEN=ghp_abc...' or '--password=secret'
320
+ as the original_value. We need just the secret part so that .replace()
321
+ doesn't destroy the command structure.
322
+ """
323
+ # Pattern: --flag=value or -f=value (CLI flag syntax)
324
+ flag_match = re.match(r"^--?[a-zA-Z][\w-]*=(.+)$", original_value)
325
+ if flag_match:
326
+ value = flag_match.group(1)
327
+ if value in cmd:
328
+ return value
329
+
330
+ # Pattern: ENV_VAR=value (environment variable assignment)
331
+ env_match = re.match(r"^[A-Z][A-Z0-9_]+=(.+)$", original_value)
332
+ if env_match:
333
+ value = env_match.group(1)
334
+ if value in cmd:
335
+ return value
336
+
337
+ return original_value
338
+
339
+
340
+ def _looks_like_hostname(value: str) -> bool:
341
+ """Check if a value looks like a hostname or domain, not a credential."""
342
+ # Hostnames: words joined by dots, no special chars typical of secrets
343
+ if re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*\.)+[a-zA-Z]{2,}$", value):
344
+ return True
345
+ # IP addresses
346
+ if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", value):
347
+ return True
348
+ return False
349
+
350
+
316
351
  def _deduplicate_detections(
317
352
  detections: list[tuple[str, str, str]],
318
353
  cmd: str,
@@ -325,30 +360,39 @@ def _deduplicate_detections(
325
360
  - Very short values (< 8 chars, not plausible secrets)
326
361
  - Duplicate original_value entries
327
362
  - Values that are substrings of other detected values
363
+
364
+ Also extracts the actual secret from flag=value syntax when the AI
365
+ returns the whole expression as original_value.
328
366
  """
329
367
  result: list[tuple[str, str, str]] = []
330
368
  seen_values: set[str] = set()
331
369
 
332
370
  for original_value, suggested_name, reason in detections:
371
+ # Extract actual value from flag=value or ENV=value syntax
372
+ value = _extract_value_from_syntax(original_value, cmd)
373
+
333
374
  # Skip hallucinated values not in the command
334
- if original_value not in cmd:
375
+ if value not in cmd:
335
376
  continue
336
377
  # Skip URLs
337
- if original_value.startswith(("http://", "https://")):
378
+ if value.startswith(("http://", "https://")):
379
+ continue
380
+ # Skip hostnames and IP addresses
381
+ if _looks_like_hostname(value):
338
382
  continue
339
383
  # Skip very short values
340
- if len(original_value) < 8:
384
+ if len(value) < 8:
341
385
  continue
342
386
  # Skip duplicates
343
- if original_value in seen_values:
387
+ if value in seen_values:
344
388
  continue
345
389
  # Normalize suggested name to UPPER_SNAKE_CASE
346
390
  normalized = _normalize_var_name(suggested_name)
347
391
  if not re.match(r"^[A-Z][A-Z0-9_]+$", normalized):
348
392
  continue
349
393
 
350
- seen_values.add(original_value)
351
- result.append((original_value, normalized, reason))
394
+ seen_values.add(value)
395
+ result.append((value, normalized, reason))
352
396
 
353
397
  # Remove values that are substrings of other detected values
354
398
  filtered: list[tuple[str, str, str]] = []
@@ -387,17 +431,26 @@ async def _detect_credentials_async(cmd: str) -> list[tuple[str, str, str]]:
387
431
  prompt = (
388
432
  "Analyze this shell command and find ONLY hardcoded sensitive values "
389
433
  "that should be replaced with environment variables.\n\n"
434
+ "RULES FOR original_value:\n"
435
+ "- Return ONLY the literal secret value, not the flag or key name.\n"
436
+ "- For --password=mysecret, return 'mysecret' not '--password=mysecret'.\n"
437
+ "- For GITHUB_TOKEN=ghp_abc, return 'ghp_abc' not 'GITHUB_TOKEN=ghp_abc'.\n"
438
+ "- For postgres://user:pass@host, return 'pass' not the full URL.\n\n"
439
+ "RULES FOR suggested_name:\n"
440
+ "- Derive the name from the service or tool in the command.\n"
441
+ "- Examples: STRIPE_API_KEY, GITHUB_TOKEN, REDIS_PASSWORD, DB_PASSWORD.\n"
442
+ "- NEVER use generic prefixes like ACME_.\n\n"
390
443
  "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"
444
+ "- API tokens or keys (long alphanumeric/base64 strings)\n"
445
+ "- Passwords passed inline (after --password, -p, -a, etc.)\n"
393
446
  "- Bearer tokens in Authorization headers\n"
394
- "- Database connection strings with embedded passwords\n\n"
447
+ "- Embedded passwords in connection strings\n\n"
395
448
  "DO NOT flag:\n"
396
- "- URLs, hostnames, or IP addresses\n"
397
- "- The command name or subcommands\n"
449
+ "- URLs, hostnames, IP addresses, or domain names\n"
450
+ "- The command name, subcommands, or usernames\n"
398
451
  "- 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"
452
+ "- Port numbers, known constants, or registry addresses\n\n"
453
+ "If NO credentials found, return an EMPTY list.\n\n"
401
454
  f"Command: {cmd}"
402
455
  )
403
456
 
@@ -1858,3 +1858,110 @@ class TestDeduplicateDetections:
1858
1858
  detections = [(token, "AcmeApiToken", "JWT")]
1859
1859
  result = _deduplicate_detections(detections, cmd)
1860
1860
  assert result[0][1] == "ACME_API_TOKEN"
1861
+
1862
+ def test_extracts_value_from_flag_syntax(self):
1863
+ """Bug A: AI returns '--password=secret' instead of just 'secret'."""
1864
+ from mem.variables import _deduplicate_detections
1865
+
1866
+ cmd = "mysql -u admin --password=S3cretP@ssw0rd123 mydb"
1867
+ detections = [("--password=S3cretP@ssw0rd123", "DB_PASSWORD", "password")]
1868
+ result = _deduplicate_detections(detections, cmd)
1869
+ assert len(result) == 1
1870
+ assert result[0][0] == "S3cretP@ssw0rd123"
1871
+
1872
+ def test_extracts_value_from_env_var_syntax(self):
1873
+ """Bug A: AI returns 'GITHUB_TOKEN=ghp_abc...' instead of just the token."""
1874
+ from mem.variables import _deduplicate_detections
1875
+
1876
+ token = "ghp_abcdefghijklmnopqrstuvwxyz123456"
1877
+ cmd = f"GITHUB_TOKEN={token} gh pr create"
1878
+ detections = [(f"GITHUB_TOKEN={token}", "GITHUB_TOKEN", "API token")]
1879
+ result = _deduplicate_detections(detections, cmd)
1880
+ assert len(result) == 1
1881
+ assert result[0][0] == token
1882
+
1883
+ def test_filters_hostnames(self):
1884
+ """Bug C: hostnames like registry.ghcr.io are not credentials."""
1885
+ from mem.variables import _deduplicate_detections
1886
+
1887
+ cmd = "docker login -u deploy -p secret123456789 registry.ghcr.io"
1888
+ detections = [
1889
+ ("secret123456789", "REGISTRY_TOKEN", "token"),
1890
+ ("registry.ghcr.io", "REGISTRY_URL", "hostname"),
1891
+ ]
1892
+ result = _deduplicate_detections(detections, cmd)
1893
+ assert len(result) == 1
1894
+ assert result[0][0] == "secret123456789"
1895
+
1896
+ def test_filters_ip_addresses(self):
1897
+ from mem.variables import _deduplicate_detections
1898
+
1899
+ cmd = "redis-cli -h 10.0.1.50 -a MyR3d1sPassword123 PING"
1900
+ detections = [
1901
+ ("MyR3d1sPassword123", "REDIS_PASSWORD", "password"),
1902
+ ("10.0.1.50", "REDIS_HOST", "IP address"),
1903
+ ]
1904
+ result = _deduplicate_detections(detections, cmd)
1905
+ assert len(result) == 1
1906
+ assert result[0][0] == "MyR3d1sPassword123"
1907
+
1908
+
1909
+ class TestExtractValueFromSyntax:
1910
+ """Unit tests for _extract_value_from_syntax."""
1911
+
1912
+ def test_cli_flag_long(self):
1913
+ from mem.variables import _extract_value_from_syntax
1914
+
1915
+ cmd = "mysql --password=secret123 mydb"
1916
+ assert _extract_value_from_syntax("--password=secret123", cmd) == "secret123"
1917
+
1918
+ def test_cli_flag_short(self):
1919
+ from mem.variables import _extract_value_from_syntax
1920
+
1921
+ cmd = "tool -p=myvalue123 arg"
1922
+ assert _extract_value_from_syntax("-p=myvalue123", cmd) == "myvalue123"
1923
+
1924
+ def test_env_var_assignment(self):
1925
+ from mem.variables import _extract_value_from_syntax
1926
+
1927
+ cmd = "GITHUB_TOKEN=ghp_abc123 gh pr create"
1928
+ assert _extract_value_from_syntax("GITHUB_TOKEN=ghp_abc123", cmd) == "ghp_abc123"
1929
+
1930
+ def test_plain_value_unchanged(self):
1931
+ from mem.variables import _extract_value_from_syntax
1932
+
1933
+ cmd = "curl -H 'Bearer eyJtoken'"
1934
+ assert _extract_value_from_syntax("eyJtoken", cmd) == "eyJtoken"
1935
+
1936
+ def test_value_not_in_cmd_returns_original(self):
1937
+ from mem.variables import _extract_value_from_syntax
1938
+
1939
+ cmd = "echo hello"
1940
+ assert _extract_value_from_syntax("--flag=nothere", cmd) == "--flag=nothere"
1941
+
1942
+
1943
+ class TestLooksLikeHostname:
1944
+ def test_domain(self):
1945
+ from mem.variables import _looks_like_hostname
1946
+
1947
+ assert _looks_like_hostname("registry.ghcr.io") is True
1948
+
1949
+ def test_subdomain(self):
1950
+ from mem.variables import _looks_like_hostname
1951
+
1952
+ assert _looks_like_hostname("api.prod.internal.company.com") is True
1953
+
1954
+ def test_ip_address(self):
1955
+ from mem.variables import _looks_like_hostname
1956
+
1957
+ assert _looks_like_hostname("10.0.1.50") is True
1958
+
1959
+ def test_token_not_hostname(self):
1960
+ from mem.variables import _looks_like_hostname
1961
+
1962
+ assert _looks_like_hostname("eyJhbGciOiJIUzI1NiJ9") is False
1963
+
1964
+ def test_password_not_hostname(self):
1965
+ from mem.variables import _looks_like_hostname
1966
+
1967
+ assert _looks_like_hostname("S3cretP@ssw0rd") is False
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