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.
- package/README.md +57 -43
- package/docs/CHANGELOG.md +61 -0
- package/docs/TRUST_MODEL.md +66 -0
- package/docs/WHY_LATTICE.md +54 -0
- package/frontend/src/App.tsx +1 -1
- package/frontend/src/components/primitives.tsx +1 -1
- package/frontend/src/i18n.ts +6 -4
- package/frontend/src/pages/System.tsx +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/portability.py +11 -7
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +19 -11
- package/latticeai/api/models.py +6 -0
- package/latticeai/api/security_dashboard.py +3 -15
- package/latticeai/api/static_routes.py +16 -0
- package/latticeai/app_factory.py +114 -40
- package/latticeai/core/audit.py +3 -1
- package/latticeai/core/builtin_hooks.py +7 -9
- package/latticeai/core/logging_safety.py +5 -21
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/security.py +67 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/package.json +2 -2
- package/scripts/clean_release_artifacts.mjs +16 -1
- package/scripts/com.pts.claudecode.discord.plist +31 -0
- package/scripts/pts-claudecode-discord-bridge.mjs +189 -0
- package/scripts/run_integration_tests.mjs +91 -0
- package/scripts/start-pts-claudecode-discord.sh +51 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +3 -2
- package/static/app/asset-manifest.json +3 -3
- package/static/app/assets/{index-FR1UZkCD.js → index-DONOJfMn.js} +2 -2
- package/static/app/assets/index-DONOJfMn.js.map +1 -0
- package/static/app/index.html +1 -1
- 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
|
-
|
|
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)
|
package/latticeai/app_factory.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
222
|
-
ENABLE_TELEGRAM =
|
|
223
|
-
ENABLE_GRAPH =
|
|
224
|
-
AUTOLOAD_MODELS =
|
|
225
|
-
MODEL_IDLE_UNLOAD_SECONDS =
|
|
226
|
-
ALLOW_LOCAL_MODELS =
|
|
227
|
-
REQUIRE_AUTH =
|
|
228
|
-
ALLOW_PLAINTEXT_API_KEYS =
|
|
229
|
-
CORS_ALLOW_NETWORK =
|
|
230
|
-
CORS_EXTRA_ORIGINS =
|
|
231
|
-
PUBLIC_MODEL =
|
|
232
|
-
LOCAL_MODEL =
|
|
233
|
-
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 =
|
|
237
|
-
SSO_CLIENT_ID =
|
|
238
|
-
SSO_CLIENT_SECRET =
|
|
239
|
-
SSO_REDIRECT_URI =
|
|
240
|
-
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
|
-
|
|
347
|
-
DATA_DIR,
|
|
348
|
-
|
|
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
|
-
)
|
|
351
|
-
|
|
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")
|
package/latticeai/core/audit.py
CHANGED
|
@@ -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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
133
|
-
|
|
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.
|
|
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.
|
|
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/
|
|
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>
|