ltcai 0.1.19 → 0.1.22

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 CHANGED
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
14
14
 
15
15
  ### 현재 배포 버전
16
16
 
17
- - `PyPI`: `ltcai==0.1.18`
18
- - `npm`: `ltcai@0.1.18`
19
- - `VS Code Marketplace`: `parktaesoo.ltcai@0.1.18`
20
- - `Open VSX`: `parktaesoo.ltcai@0.1.18`
17
+ - `PyPI`: `ltcai==0.1.22`
18
+ - `npm`: `ltcai@0.1.22`
19
+ - `VS Code Marketplace`: `parktaesoo.ltcai@0.1.22`
20
+ - `Open VSX`: `parktaesoo.ltcai@0.1.22`
21
21
 
22
22
  ### 왜 Lattice AI인가
23
23
 
@@ -126,6 +126,7 @@ LATTICEAI_TELEGRAM_BOT_TOKEN=your-token LTCAI
126
126
  | 기능 | 설명 |
127
127
  |------|------|
128
128
  | **웹 UI** | 반응형 채팅 + 어드민 패널 + 그래프 시각화 |
129
+ | **Auto Setup Wizard** | 구성요소 감지 → 공식 다운로드 연결 → 설치 감지 → PATH/env 세팅 → 동작 테스트 → 자동 복구 |
129
130
  | **VS Code / Cursor 확장** | 채팅, Edit Selection, Diff 뷰, 파일 첨부 |
130
131
  | **Telegram 봇** | 로컬 AI 미러 + Codex 클라우드 봇 |
131
132
  | **MCP 서버** | Claude Desktop / Cursor에서 직접 도구 사용 |
@@ -214,11 +215,13 @@ docker run --rm -p 4825:4825 \
214
215
  ## 보안
215
216
 
216
217
  - **바인딩**: 기본 `127.0.0.1:4825` (로컬 전용) — 같은 Wi-Fi 기기 접근 허용 시 `LATTICEAI_HOST=0.0.0.0` 설정
217
- - **인증**: 모든 민감 엔드포인트 로그인 세션 필요 (`REQUIRE_AUTH=true` 기본)
218
+ - **인증**: 퍼블릭 모드 또는 `0.0.0.0` 등 네트워크 노출 호스트에서는 로그인 세션이 기본으로 필요합니다
218
219
  - **세션**: 24시간 TTL + sliding refresh, 서버 디스크 저장 (재시작 후에도 유지)
219
- - **CORS**: 기본 localhost만 허용 — 외부 허용 `LATTICEAI_CORS_ALLOW_NETWORK=true`
220
+ - **CORS**: 기본 localhost만 허용 — 외부 브라우저 origin은 `LATTICEAI_CORS_ALLOWED_ORIGINS`에 명시
220
221
  - **API 키**: OS keyring/Keychain 저장 (평문 미저장)
221
- - **쿠키**: `HttpOnly + SameSite=Lax` (CSRF 방어)
222
+ - **쿠키**: `HttpOnly + SameSite=Lax` (세션 토큰은 localStorage에 저장하지 않음)
223
+ - **로컬 파일 접근**: list/read/write/preview는 서버 발급 approval token으로 경로·사용자·작업 범위를 검증하고, 웹 UI 또는 Discord permission flow에서 승인 후 실행
224
+ - **채팅 경로 자동 읽기**: 기본 OFF. `LATTICEAI_AUTO_READ_CHAT_PATHS=true`로 명시한 경우에만 `/path` 또는 `~/path` 내용을 모델 컨텍스트에 주입
222
225
  - **Rate limit**: `/chat` 30/분, `/agent` 6/분, `/upload` 12/분 (per user)
223
226
  - **파일 업로드**: magic-number 시그니처 검증 (확장자 위조 차단)
224
227
  - **텔레메트리**: 없음. 모든 데이터는 로컬(`~/.ltcai/`)에만 저장됩니다.
@@ -251,7 +254,7 @@ docker run --rm -p 4825:4825 \
251
254
 
252
255
  ### 릴리스 체크
253
256
 
