ltcai 5.0.0 → 5.1.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.
Files changed (37) hide show
  1. package/README.md +57 -43
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/TRUST_MODEL.md +66 -0
  4. package/docs/WHY_LATTICE.md +54 -0
  5. package/frontend/src/App.tsx +1 -1
  6. package/frontend/src/components/primitives.tsx +1 -1
  7. package/frontend/src/i18n.ts +6 -4
  8. package/frontend/src/pages/System.tsx +1 -1
  9. package/lattice_brain/__init__.py +1 -1
  10. package/lattice_brain/portability.py +11 -7
  11. package/lattice_brain/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/chat.py +19 -11
  14. package/latticeai/api/models.py +6 -0
  15. package/latticeai/api/security_dashboard.py +3 -15
  16. package/latticeai/api/static_routes.py +16 -0
  17. package/latticeai/app_factory.py +114 -40
  18. package/latticeai/core/audit.py +3 -1
  19. package/latticeai/core/builtin_hooks.py +7 -9
  20. package/latticeai/core/logging_safety.py +5 -21
  21. package/latticeai/core/marketplace.py +1 -1
  22. package/latticeai/core/security.py +67 -9
  23. package/latticeai/core/workspace_os.py +1 -1
  24. package/package.json +2 -2
  25. package/scripts/clean_release_artifacts.mjs +16 -1
  26. package/scripts/com.pts.claudecode.discord.plist +31 -0
  27. package/scripts/pts-claudecode-discord-bridge.mjs +189 -0
  28. package/scripts/run_integration_tests.mjs +91 -0
  29. package/scripts/start-pts-claudecode-discord.sh +51 -0
  30. package/src-tauri/Cargo.lock +1 -1
  31. package/src-tauri/Cargo.toml +1 -1
  32. package/src-tauri/tauri.conf.json +3 -2
  33. package/static/app/asset-manifest.json +3 -3
  34. package/static/app/assets/{index-FR1UZkCD.js → index-DONOJfMn.js} +2 -2
  35. package/static/app/assets/index-DONOJfMn.js.map +1 -0
  36. package/static/app/index.html +1 -1
  37. package/static/app/assets/index-FR1UZkCD.js.map +0 -1
@@ -26,7 +26,6 @@ from __future__ import annotations
26
26
  import io
27
27
  import json
28
28
  import logging
29
- import re
30
29
  from collections import defaultdict
31
30
  from datetime import datetime
32
31
  from typing import Any, Callable, Dict, List, Optional
@@ -36,6 +35,7 @@ from fastapi.responses import Response
36
35
  from pydantic import BaseModel
37
36
 
38
37
  from ..core import timezones
38
+ from ..core.security import SECRET_TEXT_PATTERNS, redact_secret_text
39
39
 
40
40
  logger = logging.getLogger(__name__)
41
41
 
@@ -43,23 +43,11 @@ logger = logging.getLogger(__name__)
43
43
  # ── Hard secret patterns ──────────────────────────────────────────────────────
44
44
  # 이 값들은 관리자도 절대 원문으로 보면 안 된다.
45
45
 
46
- HARD_SECRET_PATTERNS = [
47
- re.compile(r"(?i)(api[_-]?key|secret|access[_-]?token|password|passwd|bearer)\s*[:=]\s*\S+"),
48
- re.compile(r"sk-[A-Za-z0-9]{20,}"),
49
- re.compile(r"ghp_[A-Za-z0-9]{30,}"),
50
- re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
51
- re.compile(r"AKIA[0-9A-Z]{16}"),
52
- re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----"),
53
- ]
46
+ HARD_SECRET_PATTERNS = SECRET_TEXT_PATTERNS
54
47
 
55
48
 
56
49
  def redact_hard_secrets(text: str) -> str:
57
- if not text:
58
- return text or ""
59
- out = text
60
- for pat in HARD_SECRET_PATTERNS:
61
- out = pat.sub("[REDACTED_SECRET]", out)
62
- return out
50
+ return redact_secret_text(text)
63
51
 