254
- `0.1.17 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
257
+ `0.1.22` 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
255
258
 
256
259
  - `npm`
257
260
  - `PyPI`
@@ -368,7 +371,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
368
371
 
369
372
  ## 릴리스 노트
370
373
 
371
- 현재 버전: **0.1.18** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
374
+ 현재 버전: **0.1.22** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
372
375
 
373
376
  ## 라이선스
374
377
 
package/bin/ltcai.js CHANGED
@@ -12,6 +12,33 @@ const managedPython = process.platform === "win32"
12
12
  ? path.join(managedVenv, "Scripts", "python.exe")
13
13
  : path.join(managedVenv, "bin", "python");
14
14
 
15
+ function loadDotEnv(file) {
16
+ if (!fs.existsSync(file)) return;
17
+ for (const rawLine of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
18
+ const line = rawLine.trim();
19
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
20
+ const index = line.indexOf("=");
21
+ const key = line.slice(0, index).trim();
22
+ const value = line.slice(index + 1).trim().replace(/^["']|["']$/g, "");
23
+ if (key && process.env[key] === undefined) process.env[key] = value;
24
+ }
25
+ }
26
+
27
+ function applyExtraPath() {
28
+ const extra = process.env.LATTICEAI_EXTRA_PATH;
29
+ if (!extra) return;
30
+ const sep = path.delimiter;
31
+ const current = (process.env.PATH || "").split(sep).filter(Boolean);
32
+ for (const item of extra.split(sep).filter(Boolean).reverse()) {
33
+ const expanded = item.replace(/^~(?=$|\/|\\)/, os.homedir());
34
+ if (fs.existsSync(expanded) && !current.includes(expanded)) current.unshift(expanded);
35
+ }
36
+ process.env.PATH = current.join(sep);
37
+ }
38
+
39
+ loadDotEnv(path.join(root, ".env"));
40
+ applyExtraPath();
41
+
15
42
  function runSync(cmd, args, options = {}) {
16
43
  const result = spawnSync(cmd, args, { stdio: "inherit", ...options });
17
44
  return result.status === 0;
package/docs/CHANGELOG.md CHANGED
@@ -1,12 +1,89 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.19] - 2026-05-23
3
+ ## [0.1.22] - 2026-05-24
4
4
 
5
- ### Publisher 변경
5
+ ### Release preparation
6
6
 
7
- - VS Code extension publisher `parktaesoo` → `TaeSooPark-PTS` 로 변경
8
- - Extension ID: `parktaesoo.ltcai` → `TaeSooPark-PTS.ltcai`
9
- - Open VSX namespace 통일 (`TaeSooPark-PTS`)
7
+ - 배포 버전을 `0.1.22`로 상향
8
+ - `package.json`
9
+ - `pyproject.toml`
10
+ - `vscode-extension/package.json`
11
+ - README 현재 배포 버전과 릴리스 체크 섹션을 `0.1.22` 기준으로 갱신
12
+ - npm / PyPI / VS Code Marketplace / Open VSX 배포 전 빌드 산출물 생성
13
+
14
+ ### Verification
15
+
16
+ - Python compile check 통과
17
+ - unit tests 통과
18
+ - root npm package 생성
19
+ - Python wheel / sdist 생성
20
+ - VS Code / Open VSX용 VSIX 생성
21
+
22
+ ## [0.1.21] - 2026-05-24
23
+
24
+ ### Setup Wizard — 자동 설치 · 연결 · 검증 · 복구
25
+
26
+ - **구성요소 자동 감지** — Homebrew, Python, Git, Node/npm, Ollama, LM Studio, Tesseract, MLX 계열 탐지
27
+ - `COMMON_PATH_DIRS` 확장: `/opt/homebrew/bin`, `~/.local/bin`, `~/.latticeai/bin` 등 자동 포함
28
+ - `PACKAGE_MODULES` 맵으로 pip 패키지 → import 이름 변환 (mlx-lm, mlx-vlm, openai-whisper 등)
29
+ - **공식 다운로드 연결** — 자동 설치 실패 시 OS별 공식 페이지(`OFFICIAL_DOWNLOADS`) 자동 오픈
30
+ - **설치 완료 자동 감지** — binary / Python 모듈 재탐색 폴링으로 설치 완료 감지
31
+ - **환경 변수 / PATH 자동 세팅** — PATH 누락 디렉토리를 `.env`의 `LATTICEAI_EXTRA_PATH`에 자동 저장
32
+ - `_update_env_file()` 헬퍼로 `.env` 파일 안전 갱신 (중복 없이 key 업데이트)
33
+ - **동작 테스트** — binary는 `--version`, Python 패키지는 `import` smoke test
34
+ - **실패 시 자동 복구** — PATH 재보정, pip 재시도, brew 실패 시 공식 다운로드 fallback
35
+
36
+ ### 보안 강화 — 로컬 파일 접근 승인 시스템
37
+
38
+ - **토큰 기반 로컬 파일 승인** — `_local_permission_response()` / `_require_local_approval()`
39
+ - 5분(300초) TTL 만료 토큰으로 read / write / list 각 액션을 별도 승인
40
+ - write 승인 시 `content_hash`(SHA-256)로 내용 위변조 방지
41
+ - 만료 토큰 자동 정리(lazy GC)
42
+ - Discord permission monitor 또는 웹 UI 승인 후에만 토큰 활성화
43
+ - **로컬 파일 미리보기 보호** — `/local/serve`, `/tools/read_document`, `/tools/pdf_pages`도 서버 발급 approval token 없이는 로컬 절대 경로를 열지 않도록 변경
44
+ - **workspace 정적 노출 제거** — `/agent-files` `StaticFiles` mount 제거, 인증이 있는 다운로드 라우트만 사용
45
+ - **세션 토큰 저장 강화** — 로그인 응답 body에서 bearer token 제거, 웹 UI는 HttpOnly cookie 기반 인증만 사용
46
+ - `static/account.html`, `static/chat.html`, `static/admin.html`, `static/graph.html`의 `localStorage` 세션 토큰 의존 제거
47
+ - **loopback 감지** — `_host_is_loopback()` + `ipaddress` 표준 라이브러리로 네트워크 노출 여부 판단
48
+ - `REQUIRE_AUTH` 기본값: 퍼블릭 모드 또는 네트워크 노출 시 `true` 자동 적용
49
+ - `OPEN_REGISTRATION`: 네트워크 노출/퍼블릭 모드에서 기본 `false` (초대 코드 필요)
50
+ - **CORS 세밀 제어** — wildcard credential CORS 대신 `LATTICEAI_CORS_ALLOWED_ORIGINS` 환경변수로 허용 출처 추가 설정 가능
51
+ - **파일 자동 주입(opt-in)** — `LATTICEAI_AUTO_READ_CHAT_PATHS=true` 설정 시에만 채팅 메시지의 로컬 경로를 컨텍스트에 주입 (기본 OFF — 클라우드 모델 파일 누출 방지)
52
+
53
+ ### 어드민 대시보드 — Audit & Data Governance
54
+
55
+ - **감사 로그 섹션** — 사용자별 AI 사용량, 업로드 문서 수, 민감정보 감지, clear/delete 이벤트, 최근 감사 이벤트 표시
56
+ - **데이터 보존 정책** — `/clear`, `/clear_all`, 대화 삭제는 화면 정리만 수행; Data Graph / RAG / 감사 로그는 보존
57
+ - clear 동작을 `ClearEvent` 노드로 그래프에 기록 (언제 누가 clear 했는지 감사 추적)
58
+ - **민감정보 검사** — 문서 업로드 텍스트를 감사 로그에 기록
59
+
60
+ ### Graph RAG / Data Graph
61
+
62
+ - **한국어 단어 검색 개선** — 2글자 키워드(`문서`, `모델` 등) RAG 검색 누락 문제 수정
63
+ - **`graph.html` 독립 페이지 유지** — 채팅 사이드바 `Data Graph` 버튼으로 연결, New Chat 버튼은 대화 검색 아래로 이동
64
+
65
+ ### CLI / Node.js 래퍼
66
+
67
+ - `ltcai_cli.py` — `doctor` 명령어에 확장된 구성요소 탐지 통합
68
+ - `bin/ltcai.js` — Node.js 래퍼 PATH 보정 로직 개선
69
+
70
+ ### 테스트
71
+
72
+ - `tests/unit/test_security.py` — loopback 감지, 로컬 파일 접근 approval token, write content hash 검증 추가
73
+ - `tests/unit/test_setup_wizard.py` — 자동 설정 구성요소 감지와 PATH 보정 검증 추가
74
+
75
+ ### 환경변수 추가 (`.env.example`)
76
+
77
+ | 변수 | 기본값 | 설명 |
78
+ |------|--------|------|
79
+ | `LATTICEAI_AUTO_READ_CHAT_PATHS` | `false` | 채팅 메시지 내 로컬 경로 자동 주입 |
80
+ | `LATTICEAI_CORS_ALLOWED_ORIGINS` | `` | 추가 허용 CORS 출처 (콤마 구분) |
81
+ | `LATTICEAI_EXTRA_PATH` | `` | 추가 PATH 디렉토리 (Setup Wizard 자동 기록) |
82
+
83
+ ## [0.1.20] - 2026-05-23
84
+
85
+ ### Release
86
+ - 배포 버전을 `0.1.19`로 상향
10
87
  - 대상 채널: `npm` · `PyPI` · `VS Code Marketplace` · `Open VSX`
11
88
 
12
89
  ## [0.1.18] - 2026-05-23
package/ltcai_cli.py CHANGED
@@ -18,6 +18,32 @@ import urllib.request
18
18
  from pathlib import Path
19
19
 
20
20
 
21
+ def _load_env_file(path: Path) -> None:
22
+ if not path.exists():
23
+ return
24
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
25
+ line = raw_line.strip()
26
+ if not line or line.startswith("#") or "=" not in line:
27
+ continue
28
+ key, value = line.split("=", 1)
29
+ key = key.strip()
30
+ value = value.strip().strip('"').strip("'")
31
+ if key and key not in os.environ:
32
+ os.environ[key] = value
33
+
34
+
35
+ def _apply_extra_path() -> None:
36
+ extra = os.getenv("LATTICEAI_EXTRA_PATH", "")
37
+ if not extra:
38
+ return
39
+ current = [p for p in os.environ.get("PATH", "").split(os.pathsep) if p]
40
+ for item in reversed([p for p in extra.split(os.pathsep) if p]):
41
+ expanded = str(Path(item).expanduser())
42
+ if Path(expanded).exists() and expanded not in current:
43
+ current.insert(0, expanded)
44
+ os.environ["PATH"] = os.pathsep.join(current)
45
+
46
+
21
47
  def _has_module(name: str) -> bool:
22
48
  return importlib.util.find_spec(name) is not None
23
49
 
@@ -200,6 +226,10 @@ def _start_tunnel(port: int) -> str | None:
200
226
  # ─────────────────────────────────────────────────────────────────────────────
201
227
 
202
228
  def main() -> None:
229
+ app_dir = Path(__file__).resolve().parent
230
+ _load_env_file(app_dir / ".env")
231
+ _apply_extra_path()
232
+
203
233
  parser = argparse.ArgumentParser(prog="LTCAI", description="Run the Lattice AI local server.")
204
234
  subparsers = parser.add_subparsers(dest="command")
205
235
  subparsers.add_parser("doctor", help="Check local runtime dependencies and configuration.")
@@ -216,7 +246,6 @@ def main() -> None:
216
246
  if args.command == "doctor":
217
247
  raise SystemExit(doctor())
218
248
 
219
- app_dir = Path(__file__).resolve().parent
220
249
  os.chdir(app_dir)
221
250
 
222
251
  # --tunnel forces 0.0.0.0 so cloudflared can reach the server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.19",
3
+ "version": "0.1.22",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
package/server.py CHANGED
@@ -22,6 +22,7 @@ import tempfile
22
22
  import time
23
23
  import urllib.error
24
24
  import urllib.request
25
+ import ipaddress
25
26
  from contextlib import asynccontextmanager
26
27
  from pathlib import Path
27
28
 
@@ -170,14 +171,28 @@ if APP_MODE not in {"local", "public"}:
170
171
  IS_PUBLIC_MODE = APP_MODE == "public"
171
172
  DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
172
173
  DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
174
+ def _host_is_loopback(host: str) -> bool:
175
+ if host in {"localhost", "127.0.0.1", "::1"}:
176
+ return True
177
+ try:
178
+ return ipaddress.ip_address(host).is_loopback
179
+ except ValueError:
180
+ return False
181
+
182
+ NETWORK_EXPOSED = not _host_is_loopback(DEFAULT_HOST)
173
183
  ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
174
184
  ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
175
185
  AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
176
186
  MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
177
187
  ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
178
- REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE)
188
+ REQUIRE_AUTH = env_bool("LATTICEAI_REQUIRE_AUTH", default=IS_PUBLIC_MODE or NETWORK_EXPOSED)
179
189
  ALLOW_PLAINTEXT_API_KEYS = env_bool("LATTICEAI_ALLOW_PLAINTEXT_API_KEYS", default=False)
180
190
  CORS_ALLOW_NETWORK = env_bool("LATTICEAI_CORS_ALLOW_NETWORK", default=False)
191
+ CORS_EXTRA_ORIGINS = [
192
+ item.strip()
193
+ for item in env_value("LATTICEAI_CORS_ALLOWED_ORIGINS", "").split(",")
194
+ if item.strip()
195
+ ]
181
196
  PUBLIC_MODEL = env_value("LATTICEAI_PUBLIC_MODEL", env_value("LATTICEAI_DEFAULT_MODEL", "openai:gpt-4o-mini"))
182
197
  LOCAL_MODEL = env_value("LATTICEAI_LOCAL_MODEL", "mlx-community/gemma-4-26b-a4b-it-4bit")
183
198
  LOCAL_DRAFT_MODEL = env_value("LATTICEAI_LOCAL_DRAFT_MODEL", "")
@@ -1928,9 +1943,16 @@ async def lifespan(app: FastAPI):
1928
1943
 
1929
1944
  app = FastAPI(title=f"Lattice AI Server ({APP_MODE})", version="2.1.0", lifespan=lifespan)
1930
1945
 
1931
- CORS_ALLOWED_ORIGINS = ["http://localhost:4825", "http://127.0.0.1:4825"]
1946
+ CORS_ALLOWED_ORIGINS = [
1947
+ f"http://localhost:{DEFAULT_PORT}",
1948
+ f"http://127.0.0.1:{DEFAULT_PORT}",
1949
+ *CORS_EXTRA_ORIGINS,
1950
+ ]
1932
1951
  if CORS_ALLOW_NETWORK:
1933
- CORS_ALLOWED_ORIGINS = ["*"]
1952
+ CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS + [
1953
+ f"http://{DEFAULT_HOST}:{DEFAULT_PORT}",
1954
+ f"https://{DEFAULT_HOST}:{DEFAULT_PORT}",
1955
+ ]
1934
1956
 
1935
1957
  app.add_middleware(
1936
1958
  CORSMiddleware,
@@ -1948,9 +1970,8 @@ _ICONS_DIR = STATIC_DIR / "icons"
1948
1970
  if _ICONS_DIR.exists():
1949
1971
  app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1950
1972
  ensure_agent_root()
1951
- app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
1952
1973
 
1953
- OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=True)
1974
+ OPEN_REGISTRATION = env_bool("LATTICEAI_OPEN_REGISTRATION", default=not NETWORK_EXPOSED and not IS_PUBLIC_MODE)
1954
1975
 
1955
1976
  @app.post("/register")
1956
1977
  async def register(req: UserRegister, request: Request):
@@ -1993,7 +2014,6 @@ async def login(req: UserLogin, request: Request):
1993
2014
  "email": req.email,
1994
2015
  "role": role,
1995
2016
  "is_admin": role == "admin",
1996
- "token": token,
1997
2017
  })
1998
2018
  response.set_cookie(key="session_token", value=token, httponly=True, samesite="lax", max_age=_SESSION_TTL)
1999
2019
  return response
@@ -2459,6 +2479,7 @@ _pending_agents_lock = threading.Lock()
2459
2479
 
2460
2480
  class ToolPathRequest(BaseModel):
2461
2481
  path: str = "."
2482
+ approval_token: Optional[str] = None
2462
2483
 
2463
2484
 
2464
2485
  class ToolWriteFileRequest(BaseModel):
@@ -2556,12 +2577,14 @@ class ToolPdfRequest(BaseModel):
2556
2577
  class LocalAccessRequest(BaseModel):
2557
2578
  path: str
2558
2579
  approved: bool = False
2580
+ approval_token: Optional[str] = None
2559
2581
 
2560
2582
 
2561
2583
  class LocalWriteRequest(BaseModel):
2562
2584
  path: str
2563
2585
  content: str
2564
2586
  approved: bool = False
2587
+ approval_token: Optional[str] = None
2565
2588
 
2566
2589
 
2567
2590
  class McpCallRequest(BaseModel):
@@ -2588,7 +2611,7 @@ class ToolGitShowRequest(BaseModel):
2588
2611
 
2589
2612
  ENGINE_INSTALLERS = {
2590
2613
  "local_mlx": {
2591
- "command": [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
2614
+ "command": [sys.executable, "-m", "pip", "install", "--upgrade", "mlx-lm", "mlx-vlm", "huggingface_hub[cli]"],
2592
2615
  "label": "Install MLX runtime",
2593
2616
  },
2594
2617
  "openai": {
@@ -3980,18 +4003,19 @@ async def chat(req: ChatRequest, request: Request):
3980
4003
  if screenshot_context:
3981
4004
  context += f"\n\n{screenshot_context}"
3982
4005
 
3983
- # 메시지 안에 절대 경로나 ~/... 경로가 있으면 자동으로 파일 읽어서 컨텍스트 주입
3984
- _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
3985
- for _m in _file_path_re.finditer(req.message or ""):
3986
- _fpath = _m.group(1).strip()
3987
- try:
3988
- _result = local_read(_fpath)
3989
- _fcontent = _result.get("content", "")
3990
- if _fcontent:
3991
- context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
3992
- print(f"📂 Auto-injected file context: {_fpath}")
3993
- except Exception:
3994
- pass
4006
+ if env_bool("LATTICEAI_AUTO_READ_CHAT_PATHS", default=False):
4007
+ # Off by default: automatic local-file injection can leak files to cloud models.
4008
+ _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
4009
+ for _m in _file_path_re.finditer(req.message or ""):
4010
+ _fpath = _m.group(1).strip()
4011
+ try:
4012
+ _result = local_read(_fpath)
4013
+ _fcontent = _result.get("content", "")
4014
+ if _fcontent:
4015
+ context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
4016
+ print(f"📂 Auto-injected file context: {_fpath}")
4017
+ except Exception:
4018
+ pass
3995
4019
 
3996
4020
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
3997
4021
  save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
@@ -5313,14 +5337,17 @@ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
5313
5337
 
5314
5338
  @app.post("/tools/read_document")
5315
5339
  async def tools_read_document(req: ToolPathRequest, request: Request):
5316
- require_user(request)
5340
+ current_user = require_user(request)
5341
+ if Path(req.path).expanduser().is_absolute():
5342
+ _require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
5317
5343
  return _tool_response(read_document, req.path)
5318
5344
 
5319
5345
 
5320
5346
  @app.get("/tools/pdf_pages")
5321
- async def tools_pdf_pages(path: str, request: Request):
5347
+ async def tools_pdf_pages(path: str, request: Request, approval_token: Optional[str] = None):
5322
5348
  """Render PDF pages as base64 PNG images using PyMuPDF."""
5323
- require_user(request)
5349
+ current_user = require_user(request)
5350
+ _require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
5324
5351
  target = Path(path).expanduser().resolve()
5325
5352
  if not target.exists() or not target.is_file():
5326
5353
  raise HTTPException(status_code=404, detail="File not found")
@@ -5444,36 +5471,329 @@ _PERMISSION_ACTION_LABELS = {
5444
5471
  "write": "파일 쓰기",
5445
5472
  }
5446
5473
 
5447
- def _local_permission_response(path: str, action: str) -> dict:
5474
+ _LOCAL_APPROVAL_TTL_SECONDS = 5 * 60
5475
+ _local_approval_lock = threading.Lock()
5476
+ _local_approvals: Dict[str, Dict[str, object]] = {}
5477
+
5478
+ # Discord bot / webhook settings for permission notifications (optional)
5479
+ DISCORD_PERMISSION_WEBHOOK_URL = env_value("LATTICEAI_DISCORD_PERMISSION_WEBHOOK", "")
5480
+ DISCORD_BOT_TOKEN = env_value("LATTICEAI_DISCORD_BOT_TOKEN", "")
5481
+ DISCORD_PERMISSION_CHANNEL = env_value("LATTICEAI_DISCORD_PERMISSION_CHANNEL", "")
5482
+
5483
+ # Secret token that allows permission monitor script to call approve/deny endpoints
5484
+ # without an admin user session (used by perm_monitor.py).
5485
+ PERMISSION_MONITOR_SECRET = env_value("LATTICEAI_PERMISSION_SECRET", "")
5486
+
5487
+ # Local queue file — written by server, read by perm_monitor.py
5488
+ _PERM_QUEUE_FILE = DATA_DIR / "permission_queue.json"
5489
+
5490
+
5491
+ def _perm_queue_write(token: str, record: Dict[str, object]) -> None:
5492
+ """Append a permission request to the local queue file for the monitor script."""
5493
+ try:
5494
+ queue: Dict = {}
5495
+ if _PERM_QUEUE_FILE.exists():
5496
+ try:
5497
+ queue = json.loads(_PERM_QUEUE_FILE.read_text(encoding="utf-8"))
5498
+ except Exception:
5499
+ queue = {}
5500
+ queue[token] = {**record, "notified": False}
5501
+ _PERM_QUEUE_FILE.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
5502
+ except Exception as exc:
5503
+ logging.warning("perm_queue_write failed: %s", exc)
5504
+
5505
+
5506
+ def _perm_queue_remove(token: str) -> None:
5507
+ """Remove a token from the queue file after approval or denial."""
5508
+ try:
5509
+ if not _PERM_QUEUE_FILE.exists():
5510
+ return
5511
+ queue: Dict = json.loads(_PERM_QUEUE_FILE.read_text(encoding="utf-8"))
5512
+ queue.pop(token, None)
5513
+ _PERM_QUEUE_FILE.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8")
5514
+ except Exception as exc:
5515
+ logging.warning("perm_queue_remove failed: %s", exc)
5516
+
5517
+
5518
+ def _normalize_local_path_for_approval(path: str) -> str:
5519
+ return str(Path(path).expanduser().resolve())
5520
+
5521
+
5522
+ def _content_fingerprint(content: str = "") -> str:
5523
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
5524
+
5525
+
5526
+ def _notify_discord_permission_sync(token: str, path: str, action: str, user_email: str) -> None:
5527
+ """Fire-and-forget Discord bot/webhook notification for permission requests."""
5528
+ # Try Discord bot API first (sends to a specific channel), then fall back to webhook
5529
+ sent = False
5530
+ if DISCORD_BOT_TOKEN and DISCORD_PERMISSION_CHANNEL:
5531
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
5532
+ expires_at_iso = time.strftime(
5533
+ "%Y-%m-%d %H:%M:%S UTC",
5534
+ time.gmtime(time.time() + _LOCAL_APPROVAL_TTL_SECONDS),
5535
+ )
5536
+ msg = (
5537
+ f"🔐 **파일 접근 권한 요청**\n"
5538
+ f"**경로:** `{path}`\n"
5539
+ f"**작업:** {action_label}\n"
5540
+ f"**요청자:** {user_email}\n"
5541
+ f"**토큰:** `{token}`\n"
5542
+ f"**만료:** {expires_at_iso}\n\n"
5543
+ f"승인하려면 `승인 {token[:8]}` / 거부하려면 `거부 {token[:8]}` 라고 답장하세요."
5544
+ )
5545
+ payload = json.dumps({"content": msg}, ensure_ascii=False).encode("utf-8")
5546
+ try:
5547
+ req = urllib.request.Request(
5548
+ f"https://discord.com/api/v10/channels/{DISCORD_PERMISSION_CHANNEL}/messages",
5549
+ data=payload,
5550
+ headers={
5551
+ "Content-Type": "application/json",
5552
+ "Authorization": f"Bot {DISCORD_BOT_TOKEN}",
5553
+ },
5554
+ method="POST",
5555
+ )
5556
+ with urllib.request.urlopen(req, timeout=5):
5557
+ pass
5558
+ sent = True
5559
+ except Exception as exc:
5560
+ logging.warning("Discord bot permission notify failed: %s", exc)
5561
+
5562
+ if not sent and DISCORD_PERMISSION_WEBHOOK_URL:
5563
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
5564
+ expires_at_iso = time.strftime(
5565
+ "%Y-%m-%d %H:%M:%S UTC",
5566
+ time.gmtime(time.time() + _LOCAL_APPROVAL_TTL_SECONDS),
5567
+ )
5568
+ payload = json.dumps({
5569
+ "embeds": [
5570
+ {
5571
+ "title": "🔐 파일 접근 권한 요청",
5572
+ "color": 0xFF9900,
5573
+ "fields": [
5574
+ {"name": "경로", "value": f"`{path}`", "inline": False},
5575
+ {"name": "작업", "value": action_label, "inline": True},
5576
+ {"name": "요청자", "value": user_email, "inline": True},
5577
+ {"name": "토큰", "value": f"`{token}`", "inline": False},
5578
+ {"name": "만료", "value": expires_at_iso, "inline": True},
5579
+ ],
5580
+ "footer": {
5581
+ "text": (
5582
+ "승인: POST /permissions/approve/{token} | "
5583
+ "거부: POST /permissions/deny/{token} | "
5584
+ "목록: GET /permissions/pending"
5585
+ )
5586
+ },
5587
+ }
5588
+ ]
5589
+ }, ensure_ascii=False).encode("utf-8")
5590
+ try:
5591
+ req = urllib.request.Request(
5592
+ DISCORD_PERMISSION_WEBHOOK_URL,
5593
+ data=payload,
5594
+ headers={"Content-Type": "application/json"},
5595
+ method="POST",
5596
+ )
5597
+ with urllib.request.urlopen(req, timeout=5):
5598
+ pass
5599
+ except Exception as exc: # pylint: disable=broad-except
5600
+ logging.warning("Discord permission webhook failed: %s", exc)
5601
+
5602
+
5603
+ def _local_permission_response(path: str, action: str, user_email: str, content: str = "") -> dict:
5604
+ normalized = _normalize_local_path_for_approval(path)
5605
+ token = secrets.token_urlsafe(24)
5606
+ record: Dict[str, object] = {
5607
+ "path": normalized,
5608
+ "action": action,
5609
+ "user_email": user_email,
5610
+ "expires_at": time.time() + _LOCAL_APPROVAL_TTL_SECONDS,
5611
+ # approved=False until user explicitly confirms (Discord, web UI, etc.)
5612
+ "approved": False,
5613
+ }
5614
+ if action == "write":
5615
+ record["content_hash"] = _content_fingerprint(content)
5616
+ with _local_approval_lock:
5617
+ _local_approvals[token] = record
5618
+ # Write to local queue file — perm_monitor.py or Claude Code reads this
5619
+ # and relays the notification to Discord via the Discord MCP plugin.
5620
+ _perm_queue_write(token, record)
5621
+ action_label = _PERMISSION_ACTION_LABELS.get(action, action)
5448
5622
  return {
5449
5623
  "permission_required": True,
5450
5624
  "path": path,
5451
5625
  "action": action,
5452
- "action_label": _PERMISSION_ACTION_LABELS.get(action, action),
5453
- "message": f"AI가 '{path}' 에 대한 {_PERMISSION_ACTION_LABELS.get(action, action)} 권한을 요청합니다.",
5626
+ "action_label": action_label,
5627
+ "approval_token": token,
5628
+ "expires_in": _LOCAL_APPROVAL_TTL_SECONDS,
5629
+ "message": f"AI가 '{path}' 에 대한 {action_label} 권한을 요청합니다.",
5630
+ "check_status_url": f"/permissions/status/{token}",
5631
+ }
5632
+
5633
+
5634
+ def _require_local_approval(
5635
+ *,
5636
+ token: Optional[str],
5637
+ path: str,
5638
+ action: str,
5639
+ user_email: str,
5640
+ content: str = "",
5641
+ ) -> None:
5642
+ if not token:
5643
+ raise HTTPException(status_code=403, detail="파일 접근 승인 토큰이 필요합니다.")
5644
+ normalized = _normalize_local_path_for_approval(path)
5645
+ now = time.time()
5646
+ with _local_approval_lock:
5647
+ expired = [key for key, value in _local_approvals.items() if float(value.get("expires_at", 0)) < now]
5648
+ for key in expired:
5649
+ _local_approvals.pop(key, None)
5650
+ record = _local_approvals.get(token)
5651
+ if not record:
5652
+ raise HTTPException(status_code=403, detail="파일 접근 승인이 만료되었거나 유효하지 않습니다.")
5653
+ if not record.get("approved"):
5654
+ raise HTTPException(status_code=403, detail="파일 접근이 아직 승인되지 않았습니다. Discord 또는 UI에서 승인해주세요.")
5655
+ if record.get("user_email") != user_email:
5656
+ raise HTTPException(status_code=403, detail="다른 사용자의 파일 접근 승인은 사용할 수 없습니다.")
5657
+ if record.get("path") != normalized or record.get("action") != action:
5658
+ raise HTTPException(status_code=403, detail="파일 접근 승인 범위가 일치하지 않습니다.")
5659
+ if action == "write" and record.get("content_hash") != _content_fingerprint(content):
5660
+ raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
5661
+
5662
+
5663
+ # ── Permission management endpoints ──────────────────────────────────────────
5664
+
5665
+ @app.get("/permissions/pending")
5666
+ async def permissions_pending(request: Request):
5667
+ """List all pending (not yet approved) permission requests. Admin only."""
5668
+ require_admin(request)
5669
+ now = time.time()
5670
+ with _local_approval_lock:
5671
+ result = {}
5672
+ for tok, rec in list(_local_approvals.items()):
5673
+ expires_at = float(rec.get("expires_at", 0))
5674
+ if expires_at < now:
5675
+ continue
5676
+ result[tok] = {
5677
+ "path": rec.get("path"),
5678
+ "action": rec.get("action"),
5679
+ "action_label": _PERMISSION_ACTION_LABELS.get(str(rec.get("action", "")), str(rec.get("action", ""))),
5680
+ "user_email": rec.get("user_email"),
5681
+ "approved": bool(rec.get("approved")),
5682
+ "expires_in": round(expires_at - now),
5683
+ }
5684
+ return {"pending": result, "count": len(result)}
5685
+
5686
+
5687
+ def _check_permission_auth(request: Request, token: Optional[str] = None) -> None:
5688
+ """Allow access if requester is admin OR presents the LATTICEAI_PERMISSION_SECRET.
5689
+ Used by approve/deny endpoints so the permission monitor script can call them."""
5690
+ # Check secret header first (monitor script path)
5691
+ if PERMISSION_MONITOR_SECRET:
5692
+ auth_header = request.headers.get("Authorization", "")
5693
+ if auth_header == f"Bearer {PERMISSION_MONITOR_SECRET}":
5694
+ return # Authorized via secret
5695
+ if token:
5696
+ current_user = get_current_user(request)
5697
+ with _local_approval_lock:
5698
+ record = _local_approvals.get(token)
5699
+ if current_user and record and record.get("user_email") == current_user:
5700
+ return
5701
+ # Fall back to admin session
5702
+ require_admin(request)
5703
+
5704
+
5705
+ @app.post("/permissions/approve/{token}")
5706
+ async def permissions_approve(token: str, request: Request):
5707
+ """Approve a pending permission request. Admin or permission-monitor secret.
5708
+ Called by Discord (via Claude Code) or web UI after user confirmation."""
5709
+ _check_permission_auth(request, token)
5710
+ with _local_approval_lock:
5711
+ record = _local_approvals.get(token)
5712
+ if not record:
5713
+ raise HTTPException(status_code=404, detail="토큰이 없거나 만료되었습니다.")
5714
+ if float(record.get("expires_at", 0)) < time.time():
5715
+ _local_approvals.pop(token, None)
5716
+ raise HTTPException(status_code=410, detail="토큰이 만료되었습니다.")
5717
+ record["approved"] = True
5718
+ _perm_queue_remove(token)
5719
+ logging.info(
5720
+ "Permission approved: token=%s path=%s action=%s user=%s",
5721
+ token, record.get("path"), record.get("action"), record.get("user_email"),
5722
+ )
5723
+ return {
5724
+ "ok": True,
5725
+ "token": token,
5726
+ "path": record.get("path"),
5727
+ "action": record.get("action"),
5728
+ "user_email": record.get("user_email"),
5729
+ }
5730
+
5731
+
5732
+ @app.post("/permissions/deny/{token}")
5733
+ async def permissions_deny(token: str, request: Request):
5734
+ """Deny/revoke a pending permission request. Admin or permission-monitor secret."""
5735
+ _check_permission_auth(request, token)
5736
+ with _local_approval_lock:
5737
+ record = _local_approvals.pop(token, None)
5738
+ _perm_queue_remove(token)
5739
+ if not record:
5740
+ raise HTTPException(status_code=404, detail="토큰이 없거나 이미 처리되었습니다.")
5741
+ logging.info(
5742
+ "Permission denied: token=%s path=%s action=%s user=%s",
5743
+ token, record.get("path"), record.get("action"), record.get("user_email"),
5744
+ )
5745
+ return {
5746
+ "ok": True,
5747
+ "denied": True,
5748
+ "token": token,
5749
+ "path": record.get("path"),
5750
+ "action": record.get("action"),
5751
+ }
5752
+
5753
+
5754
+ @app.get("/permissions/status/{token}")
5755
+ async def permissions_status(token: str, request: Request):
5756
+ """Check approval status of a token. Used by AI agents to poll for approval."""
5757
+ require_user(request)
5758
+ now = time.time()
5759
+ with _local_approval_lock:
5760
+ record = _local_approvals.get(token)
5761
+ if not record:
5762
+ return {"status": "denied_or_expired", "token": token}
5763
+ if float(record.get("expires_at", 0)) < now:
5764
+ return {"status": "expired", "token": token}
5765
+ if record.get("approved"):
5766
+ return {"status": "approved", "token": token}
5767
+ return {
5768
+ "status": "pending",
5769
+ "token": token,
5770
+ "expires_in": round(float(record.get("expires_at", 0)) - now),
5454
5771
  }
5455
5772
 
5456
5773
 
5457
5774
  @app.post("/local/list")
5458
5775
  async def local_list_endpoint(req: LocalAccessRequest, request: Request):
5459
- require_user(request)
5776
+ current_user = require_user(request)
5460
5777
  if not req.approved:
5461
- return _local_permission_response(req.path, "list")
5778
+ return _local_permission_response(req.path, "list", current_user)
5779
+ _require_local_approval(token=req.approval_token, path=req.path, action="list", user_email=current_user)
5462
5780
  return _tool_response(local_list, req.path)
5463
5781
 
5464
5782
 
5465
5783
  @app.post("/local/read")
5466
5784
  async def local_read_endpoint(req: LocalAccessRequest, request: Request):
5467
- require_user(request)
5785
+ current_user = require_user(request)
5468
5786
  if not req.approved:
5469
- return _local_permission_response(req.path, "read")
5787
+ return _local_permission_response(req.path, "read", current_user)
5788
+ _require_local_approval(token=req.approval_token, path=req.path, action="read", user_email=current_user)
5470
5789
  return _tool_response(local_read, req.path)
5471
5790
 
5472
5791
 
5473
5792
  @app.get("/local/serve")
5474
- async def local_serve_file(path: str, request: Request):
5793
+ async def local_serve_file(path: str, request: Request, approval_token: Optional[str] = None):
5475
5794
  """Serve a local file (images etc.) directly for browser preview."""
5476
- require_user(request)
5795
+ current_user = require_user(request)
5796
+ _require_local_approval(token=approval_token, path=path, action="read", user_email=current_user)
5477
5797
  target = Path(path).expanduser().resolve()
5478
5798
  if not target.exists() or not target.is_file():
5479
5799
  raise HTTPException(status_code=404, detail="File not found")
@@ -5482,9 +5802,16 @@ async def local_serve_file(path: str, request: Request):
5482
5802
 
5483
5803
  @app.post("/local/write")
5484
5804
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
5485
- require_user(request)
5805
+ current_user = require_user(request)
5486
5806
  if not req.approved:
5487
- return _local_permission_response(req.path, "write")
5807
+ return _local_permission_response(req.path, "write", current_user, req.content)
5808
+ _require_local_approval(
5809
+ token=req.approval_token,
5810
+ path=req.path,
5811
+ action="write",
5812
+ user_email=current_user,
5813
+ content=req.content,
5814
+ )
5488
5815
  return _tool_response(local_write, req.path, req.content)
5489
5816
 
5490
5817
 
@@ -309,8 +309,6 @@
309
309
  const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
310
310
  function apiFetch(path, opts = {}) {
311
311
  const headers = { ...(opts.headers || {}) };
312
- const token = localStorage.getItem('ltcai_session_token') || '';
313
- if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
314
312
  return fetch(API_BASE + path, { credentials: 'include', ...opts, headers });
315
313
  }
316
314
 
@@ -443,7 +441,6 @@
443
441
  localStorage.setItem('ltcai_user_email', data.email);
444
442
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
445
443
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
446
- if (data.token) localStorage.setItem('ltcai_session_token', data.token);
447
444
  window.location.href = '/chat';
448
445
  } else {
449
446
  const data = await res.json().catch(() => ({}));
@@ -486,7 +483,6 @@
486
483
  localStorage.setItem('ltcai_user_email', data.email);
487
484
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
488
485
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
489
- if (data.token) localStorage.setItem('ltcai_session_token', data.token);
490
486
  window.location.href = '/chat';
491
487
  }
492
488
  });
package/static/admin.html CHANGED
@@ -829,8 +829,6 @@
829
829
 
830
830
  function apiFetch(path, options = {}) {
831
831
  const headers = { ...(options.headers || {}) };
832
- const token = localStorage.getItem('ltcai_session_token') || '';
833
- if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
834
832
  return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
835
833
  }
836
834
 
@@ -852,20 +850,17 @@
852
850
  sessionStorage.removeItem('ltcai_admin_handoff');
853
851
  let data;
854
852
  try { data = JSON.parse(raw); } catch { return; }
855
- const { email, nickname, is_admin, token } = data;
853
+ const { email, nickname, is_admin } = data;
856
854
  if (!email) return;
857
855
  localStorage.setItem('ltcai_user_email', email);
858
856
  if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
859
857
  if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
860
- if (token) localStorage.setItem('ltcai_session_token', token);
861
858
  }
862
859
 
863
860
  function adminHeaders() {
864
- const token = localStorage.getItem('ltcai_session_token') || '';
865
861
  return {
866
862
  'Content-Type': 'application/json',
867
863
  'X-Admin-Email': currentUserEmail(),
868
- ...(token ? { Authorization: `Bearer ${token}` } : {})
869
864
  };
870
865
  }
871
866
 
@@ -1450,7 +1445,6 @@
1450
1445
  localStorage.removeItem('ltcai_user_email');
1451
1446
  localStorage.removeItem('ltcai_user_nickname');
1452
1447
  localStorage.removeItem('ltcai_is_admin');
1453
- localStorage.removeItem('ltcai_session_token');
1454
1448
  window.location.href = '/';
1455
1449
  }
1456
1450
 
package/static/chat.html CHANGED
@@ -3805,8 +3805,6 @@
3805
3805
 
3806
3806
  function apiFetch(path, options = {}) {
3807
3807
  const headers = { ...(options.headers || {}) };
3808
- const token = localStorage.getItem('ltcai_session_token') || '';
3809
- if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
3810
3808
  return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
3811
3809
  }
3812
3810
 
@@ -3837,7 +3835,6 @@
3837
3835
  localStorage.removeItem('ltcai_user_email');
3838
3836
  localStorage.removeItem('ltcai_user_nickname');
3839
3837
  localStorage.removeItem('ltcai_is_admin');
3840
- localStorage.removeItem('ltcai_session_token');
3841
3838
  window.location.href = '/account';
3842
3839
  }
3843
3840
 
@@ -4089,11 +4086,9 @@
4089
4086
  }
4090
4087
 
4091
4088
  function adminHeaders() {
4092
- const token = localStorage.getItem('ltcai_session_token') || '';
4093
4089
  return {
4094
4090
  'Content-Type': 'application/json',
4095
4091
  'X-Admin-Email': currentUserEmail,
4096
- ...(token ? { Authorization: `Bearer ${token}` } : {})
4097
4092
  };
4098
4093
  }
4099
4094
 
@@ -4486,7 +4481,6 @@
4486
4481
  email: currentUserEmail || '',
4487
4482
  nickname: currentUserNickname || '',
4488
4483
  is_admin: isAdmin ? 'true' : 'false',
4489
- token: localStorage.getItem('ltcai_session_token') || '',
4490
4484
  }));
4491
4485
  window.location.href = `${API_BASE || ''}/admin`;
4492
4486
  }
@@ -4873,31 +4867,46 @@
4873
4867
  await localNav(parts.join('/') || '/');
4874
4868
  }
4875
4869
 
4876
- async function browseLocalPath(path) {
4877
- path = path ?? _localCurrentPath;
4878
- const resultEl = document.getElementById('local-browser-result');
4879
- resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4880
-
4881
- const probe = await apiFetch('/local/list', {
4870
+ async function getLocalApprovalToken(path, action = 'read', content = '') {
4871
+ const endpoint = action === 'write' ? '/local/write' : action === 'list' ? '/local/list' : '/local/read';
4872
+ const payload = action === 'write'
4873
+ ? { path, content, approved: false }
4874
+ : { path, approved: false };
4875
+ const probe = await apiFetch(endpoint, {
4882
4876
  method: 'POST',
4883
4877
  headers: { 'Content-Type': 'application/json' },
4884
- body: JSON.stringify({ path, approved: false })
4878
+ body: JSON.stringify(payload)
4885
4879
  });
4886
4880
  const probeData = await probe.json();
4881
+ if (!probeData.permission_required) return probeData.approval_token || '';
4882
+ const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
4883
+ if (!allowed) return '';
4884
+ const token = probeData.approval_token || '';
4885
+ if (!token) return '';
4886
+ const approval = await apiFetch(`/permissions/approve/${encodeURIComponent(token)}`, { method: 'POST' });
4887
+ if (!approval.ok) {
4888
+ const data = await approval.json().catch(() => ({}));
4889
+ throw new Error(data.detail || '파일 접근 승인 실패');
4890
+ }
4891
+ return token;
4892
+ }
4887
4893
 
4888
- if (probeData.permission_required) {
4889
- const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
4890
- if (!allowed) {
4891
- resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
4892
- return;
4893
- }
4894
+ async function browseLocalPath(path) {
4895
+ path = path ?? _localCurrentPath;
4896
+ const resultEl = document.getElementById('local-browser-result');
4897
+ resultEl.innerHTML = '<div class="sensitivity-preview">불러오는 중...</div>';
4898
+
4899
+ const approvalToken = await getLocalApprovalToken(path, 'list');
4900
+ if (!approvalToken) {
4901
+ resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
4902
+ return;
4894
4903
  }
4895
4904
 
4896
4905
  try {
4897
4906
  const res = await apiFetch('/local/list', {
4898
4907
  method: 'POST',
4899
4908
  headers: { 'Content-Type': 'application/json' },
4900
- body: JSON.stringify({ path, approved: true })
4909
+ body: JSON.stringify({ path, approved: true, approval_token: approvalToken })
4901
4910
  });
4902
4911
  const data = await res.json();
4903
4912
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
@@ -4945,13 +4954,18 @@
4945
4954
  const path = decodeURIComponent(encodedPath);
4946
4955
  const resultEl = document.getElementById('local-browser-result');
4947
4956
  const ext = path.split('.').pop().toLowerCase();
4957
+ const readApprovalToken = await getLocalApprovalToken(path, 'read');
4958
+ if (!readApprovalToken) {
4959
+ resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
4960
+ return;
4961
+ }
4962
+ const localServeUrl = `/local/serve?path=${encodeURIComponent(path)}&approval_token=${encodeURIComponent(readApprovalToken)}`;
4948
4963
 
4949
4964
  // 이미지 파일: 미리보기 + AI 전송
4950
4965
  if (IMAGE_EXTS.has(ext)) {
4951
- const safeEnc = encodeURIComponent(path);
4952
4966
  resultEl.innerHTML = `
4953
4967
  <div style="text-align:center;padding:12px">
4954
- <img src="/local/serve?path=${safeEnc}" alt="${escapeHtml(path.split('/').pop())}"
4968
+ <img src="${localServeUrl}" alt="${escapeHtml(path.split('/').pop())}"
4955
4969
  style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
4956
4970
  onerror="this.parentElement.innerHTML='<div style=color:var(--faint)>이미지 미리보기 불가</div>'">
4957
4971
  <div style="color:var(--faint);font-size:11px;margin-top:8px">${escapeHtml(path.split('/').pop())}</div>
@@ -4971,11 +4985,10 @@
4971
4985
 
4972
4986
  // 동영상: HTML5 플레이어
4973
4987
  if (VIDEO_EXTS.has(ext)) {
4974
- const safeEnc = encodeURIComponent(path);
4975
4988
  resultEl.innerHTML = `
4976
4989
  <div style="text-align:center;padding:8px">
4977
4990
  <video controls style="max-width:100%;max-height:280px;border-radius:8px;border:1px solid var(--border)"
4978
- src="/local/serve?path=${safeEnc}">지원하지 않는 형식</video>
4991
+ src="${localServeUrl}">지원하지 않는 형식</video>
4979
4992
  <div style="color:var(--faint);font-size:11px;margin-top:6px">${escapeHtml(path.split('/').pop())}</div>
4980
4993
  <button class="status-btn" style="font-size:12px;margin-top:8px"
4981
4994
  onclick="sendArchiveToChat('${path.replace(/'/g,"\\'")}','video')">
@@ -4987,11 +5000,10 @@
4987
5000
 
4988
5001
  // PDF: 브라우저 내장 뷰어 + AI 전송 (페이지 이미지 + 텍스트)
4989
5002
  if (ext === 'pdf') {
4990
- const safeEnc = encodeURIComponent(path);
4991
5003
  const safePath = path.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
4992
5004
  resultEl.innerHTML = `
4993
5005
  <div style="display:flex;flex-direction:column;gap:10px">
4994
- <embed src="/local/serve?path=${safeEnc}#toolbar=0"
5006
+ <embed src="${localServeUrl}#toolbar=0"
4995
5007
  type="application/pdf"
4996
5008
  style="width:100%;height:340px;border-radius:8px;border:1px solid var(--border)">
4997
5009
  <div style="display:flex;gap:8px">
@@ -5016,7 +5028,7 @@
5016
5028
  const res = await apiFetch('/tools/read_document', {
5017
5029
  method: 'POST',
5018
5030
  headers: { 'Content-Type': 'application/json' },
5019
- body: JSON.stringify({ path })
5031
+ body: JSON.stringify({ path, approval_token: readApprovalToken })
5020
5032
  });
5021
5033
  const data = await res.json();
5022
5034
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '읽기 실패');
@@ -5062,25 +5074,11 @@
5062
5074
 