64
52
 
65
53
  def soft_mask(text: str, *, keep: int = 4) -> str:
@@ -12,11 +12,27 @@ from fastapi.responses import FileResponse, HTMLResponse
12
12
 
13
13
  from latticeai.api.ui_redirects import app_redirect
14
14
 
15
+ PRODUCTION_CSP = (
16
+ "default-src 'self'; "
17
+ "script-src 'self'; "
18
+ "style-src 'self' 'unsafe-inline'; "
19
+ "img-src 'self' data: blob: http://127.0.0.1:*; "
20
+ "font-src 'self' data:; "
21
+ "connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*; "
22
+ "frame-src 'none'; "
23
+ "object-src 'none'; "
24
+ "base-uri 'none'; "
25
+ "form-action 'self'; "
26
+ "frame-ancestors 'none'"
27
+ )
28
+
29
+
15
30
  def ui_file_response(path: Path) -> FileResponse:
16
31
  response = FileResponse(path)
17
32
  response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
18
33
  response.headers["Pragma"] = "no-cache"
19
34
  response.headers["Expires"] = "0"
35
+ response.headers["Content-Security-Policy"] = PRODUCTION_CSP
20
36
  return response
21
37
 
22
38
  @dataclass(frozen=True)
@@ -23,6 +23,84 @@ if TYPE_CHECKING: # imports for annotations only — keep module import light
23
23
  from latticeai.core.config import Config
24
24
 
25
25
 
26
+ def build_config_runtime(config: "Optional[Config]" = None) -> Dict[str, Any]:
27
+ """Build the app configuration runtime without importing heavy model code."""
28
+
29
+ from latticeai.core.config import Config
30
+
31
+ cfg = config if config is not None else Config.from_env()
32
+ return {
33
+ "CONFIG": cfg,
34
+ "APP_MODE": cfg.app_mode,
35
+ "IS_PUBLIC_MODE": cfg.is_public,
36
+ "DEFAULT_HOST": cfg.host,
37
+ "DEFAULT_PORT": cfg.port,
38
+ "NETWORK_EXPOSED": cfg.network_exposed,
39
+ "ENABLE_TELEGRAM": cfg.enable_telegram,
40
+ "ENABLE_GRAPH": cfg.enable_graph,
41
+ "AUTOLOAD_MODELS": cfg.autoload_models,
42
+ "MODEL_IDLE_UNLOAD_SECONDS": cfg.model_idle_unload_seconds,
43
+ "ALLOW_LOCAL_MODELS": cfg.allow_local_models,
44
+ "REQUIRE_AUTH": cfg.require_auth,
45
+ "ALLOW_PLAINTEXT_API_KEYS": cfg.allow_plaintext_api_keys,
46
+ "CORS_ALLOW_NETWORK": cfg.cors_allow_network,
47
+ "CORS_EXTRA_ORIGINS": cfg.cors_extra_origins,
48
+ "PUBLIC_MODEL": cfg.public_model,
49
+ "LOCAL_MODEL": cfg.local_model,
50
+ "LOCAL_DRAFT_MODEL": cfg.local_draft_model,
51
+ }
52
+
53
+
54
+ def build_security_runtime(config: "Config") -> Dict[str, Any]:
55
+ """Build auth/security-derived runtime settings from the central config."""
56
+
57
+ from latticeai.core.security import configure_trusted_proxies
58
+
59
+ configure_trusted_proxies(config.trusted_proxies)
60
+ return {
61
+ "SSO_DISCOVERY_URL": config.sso_discovery_url,
62
+ "SSO_CLIENT_ID": config.sso_client_id,
63
+ "SSO_CLIENT_SECRET": config.sso_client_secret,
64
+ "SSO_REDIRECT_URI": config.sso_redirect_uri,
65
+ "SSO_PROVIDER_NAME": config.sso_provider_name,
66
+ "RATE_LIMIT_ENABLED": config.rate_limit_enabled,
67
+ "OPEN_REGISTRATION": config.open_registration,
68
+ "INVITE_CODE": config.invite_code,
69
+ "INVITE_GATE_ENABLED": config.invite_gate_enabled,
70
+ }
71
+
72
+
73
+ def build_brain_runtime(
74
+ *,
75
+ data_dir: Any,
76
+ history_file: Any,
77
+ enable_graph: bool,
78
+ embedder: Any,
79
+ storage_engine: Any,
80
+ ) -> Dict[str, Any]:
81
+ """Construct Brain Core storage/conversation primitives behind one seam."""
82
+
83
+ from lattice_brain import BrainCore, ConversationStore
84
+
85
+ brain_core = BrainCore.from_paths(
86
+ data_dir,
87
+ embedder=embedder.provider,
88
+ storage_engine=storage_engine,
89
+ ) if enable_graph else None
90
+ knowledge_graph = brain_core.knowledge if brain_core is not None else None
91
+ conversations = (
92
+ brain_core.conversations
93
+ if brain_core is not None
94
+ else ConversationStore(data_dir / "knowledge_graph.sqlite")
95
+ )
96
+ conversations.import_legacy_json(history_file)
97
+ return {
98
+ "BRAIN_CORE": brain_core,
99
+ "KNOWLEDGE_GRAPH": knowledge_graph,
100
+ "CONVERSATIONS": conversations,
101
+ }
102
+
103
+
26
104
  def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
27
105
  """The legacy ``server_app`` assembly, moved verbatim into function scope.
28
106
 
@@ -58,14 +136,13 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
58
136
  from pydantic import BaseModel
59
137
 
60
138
  from latticeai.models.router import LLMRouter, normalize_branding
61
- from knowledge_graph import set_llm_router
139
+ from lattice_brain._kg_common import set_llm_router
62
140
  from local_knowledge_api import LocalKnowledgeWatcher
63
141
  from latticeai.core.security import (
64
142
  hash_password,
65
143
  verify_password,
66
144
  host_is_loopback as _host_is_loopback_impl,
67
145
  client_ip as _client_ip_impl,
68
- configure_trusted_proxies as _configure_trusted_proxies,
69
146
  bytes_match_extension as _bytes_match_extension_impl,
70
147
  redact_secret_text as _redact_secret_text,
71
148
  check_ip_rate_limit as _check_ip_rate_limit,
@@ -83,7 +160,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
83
160
  from latticeai.api.admin import create_admin_router
84
161
  from latticeai.api.security_dashboard import create_security_router as _create_security_router
85
162
  from latticeai.core.model_compat import list_cached_profiles as _list_compat_profiles
86
- from latticeai.core.config import Config
87
163
  from latticeai.core.workspace_os import (
88
164
  WORKSPACE_OS_VERSION,
89
165
  WorkspaceOSStore,
@@ -160,7 +236,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
160
236
  from latticeai.api.portability import create_portability_router
161
237
  from latticeai.services.memory_service import MemoryService
162
238
  from lattice_brain.ingestion import IngestionItem, IngestionPipeline
163
- from lattice_brain import BrainCore, ConversationStore
164
239
  from lattice_brain.storage import storage_from_env
165
240
  from lattice_brain.context import ContextAssembler
166
241
  from lattice_brain.memory import BrainMemory
@@ -202,42 +277,43 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
202
277
  # ── App-level config — parsed once, in one place (latticeai.core.config) ──────
203
278
  # The module-level names below are kept as a compatibility surface for the rest
204
279
  # of server.py; all of them are now derived from a single CONFIG instance.
205
- CONFIG = config if config is not None else Config.from_env()
280
+ _config_runtime = build_config_runtime(config)
281
+ CONFIG = _config_runtime["CONFIG"]
206
282
  APP_VERSION = WORKSPACE_OS_VERSION
207
283
 
208
284
  # Forwarded headers (X-Forwarded-For / CF-Connecting-IP) are only honoured for
209
285
  # IP rate limiting when the direct peer is one of these trusted proxies. Empty by
210
286
  # default (local-first): the peer address is used and client-supplied headers are
211
287
  # ignored, so per-IP rate limits cannot be spoofed.
212
- _configure_trusted_proxies(CONFIG.trusted_proxies)
213
-
214
- APP_MODE = CONFIG.app_mode
215
- IS_PUBLIC_MODE = CONFIG.is_public
216
- DEFAULT_HOST = CONFIG.host
217
- DEFAULT_PORT = CONFIG.port
288
+ APP_MODE = _config_runtime["APP_MODE"]
289
+ IS_PUBLIC_MODE = _config_runtime["IS_PUBLIC_MODE"]
290
+ DEFAULT_HOST = _config_runtime["DEFAULT_HOST"]
291
+ DEFAULT_PORT = _config_runtime["DEFAULT_PORT"]
218
292
  def _host_is_loopback(host: str) -> bool:
219
293
  return _host_is_loopback_impl(host)
220
294
 
221
- NETWORK_EXPOSED = CONFIG.network_exposed
222
- ENABLE_TELEGRAM = CONFIG.enable_telegram
223
- ENABLE_GRAPH = CONFIG.enable_graph
224
- AUTOLOAD_MODELS = CONFIG.autoload_models
225
- MODEL_IDLE_UNLOAD_SECONDS = CONFIG.model_idle_unload_seconds
226
- ALLOW_LOCAL_MODELS = CONFIG.allow_local_models
227
- REQUIRE_AUTH = CONFIG.require_auth
228
- ALLOW_PLAINTEXT_API_KEYS = CONFIG.allow_plaintext_api_keys
229
- CORS_ALLOW_NETWORK = CONFIG.cors_allow_network
230
- CORS_EXTRA_ORIGINS = CONFIG.cors_extra_origins
231
- PUBLIC_MODEL = CONFIG.public_model
232
- LOCAL_MODEL = CONFIG.local_model
233
- LOCAL_DRAFT_MODEL = CONFIG.local_draft_model
295
+ NETWORK_EXPOSED = _config_runtime["NETWORK_EXPOSED"]
296
+ ENABLE_TELEGRAM = _config_runtime["ENABLE_TELEGRAM"]
297
+ ENABLE_GRAPH = _config_runtime["ENABLE_GRAPH"]
298
+ AUTOLOAD_MODELS = _config_runtime["AUTOLOAD_MODELS"]
299
+ MODEL_IDLE_UNLOAD_SECONDS = _config_runtime["MODEL_IDLE_UNLOAD_SECONDS"]
300
+ ALLOW_LOCAL_MODELS = _config_runtime["ALLOW_LOCAL_MODELS"]
301
+ REQUIRE_AUTH = _config_runtime["REQUIRE_AUTH"]
302
+ ALLOW_PLAINTEXT_API_KEYS = _config_runtime["ALLOW_PLAINTEXT_API_KEYS"]
303
+ CORS_ALLOW_NETWORK = _config_runtime["CORS_ALLOW_NETWORK"]
304
+ CORS_EXTRA_ORIGINS = _config_runtime["CORS_EXTRA_ORIGINS"]
305
+ PUBLIC_MODEL = _config_runtime["PUBLIC_MODEL"]
306
+ LOCAL_MODEL = _config_runtime["LOCAL_MODEL"]
307
+ LOCAL_DRAFT_MODEL = _config_runtime["LOCAL_DRAFT_MODEL"]
308
+
309
+ _security_runtime = build_security_runtime(CONFIG)
234
310
 
235
311
  # ── SSO / OIDC config ─────────────────────────────────────────────────────────
236
- SSO_DISCOVERY_URL = CONFIG.sso_discovery_url
237
- SSO_CLIENT_ID = CONFIG.sso_client_id
238
- SSO_CLIENT_SECRET = CONFIG.sso_client_secret
239
- SSO_REDIRECT_URI = CONFIG.sso_redirect_uri
240
- SSO_PROVIDER_NAME = CONFIG.sso_provider_name
312
+ SSO_DISCOVERY_URL = _security_runtime["SSO_DISCOVERY_URL"]
313
+ SSO_CLIENT_ID = _security_runtime["SSO_CLIENT_ID"]
314
+ SSO_CLIENT_SECRET = _security_runtime["SSO_CLIENT_SECRET"]
315
+ SSO_REDIRECT_URI = _security_runtime["SSO_REDIRECT_URI"]
316
+ SSO_PROVIDER_NAME = _security_runtime["SSO_PROVIDER_NAME"]
241
317
  _sso_discovery_cache: Optional[Dict] = None
242
318
  _sso_discovery_cache_url: str = ""
243
319
  _sso_states: Dict[str, float] = {} # state → timestamp (CSRF protection)
@@ -343,22 +419,20 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
343
419
  if EMBEDDER.fell_back:
344
420
  logging.warning("Embedding provider %s unavailable: %s", EMBEDDER.requested, EMBEDDER.detail)
345
421
  STORAGE_ENGINE = storage_from_env(os.environ, data_dir=DATA_DIR) if ENABLE_GRAPH else None
346
- BRAIN_CORE = BrainCore.from_paths(
347
- DATA_DIR,
348
- embedder=EMBEDDER.provider,
422
+ _brain_runtime = build_brain_runtime(
423
+ data_dir=DATA_DIR,
424
+ history_file=HISTORY_FILE,
425
+ enable_graph=ENABLE_GRAPH,
426
+ embedder=EMBEDDER,
349
427
  storage_engine=STORAGE_ENGINE,
350
- ) if ENABLE_GRAPH else None
351
- KNOWLEDGE_GRAPH = BRAIN_CORE.knowledge if BRAIN_CORE is not None else None
428
+ )
429
+ BRAIN_CORE = _brain_runtime["BRAIN_CORE"]
430
+ KNOWLEDGE_GRAPH = _brain_runtime["KNOWLEDGE_GRAPH"]
352
431
  # ── v4 durable conversation store: unbounded episodic memory in the same
353
432
  # SQLite file as the graph (kg_portability backup/restore covers it for
354
433
  # free). Legacy chat_history.json is imported once, idempotently, and the
355
434
  # file is left untouched on disk as the import source.
356
- CONVERSATIONS = (
357
- BRAIN_CORE.conversations
358
- if BRAIN_CORE is not None
359
- else ConversationStore(DATA_DIR / "knowledge_graph.sqlite")
360
- )
361
- CONVERSATIONS.import_legacy_json(HISTORY_FILE)
435
+ CONVERSATIONS = _brain_runtime["CONVERSATIONS"]
362
436
  # Hooks registry is constructed here (ahead of the watcher) so folder-watch
363
437
  # reindexes can fire the pre_index/post_index lifecycle hooks.
364
438
  HOOKS_REGISTRY = HooksRegistry(DATA_DIR / "hooks.json")
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Any, Callable, Dict, List, Optional
10
10
 
11
11
  from . import timezones
12
+ from .security import redact_secrets
12
13
 
13
14
  _history_lock = threading.Lock()
14
15
 
@@ -40,11 +41,12 @@ def get_audit_log(audit_file: Path) -> List[Dict]:
40
41
 
41
42
  def append_audit_event(audit_file: Path, event_type: str, **payload) -> None:
42
43
  try:
44
+ safe_payload = redact_secrets(payload)
43
45
  event = {
44
46
  "event_type": event_type,
45
47
  # item 7: 대시보드 "오늘" 계산과 동일한 시간대 기준으로 기록한다.
46
48
  "timestamp": timezones.now_iso(),
47
- **payload,
49
+ **safe_payload,
48
50
  }
49
51
  with _history_lock:
50
52
  events = get_audit_log(audit_file)
@@ -14,10 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from typing import Any, Callable
16
16
 
17
- _SECRET_KEY_HINTS = (
18
- "token", "password", "passwd", "secret", "api_key", "apikey",
19
- "authorization", "auth", "cookie", "session", "private_key",
20
- )
17
+ from latticeai.core.security import SECRET_KEY_HINTS, redact_secrets as redact_secret_values
21
18
 
22
19
 
23
20
  def register_builtin_hook_runners(
@@ -32,11 +29,12 @@ def register_builtin_hook_runners(
32
29
  def redact_secrets(context):
33
30
  """pre_run — strip secret-like keys from the agent context packet."""
34
31
  payload = context.payload if isinstance(context.payload, dict) else {}
35
- redacted = []
36
- for key in list(payload.keys()):
37
- if any(s in str(key).lower() for s in _SECRET_KEY_HINTS):
38
- payload[key] = "***redacted***"
39
- redacted.append(key)
32
+ before = dict(payload)
33
+ payload.update(redact_secret_values(payload))
34
+ redacted = [
35
+ key for key in payload.keys()
36
+ if payload.get(key) != before.get(key) or any(s in str(key).lower() for s in SECRET_KEY_HINTS)
37
+ ]
40
38
  return {"status": "ok", "output": f"redacted {len(redacted)} field(s)" if redacted else "no secrets present"}
41
39
 
42
40
  def audit_agent_run(context):
@@ -3,22 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- import re
7
6
  from typing import Any
8
7
 
9
- _TELEGRAM_BOT_TOKEN_RE = re.compile(r"\bbot(\d{5,20}):([A-Za-z0-9_-]{8,})")
10
- _TELEGRAM_BARE_TOKEN_RE = re.compile(
11
- r"(?<![A-Za-z0-9_:-])(\d{5,20}):([A-Za-z0-9_-]{8,})(?![A-Za-z0-9_-])"
12
- )
8
+ from latticeai.core.security import redact_secret_text, redact_secrets
9
+
13
10
  _LOG_FILTER_INSTALLED = False
14
11
 
15
12
 
16
13
  def mask_telegram_bot_token(value: Any) -> str:
17
- """Return ``value`` as text with Telegram bot token secrets redacted."""
14
+ """Return ``value`` as text with Telegram bot token and other secrets redacted."""
18
15
 
19
- text = str(value)
20
- text = _TELEGRAM_BOT_TOKEN_RE.sub(r"bot\1:REDACTED", text)
21
- return _TELEGRAM_BARE_TOKEN_RE.sub(r"bot\1:REDACTED", text)
16
+ return redact_secret_text(str(value))
22
17
 
23
18
 
24
19
  def safe_log_text(value: Any) -> str:
@@ -28,18 +23,7 @@ def safe_log_text(value: Any) -> str:
28
23
 
29
24
 
30
25
  def _safe_log_arg(value: Any) -> Any:
31
- if isinstance(value, str):
32
- return mask_telegram_bot_token(value)
33
- if isinstance(value, tuple):
34
- return tuple(_safe_log_arg(item) for item in value)
35
- if isinstance(value, list):
36
- return [_safe_log_arg(item) for item in value]
37
- if isinstance(value, dict):
38
- return {_safe_log_arg(key): _safe_log_arg(item) for key, item in value.items()}
39
-
40
- text = str(value)
41
- masked = mask_telegram_bot_token(text)
42
- return masked if masked != text else value
26
+ return redact_secrets(value)
43
27
 
44
28
 
45
29
  def install_sensitive_log_filter() -> None:
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "5.0.0"
14
+ MARKETPLACE_VERSION = "5.1.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -6,7 +6,7 @@ import re
6
6
  import secrets
7
7
  import threading
8
8
  import time
9
- from typing import Dict, List
9
+ from typing import Any, Dict, List
10
10
 
11
11
  from fastapi import HTTPException
12
12
 
@@ -119,21 +119,79 @@ def bytes_match_extension(data: bytes, ext: str) -> bool:
119
119
  return any(head.startswith(sig) for sig in signatures)
120
120
 
121
121
 
122
+ SECRET_KEY_HINTS = (
123
+ "api_key",
124
+ "apikey",
125
+ "secret",
126
+ "token",
127
+ "password",
128
+ "passwd",
129
+ "authorization",
130
+ "cookie",
131
+ "session",
132
+ "private_key",
133
+ "client_secret",
134
+ "webhook",
135
+ "dsn",
136
+ "credential",
137
+ )
138
+
139
+ SECRET_TEXT_PATTERNS = [
140
+ re.compile(r"(?i)\b(api[_ -]?key|secret|token|password|passwd|authorization|bearer|client[_ -]?secret|webhook|dsn)\s*[:=]\s*['\"]?([^\s'\",;]{8,})['\"]?"),
141
+ re.compile(r"\b(sk-[A-Za-z0-9_\-]{16,})\b"),
142
+ re.compile(r"\b(xai-[A-Za-z0-9_\-]{16,})\b"),
143
+ re.compile(r"\b(gsk_[A-Za-z0-9_\-]{16,})\b"),
144
+ re.compile(r"\b(ghp_[A-Za-z0-9_]{30,})\b"),
145
+ re.compile(r"\b(xox[baprs]-[A-Za-z0-9-]{10,})\b"),
146
+ re.compile(r"\b(AKIA[0-9A-Z]{16})\b"),
147
+ re.compile(r"(?i)\b(postgres(?:ql)?://[^@\s]+:[^@\s]+@[^\s]+)"),
148
+ re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----"),
149
+ ]
150
+
151
+ TELEGRAM_TOKEN_WITH_BOT_RE = re.compile(r"\bbot(\d{5,20}):[A-Za-z0-9_-]{8,}\b")
152
+ TELEGRAM_TOKEN_BARE_RE = re.compile(r"(?<![A-Za-z0-9_:-])(\d{5,20}):[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])")
153
+
154
+
155
+ def _is_secret_key(key: Any) -> bool:
156
+ lowered = str(key or "").lower().replace("-", "_")
157
+ return any(hint in lowered for hint in SECRET_KEY_HINTS)
158
+
159
+
122
160
  def redact_secret_text(text: str) -> str:
161
+ """Redact known secret shapes from user-visible text, logs, and audit data."""
162
+
123
163
  if not text:
124
164
  return ""
125
- patterns = [
126
- r"(?i)(api[_ -]?key|secret|token|password|passwd)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{12,})['\"]?",
127
- r"\b(sk-[A-Za-z0-9_\-]{16,})\b",
128
- r"\b(xai-[A-Za-z0-9_\-]{16,})\b",
129
- r"\b(gsk_[A-Za-z0-9_\-]{16,})\b",
130
- ]
131
165
  redacted = str(text)
132
- for pattern in patterns:
133
- redacted = re.sub(pattern, lambda m: f"{m.group(1)}=[REDACTED]" if len(m.groups()) > 1 else "[REDACTED]", redacted)
166
+ redacted = TELEGRAM_TOKEN_WITH_BOT_RE.sub(r"bot\1:REDACTED", redacted)
167
+ redacted = TELEGRAM_TOKEN_BARE_RE.sub(r"bot\1:REDACTED", redacted)
168
+ for pattern in SECRET_TEXT_PATTERNS:
169
+ def repl(match: re.Match) -> str:
170
+ if len(match.groups()) >= 2:
171
+ return f"{match.group(1)}=[REDACTED_SECRET]"
172
+ return "[REDACTED_SECRET]"
173
+
174
+ redacted = pattern.sub(repl, redacted)
134
175
  return redacted
135
176
 
136
177
 
178
+ def redact_secrets(value: Any) -> Any:
179
+ """Recursively redact secret-like values before logs, audit, or API previews."""
180
+
181
+ if isinstance(value, str):
182
+ return redact_secret_text(value)
183
+ if isinstance(value, dict):
184
+ out: Dict[Any, Any] = {}
185
+ for key, item in value.items():
186
+ out[key] = "[REDACTED_SECRET]" if _is_secret_key(key) else redact_secrets(item)
187
+ return out
188
+ if isinstance(value, list):
189
+ return [redact_secrets(item) for item in value]
190
+ if isinstance(value, tuple):
191
+ return tuple(redact_secrets(item) for item in value)
192
+ return value
193
+
194
+
137
195
  # ── IP-based rate limiting (registration / login) ────────────────────────────
138
196
  _ip_rate_windows: dict = {}
139
197
  _ip_rate_lock = threading.Lock()
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "5.0.0"
22
+ WORKSPACE_OS_VERSION = "5.1.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Lattice AI — local-first Living Brain workspace (conversation, durable memory, hybrid search, agents, advanced graph exploration, portable encrypted brain archives)",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -30,7 +30,7 @@
30
30
  "typecheck:frontend": "npx tsc -p tsconfig.json --noEmit",
31
31
  "test": "node scripts/run_python.mjs -m pytest tests/ -v",
32
32
  "test:unit": "node scripts/run_python.mjs -m pytest tests/unit/ -v",
33
- "test:integration": "node scripts/run_python.mjs -m pytest tests/integration/ -v",
33
+ "test:integration": "node scripts/run_integration_tests.mjs",
34
34
  "test:visual": "playwright test",
35
35
  "vercel:build": "node scripts/build_vercel_static.mjs",
36
36
  "desktop:tauri": "tauri dev",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, rmSync } from "node:fs";
2
+ import { existsSync, readdirSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  const repo = join(import.meta.dirname, "..");
@@ -19,6 +19,21 @@ const targets = [
19
19
  join(repo, "src-tauri", "target", "release", "bundle", "macos", "Lattice AI.app"),
20
20
  ];
21
21
 
22
+ const distDir = join(repo, "dist");
23
+ if (existsSync(distDir)) {
24
+ for (const name of readdirSync(distDir)) {
25
+ if (/^ltcai-\d+\.\d+\.\d+.*\.(whl|tar\.gz|vsix|tgz)$/.test(name)) {
26
+ targets.push(join(distDir, name));
27
+ }
28
+ }
29
+ }
30
+
31
+ for (const name of readdirSync(repo)) {
32
+ if (/^ltcai-\d+\.\d+\.\d+.*\.tgz$/.test(name)) {
33
+ targets.push(join(repo, name));
34
+ }
35
+ }
36
+
22
37
  for (const target of targets) {
23
38
  if (existsSync(target)) {
24
39
  rmSync(target, { recursive: true, force: true });
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
3
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
4
+ <plist version="1.0">
5
+ <dict>
6
+ <key>Label</key>
7
+ <string>com.pts.claudecode.discord</string>
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>/opt/homebrew/bin/node</string>
11
+ <string>/Users/parktaesoo/.claude/bin/pts-claudecode-discord-bridge.mjs</string>
12
+ </array>
13
+ <key>RunAtLoad</key>
14
+ <true/>
15
+ <key>KeepAlive</key>
16
+ <true/>
17
+ <key>EnvironmentVariables</key>
18
+ <dict>
19
+ <key>PATH</key>
20
+ <string>/Users/parktaesoo/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
21
+ <key>DISCORD_STATE_DIR</key>
22
+ <string>/Users/parktaesoo/.claude/channels/discord</string>
23
+ <key>PTS_CLAUDECODE_PROJECT_DIR</key>
24
+ <string>/Users/parktaesoo/Downloads/Lattice AI</string>
25
+ </dict>
26
+ <key>StandardOutPath</key>
27
+ <string>/Users/parktaesoo/.claude/logs/pts_claudecode_bridge.out.log</string>
28
+ <key>StandardErrorPath</key>
29
+ <string>/Users/parktaesoo/.claude/logs/pts_claudecode_bridge.err.log</string>
30
+ </dict>
31
+ </plist>