5063
5075
  resultEl.innerHTML = '<div class="sensitivity-preview">읽는 중...</div>';
5064
5076
 
5065
- const probe = await apiFetch('/local/read', {
5066
- method: 'POST',
5067
- headers: { 'Content-Type': 'application/json' },
5068
- body: JSON.stringify({ path, approved: false })
5069
- });
5070
- const probeData = await probe.json();
5071
- if (probeData.permission_required) {
5072
- const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
5073
- if (!allowed) {
5074
- resultEl.innerHTML = '<div class="sensitivity-preview">접근이 거부되었습니다.</div>';
5075
- return;
5076
- }
5077
- }
5078
-
5079
5077
  try {
5080
5078
  const res = await apiFetch('/local/read', {
5081
5079
  method: 'POST',
5082
5080
  headers: { 'Content-Type': 'application/json' },
5083
- body: JSON.stringify({ path, approved: true })
5081
+ body: JSON.stringify({ path, approved: true, approval_token: readApprovalToken })
5084
5082
  });
5085
5083
  const data = await res.json();
5086
5084
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
@@ -5108,22 +5106,18 @@
5108
5106
  statusEl.style.color = 'var(--accent)';
5109
5107
  statusEl.textContent = '저장 중...';
5110
5108
 
5111
- const probe = await apiFetch('/local/write', {
5112
- method: 'POST',
5113
- headers: { 'Content-Type': 'application/json' },
5114
- body: JSON.stringify({ path, content, approved: false })
5115
- });
5116
- const probeData = await probe.json();
5117
- if (probeData.permission_required) {
5118
- const allowed = await requestPermission(probeData.path, probeData.action, probeData.action_label);
5119
- if (!allowed) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = '저장 취소됨'; return; }
5109
+ const approvalToken = await getLocalApprovalToken(path, 'write', content);
5110
+ if (!approvalToken) {
5111
+ statusEl.style.color = 'var(--danger)';
5112
+ statusEl.textContent = '저장 취소됨';
5113
+ return;
5120
5114
  }
5121
5115
 
5122
5116
  try {
5123
5117
  const res = await apiFetch('/local/write', {
5124
5118
  method: 'POST',
5125
5119
  headers: { 'Content-Type': 'application/json' },
5126
- body: JSON.stringify({ path, content, approved: true })
5120
+ body: JSON.stringify({ path, content, approved: true, approval_token: approvalToken })
5127
5121
  });
5128
5122
  const data = await res.json();
5129
5123
  if (!res.ok || data.error) throw new Error(data.error || data.detail || '오류');
@@ -5140,8 +5134,10 @@
5140
5134
  const statusEl = document.getElementById('pdf-send-status');
5141
5135
  if (statusEl) statusEl.textContent = '페이지 렌더링 중...';
5142
5136
  try {
5137
+ const approvalToken = await getLocalApprovalToken(path, 'read');
5138
+ if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
5143
5139
  // 1. 페이지 이미지 가져오기
5144
- const res = await apiFetch('/tools/pdf_pages?path=' + encodeURIComponent(path));
5140
+ const res = await apiFetch('/tools/pdf_pages?path=' + encodeURIComponent(path) + '&approval_token=' + encodeURIComponent(approvalToken));
5145
5141
  const data = await res.json();
5146
5142
  const pages = data.pages || [];
5147
5143
 
@@ -5149,7 +5145,7 @@
5149
5145
  const textRes = await apiFetch('/tools/read_document', {
5150
5146
  method: 'POST',
5151
5147
  headers: { 'Content-Type': 'application/json' },
5152
- body: JSON.stringify({ path })
5148
+ body: JSON.stringify({ path, approval_token: approvalToken })
5153
5149
  });
5154
5150
  const textData = await textRes.json();
5155
5151
  const text = (textData.result ?? textData).content ?? '';
@@ -5177,10 +5173,12 @@
5177
5173
  const resultEl = document.getElementById('local-browser-result');
5178
5174
  resultEl.innerHTML = '<div class="sensitivity-preview">텍스트 추출 중...</div>';
5179
5175
  try {
5176
+ const approvalToken = await getLocalApprovalToken(path, 'read');
5177
+ if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
5180
5178
  const res = await apiFetch('/tools/read_document', {
5181
5179
  method: 'POST',
5182
5180
  headers: { 'Content-Type': 'application/json' },
5183
- body: JSON.stringify({ path })
5181
+ body: JSON.stringify({ path, approval_token: approvalToken })
5184
5182
  });
5185
5183
  const data = await res.json();
5186
5184
  const text = (data.result ?? data).content ?? '';
@@ -5210,7 +5208,9 @@
5210
5208
 
5211
5209
  async function sendImageFileToChat(path) {
5212
5210
  try {
5213
- const res = await apiFetch('/local/serve?path=' + encodeURIComponent(path));
5211
+ const approvalToken = await getLocalApprovalToken(path, 'read');
5212
+ if (!approvalToken) throw new Error('파일 접근이 거부되었습니다.');
5213
+ const res = await apiFetch('/local/serve?path=' + encodeURIComponent(path) + '&approval_token=' + encodeURIComponent(approvalToken));
5214
5214
  if (!res.ok) throw new Error('이미지 로드 실패');
5215
5215
  const blob = await res.blob();
5216
5216
  closeLocalBrowser();
@@ -6813,13 +6813,14 @@
6813
6813
  <div class="rec-grid ${colClass || ''}">${cards}</div>`;
6814
6814
  };
6815
6815
 
6816
+ html += renderSection('필수 구성요소', recs.components);
6816
6817
  html += renderSection('엔진 (로컬 · 클라우드)', recs.engines);
6817
6818
  html += renderSection('모델 — 로컬 실행 (RAM 기준 필터)', recs.models);
6818
6819
  html += renderSection('MCP — 도구 연결', recs.mcps);
6819
6820
 
6820
6821
  _body().innerHTML = html;
6821
6822
 
6822
- const allItems = [...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
6823
+ const allItems = [...(recs.components||[]), ...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
6823
6824
  _wizItems = allItems.filter(i => i.checked && !i.disabled);
6824
6825
 
6825
6826
  _footInfo('');
@@ -6828,7 +6829,7 @@
6828
6829
 
6829
6830
  function _toggleItem(id) {
6830
6831
  const recs = _wizRecs;
6831
- const allItems = [...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
6832
+ const allItems = [...(recs.components||[]), ...(recs.engines||[]), ...(recs.models||[]), ...(recs.mcps||[])];
6832
6833
  const item = allItems.find(i => i.id === id);
6833
6834
  if (!item || item.disabled) return;
6834
6835
 
package/static/graph.html CHANGED
@@ -672,16 +672,11 @@
672
672
  let searchAbortController = null;
673
673
  let searchDebounceId = null;
674
674
 
675
- function authHeaders() {
676
- const token = localStorage.getItem('ltcai_session_token') || '';
677
- return token ? { Authorization: `Bearer ${token}` } : {};
678
- }
679
-
680
675
  function apiFetch(path, opts = {}) {
681
676
  return fetch(`${API_BASE}${path}`, {
682
677
  credentials: 'include',
683
678
  ...opts,
684
- headers: { ...authHeaders(), ...(opts.headers || {}) },
679
+ headers: { ...(opts.headers || {}) },
685
680
  });
686
681
  }
687
682
 
@@ -13,6 +13,9 @@ from server import (
13
13
  hash_password,
14
14
  verify_password,
15
15
  _agent_risk,
16
+ _host_is_loopback,
17
+ _local_permission_response,
18
+ _require_local_approval,
16
19
  _LOCAL_WRITE_BLOCKED_PREFIXES,
17
20
  )
18
21
  from fastapi import HTTPException
@@ -123,3 +126,68 @@ def test_agent_risk_system_path_write_upgraded_to_high():
123
126
 
124
127
  def test_agent_risk_unknown_action_defaults_medium():
125
128
  assert _agent_risk("nonexistent_tool_xyz", {}) == "medium"
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Network exposure / local file approvals
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def test_host_loopback_detection():
136
+ assert _host_is_loopback("127.0.0.1")
137
+ assert _host_is_loopback("localhost")
138
+ assert not _host_is_loopback("0.0.0.0")
139
+ assert not _host_is_loopback("192.168.0.2")
140
+
141
+
142
+ def test_local_approval_token_allows_exact_scope(tmp_path):
143
+ target = tmp_path / "note.txt"
144
+ target.write_text("hello")
145
+ user = "alice@example.com"
146
+ approval = _local_permission_response(str(target), "read", user)
147
+ from server import _local_approvals
148
+ _local_approvals[approval["approval_token"]]["approved"] = True
149
+
150
+ _require_local_approval(
151
+ token=approval["approval_token"],
152
+ path=str(target),
153
+ action="read",
154
+ user_email=user,
155
+ )
156
+
157
+
158
+ def test_local_approval_token_rejects_wrong_path(tmp_path):
159
+ allowed = tmp_path / "allowed.txt"
160
+ denied = tmp_path / "denied.txt"
161
+ allowed.write_text("allowed")
162
+ denied.write_text("denied")
163
+ user = "alice@example.com"
164
+ approval = _local_permission_response(str(allowed), "read", user)
165
+ from server import _local_approvals
166
+ _local_approvals[approval["approval_token"]]["approved"] = True
167
+
168
+ with pytest.raises(HTTPException) as exc:
169
+ _require_local_approval(
170
+ token=approval["approval_token"],
171
+ path=str(denied),
172
+ action="read",
173
+ user_email=user,
174
+ )
175
+ assert exc.value.status_code == 403
176
+
177
+
178
+ def test_local_write_approval_binds_content(tmp_path):
179
+ target = tmp_path / "out.txt"
180
+ user = "alice@example.com"
181
+ approval = _local_permission_response(str(target), "write", user, "first")
182
+ from server import _local_approvals
183
+ _local_approvals[approval["approval_token"]]["approved"] = True
184
+
185
+ with pytest.raises(HTTPException) as exc:
186
+ _require_local_approval(
187
+ token=approval["approval_token"],
188
+ path=str(target),
189
+ action="write",
190
+ user_email=user,
191
+ content="changed",
192
+ )
193
+ assert exc.value.status_code == 403
@@ -0,0 +1,35 @@
1
+ import os
2
+ import shutil
3
+
4
+ import setup
5
+
6
+
7
+ def test_scan_environment_includes_components_and_paths():
8
+ env = setup.scan_environment()
9
+
10
+ assert "components" in env
11
+ assert "python" in env["components"]
12
+ assert "official_url" in env["components"]["python"]
13
+ assert "path" in env
14
+ assert "active" in env["path"]
15
+
16
+
17
+ def test_recommendations_include_components():
18
+ env = setup.scan_environment()
19
+ recs = setup.get_recommendations(env)
20
+
21
+ assert "components" in recs
22
+ assert any(item["id"].startswith("component_") for item in recs["components"])
23
+
24
+
25
+ def test_repair_path_can_find_binary_from_common_dir(monkeypatch):
26
+ python_path = shutil.which("python3") or shutil.which("python")
27
+ assert python_path
28
+ python_dir = os.path.dirname(python_path)
29
+
30
+ monkeypatch.setattr(setup, "COMMON_PATH_DIRS", [python_dir])
31
+ monkeypatch.setenv("PATH", "")
32
+
33
+ setup.repair_path_for("python3" if python_path.endswith("python3") else "python")
34
+
35
+ assert python_dir in os.environ["PATH"].split(os.pathsep)