ArunKr commited on
Commit
1cab6ff
·
verified ·
1 Parent(s): a896a8b

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -1,85 +1,49 @@
1
- # Autonomy Labs — Agent Notes
2
 
3
- This repo powers a FastAPI web app deployed to Hugging Face Spaces (Docker). It includes:
4
- - a Supabase-backed login + user data
5
- - a chat UI (OpenAI-compatible providers)
6
- - a PTY-backed web terminal over WebSockets
7
- - optional Codex CLI/SDK integration
8
- - optional MCP tooling integration
9
 
10
- ## Non-negotiables
11
- - **Never commit secrets** (API keys, tokens, cookies, private SSH keys). Use env vars / HF Spaces Secrets.
12
- - **Assume hostile clients**: UI checks are not security. Dangerous endpoints must be protected server-side.
13
- - **Prefer minimal, reversible changes** with clear validation steps.
 
14
 
15
- ## Repo map
16
- - `main.py`: FastAPI backend (currently a single large file).
17
- - `static/index.html`: login page (Supabase Auth).
18
- - `static/dashboard.html`: main UI (chat, terminal, agent mode, notes, settings UI).
19
- - `docker-entrypoint.sh`: runtime setup (persistence under `/data`, writes Codex auth files from env if provided).
20
- - `Dockerfile`: image build (Python + Node + CLIs).
21
- - `codex_agent.mjs`: Node wrapper around `@openai/codex-sdk`.
22
- - `.github/workflows/deploy.yml`: deploy to Hugging Face Space.
23
- - `.github/workflows/codex-autofix.yml`: optional GitHub Action to run Codex for autofixes.
24
 
25
- ## How to run (local)
26
- - Python deps: `pip install -r requirements.txt`
27
- - Start: `uvicorn main:app --host 0.0.0.0 --port 7860`
28
- - Open: `http://localhost:7860`
 
29
 
30
- ## Key environment variables
31
- ### Supabase
32
- - `SUPABASE_URL`
33
- - `SUPABASE_KEY` (anon key used by the frontend)
34
 
35
- ### Chat defaults (UI convenience)
36
- - `DEFAULT_BASE_URL` (e.g. `https://router.huggingface.co/v1`)
37
- - `DEFAULT_API_KEY` (avoid using in production; don’t commit)
38
- - `DEFAULT_MODEL`
39
 
40
- ### Codex (HF Spaces Secrets recommended)
41
- Supported token env names (either set works):
42
- - `CODEX_ID_TOKEN` or `ID_TOKEN`
43
- - `CODEX_ACCESS_TOKEN` or `ACCESS_TOKEN`
44
- - `CODEX_REFRESH_TOKEN` or `REFRESH_TOKEN`
45
- - optional: `CODEX_ACCOUNT_ID` or `ACCOUNT_ID`
46
 
47
- At runtime, the container writes:
48
- - `~/.codex/.auth.json`
49
- - `~/.codex/auth.json`
50
 
51
- ### Gemini / Claude
52
- Prefer env-based auth (keep tokens out of the UI and git):
53
- - Gemini: typically `GEMINI_API_KEY` (or Google GenAI envs, depending on mode)
54
- - Claude: typically `ANTHROPIC_API_KEY`
55
 
56
- If adding “Codex-like” auth files for these CLIs, document **exact paths + formats** and keep them **generated at runtime** from Secrets.
57
 
58
- ### SSH (optional)
59
- Default behavior: container may generate `~/.ssh/id_ed25519` and persist to `/data/.ssh` (Spaces).
60
- If adding SSH-from-secrets support, prefer:
61
- - `SSH_PRIVATE_KEY` (+ optional `SSH_PUBLIC_KEY`, `SSH_KNOWN_HOSTS`)
62
- and ensure files are `0600`, never logged.
63
 
64
- ## Security posture (important)
65
- The web terminal and any agent/Codex/MCP execution is effectively remote code execution if exposed.
66
 
67
- Before shipping features, ensure:
68
- - server-side auth checks for `/ws/terminal` and all `/api/codex*` + `/api/mcp*` endpoints
69
- - explicit capability flags (e.g. `ENABLE_TERMINAL`, `ENABLE_CODEX`, `ENABLE_MCP`) with safe defaults
70
- - rate limiting / abuse controls if public
71
-
72
- ## Development hygiene
73
- - Prefer extracting modules from `main.py` rather than growing it further.
74
- - Prefer moving large inline JS/CSS out of `static/dashboard.html` into `static/*.js` + `static/*.css`.
75
- - Keep UI theme tokens consistent between login and dashboard.
76
-
77
- ## Quick validation
78
- - Python syntax: `python3 -m py_compile main.py`
79
- - Basic endpoint check: `curl -sSf http://localhost:7860/health`
80
 
81
  ## Deployment notes (HF Spaces)
82
  - Port: `7860`
83
  - Persistence: `/data` is used for `~/.codex`, `~/.ssh`, and a default workspace directory when available.
84
  - Web terminals often require device auth flows; avoid localhost callback assumptions.
85
-
 
1
+ # Repository Guidelines
2
 
3
+ ## Project Structure & Module Organization
 
 
 
 
 
4
 
5
+ - `main.py`: app entrypoint (loads dotenv, creates FastAPI app).
6
+ - `app/`: backend package; feature routers live in `app/routes/` (chat, codex, mcp, terminal, user, admin, rag).
7
+ - `static/`: frontend assets (`landing.html`, `index.html`, `dashboard.html` + `dashboard.js`/`dashboard.css`).
8
+ - `tests/`: pytest suite (security-gate coverage is especially important).
9
+ - `docs/`: architecture and ops notes (start with `docs/ARCHITECTURE.md`).
10
 
11
+ ## Build, Test, and Development Commands
 
 
 
 
 
 
 
 
12
 
13
+ ```bash
14
+ python -m venv .venv && source .venv/bin/activate
15
+ pip install -r requirements.txt -r requirements-dev.txt
16
+ uvicorn main:app --reload --host 0.0.0.0 --port 7860
17
+ ```
18
 
19
+ - Lint/format: `ruff check .` and `ruff format .`
20
+ - Tests: `pytest -q`
21
+ - Docker (Spaces-like): `docker build -t autonomy-labs . && docker run --rm -p 7860:7860 --env-file .env autonomy-labs`
 
22
 
23
+ ## Coding Style & Naming Conventions
 
 
 
24
 
25
+ - Python 3.11+, 4-space indentation; prefer explicit types for API payloads and settings.
26
+ - Keep modules small: add new routes under `app/routes/<feature>.py` instead of growing large files.
27
+ - Ruff is the source of truth (line length is 120); fix lint before pushing.
 
 
 
28
 
29
+ ## Testing Guidelines
 
 
30
 
31
+ - Use `pytest` and keep tests fast and isolated (set env via `monkeypatch`).
32
+ - Name tests `tests/test_<topic>.py` and add coverage for:
33
+ - auth enforcement on dangerous endpoints (`/ws/terminal`, `/api/codex*`, `/api/mcp*`)
34
+ - feature flags (e.g., `ENABLE_CODEX=0` should hard-disable routes).
35
 
36
+ ## Commit & Pull Request Guidelines
37
 
38
+ - Commits use short, imperative subjects (e.g., “Add RAG document indexing MVP”), no required prefixes.
39
+ - PRs should include: summary, how you tested (`pytest -q`, `ruff check .`), screenshots for UI changes, and any new env vars documented in `.env.example`.
 
 
 
40
 
41
+ ## Security & Configuration Tips
 
42
 
43
+ - Never commit secrets; use environment variables / Hugging Face Spaces Secrets.
44
+ - High-risk features are gated by Supabase auth and flags (`ENABLE_TERMINAL`, `ENABLE_CODEX`, `ENABLE_MCP`, `ENABLE_INDEXING`). Keep defaults conservative and document changes in `SECURITY.md`.
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  ## Deployment notes (HF Spaces)
47
  - Port: `7860`
48
  - Persistence: `/data` is used for `~/.codex`, `~/.ssh`, and a default workspace directory when available.
49
  - Web terminals often require device auth flows; avoid localhost callback assumptions.
 
TASKS.md CHANGED
@@ -43,7 +43,9 @@ Legend:
43
  ## P2/P3 — MCP registry
44
  - [~] First-class MCP registry storage (per-user persistence via backend).
45
  - [~] Admin-managed MCP templates (server-side persisted).
46
- - [~] “Test connection”, “list tools”, tool allow/deny UI (SSRF-safe).
 
 
47
  - [x] Import/export `mcp.json` via UI with validation.
48
 
49
  ## P3 — RAG + indexing (docs/web/GitHub) + “password manager”
 
43
  ## P2/P3 — MCP registry
44
  - [~] First-class MCP registry storage (per-user persistence via backend).
45
  - [~] Admin-managed MCP templates (server-side persisted).
46
+ - [~] “Test connection” (browser/CORS; SSRF-safe server proxy pending).
47
+ - [x] “List tools” (via `/api/mcp/tools`).
48
+ - [x] Tool allow/deny policy (UI + server-side enforcement).
49
  - [x] Import/export `mcp.json` via UI with validation.
50
 
51
  ## P3 — RAG + indexing (docs/web/GitHub) + “password manager”
app/mcp_policy.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from app.storage import user_data_dir
10
+
11
+
12
+ def _policy_path(user_id: str) -> Path:
13
+ return user_data_dir(user_id) / "mcp-policy.json"
14
+
15
+
16
+ def load_mcp_policy(user_id: str) -> dict[str, Any]:
17
+ path = _policy_path(user_id)
18
+ try:
19
+ data = json.loads(path.read_text(encoding="utf-8"))
20
+ if not isinstance(data, dict):
21
+ raise ValueError("Invalid policy")
22
+ allow = data.get("allow")
23
+ deny = data.get("deny")
24
+ return {
25
+ "version": int(data.get("version") or 1),
26
+ "allow": allow if isinstance(allow, list) else [],
27
+ "deny": deny if isinstance(deny, list) else [],
28
+ }
29
+ except FileNotFoundError:
30
+ return {"version": 1, "allow": [], "deny": []}
31
+ except Exception as e:
32
+ raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
33
+
34
+
35
+ def save_mcp_policy(user_id: str, policy: dict[str, Any]) -> dict[str, Any]:
36
+ allow_in = policy.get("allow")
37
+ deny_in = policy.get("deny")
38
+
39
+ allow = [str(x).strip() for x in (allow_in or []) if str(x).strip()] if isinstance(allow_in, list) else []
40
+ deny = [str(x).strip() for x in (deny_in or []) if str(x).strip()] if isinstance(deny_in, list) else []
41
+
42
+ allow = list(dict.fromkeys(allow))[:500]
43
+ deny = list(dict.fromkeys(deny))[:500]
44
+
45
+ payload = {"version": int(policy.get("version") or 1), "allow": allow, "deny": deny}
46
+ path = _policy_path(user_id)
47
+ try:
48
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
49
+ return payload
50
+ except Exception as e:
51
+ raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
52
+
53
+
54
+ def tool_allowed(tool_name: str, policy: dict[str, Any]) -> bool:
55
+ name = (tool_name or "").strip()
56
+ if not name:
57
+ return False
58
+ deny = policy.get("deny") if isinstance(policy.get("deny"), list) else []
59
+ allow = policy.get("allow") if isinstance(policy.get("allow"), list) else []
60
+ deny_set = {str(x).strip() for x in deny if str(x).strip()}
61
+ allow_set = {str(x).strip() for x in allow if str(x).strip()}
62
+ if name in deny_set:
63
+ return False
64
+ if allow_set and name not in allow_set:
65
+ return False
66
+ return True
67
+
app/routes/mcp.py CHANGED
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Request
4
  from pydantic import BaseModel
5
 
6
  from app.auth import require_user_from_request
 
7
  from app.settings import feature_enabled
8
 
9
  router = APIRouter()
@@ -13,10 +14,26 @@ router = APIRouter()
13
  async def mcp_tools_list(http_request: Request):
14
  if not feature_enabled("mcp"):
15
  raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
16
- _ = await require_user_from_request(http_request)
 
17
  try:
 
18
  result = await http_request.app.state.codex_mcp_client.list_tools()
19
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  except Exception as e:
21
  raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
22
 
@@ -30,8 +47,17 @@ class McpCallRequest(BaseModel):
30
  async def mcp_tools_call(request: McpCallRequest, http_request: Request):
31
  if not feature_enabled("mcp"):
32
  raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
33
- _ = await require_user_from_request(http_request)
 
34
  try:
 
 
 
 
 
 
35
  return await http_request.app.state.codex_mcp_client.call_tool(request.name, request.arguments)
 
 
36
  except Exception as e:
37
  raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
 
4
  from pydantic import BaseModel
5
 
6
  from app.auth import require_user_from_request
7
+ from app.mcp_policy import load_mcp_policy, tool_allowed
8
  from app.settings import feature_enabled
9
 
10
  router = APIRouter()
 
14
  async def mcp_tools_list(http_request: Request):
15
  if not feature_enabled("mcp"):
16
  raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
17
+ user = await require_user_from_request(http_request)
18
+ user_id = str(user.get("id") or "")
19
  try:
20
+ policy = load_mcp_policy(user_id)
21
  result = await http_request.app.state.codex_mcp_client.list_tools()
22
+ tools = None
23
+ if isinstance(result, dict) and isinstance(result.get("tools"), list):
24
+ tools = result.get("tools")
25
+ if tools is None:
26
+ return result
27
+ filtered = []
28
+ for t in tools:
29
+ if not isinstance(t, dict):
30
+ continue
31
+ name = str(t.get("name") or "").strip()
32
+ if tool_allowed(name, policy):
33
+ filtered.append(t)
34
+ return {**result, "tools": filtered}
35
+ except HTTPException:
36
+ raise
37
  except Exception as e:
38
  raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
39
 
 
47
  async def mcp_tools_call(request: McpCallRequest, http_request: Request):
48
  if not feature_enabled("mcp"):
49
  raise HTTPException(status_code=403, detail={"code": "feature_disabled", "message": "MCP is disabled"})
50
+ user = await require_user_from_request(http_request)
51
+ user_id = str(user.get("id") or "")
52
  try:
53
+ policy = load_mcp_policy(user_id)
54
+ if not tool_allowed(request.name, policy):
55
+ raise HTTPException(
56
+ status_code=403,
57
+ detail={"code": "tool_denied", "message": f"MCP tool not allowed: {request.name}"},
58
+ )
59
  return await http_request.app.state.codex_mcp_client.call_tool(request.name, request.arguments)
60
+ except HTTPException:
61
+ raise
62
  except Exception as e:
63
  raise HTTPException(status_code=500, detail={"code": "mcp_error", "message": str(e)}) from e
app/routes/user.py CHANGED
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request
8
  from pydantic import BaseModel
9
 
10
  from app.auth import require_user_from_request
 
11
  from app.settings import feature_enabled
12
  from app.storage import user_data_dir
13
 
@@ -93,3 +94,24 @@ async def put_mcp_registry(body: McpRegistry, http_request: Request):
93
  return {"ok": True, "count": len(servers)}
94
  except Exception as e:
95
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from pydantic import BaseModel
9
 
10
  from app.auth import require_user_from_request
11
+ from app.mcp_policy import load_mcp_policy, save_mcp_policy
12
  from app.settings import feature_enabled
13
  from app.storage import user_data_dir
14
 
 
94
  return {"ok": True, "count": len(servers)}
95
  except Exception as e:
96
  raise HTTPException(status_code=500, detail={"code": "internal_error", "message": str(e)}) from e
97
+
98
+
99
+ class McpPolicy(BaseModel):
100
+ version: int = 1
101
+ allow: list[str] = []
102
+ deny: list[str] = []
103
+
104
+
105
+ @router.get("/api/user/mcp-policy")
106
+ async def get_mcp_policy(http_request: Request):
107
+ user = await require_user_from_request(http_request)
108
+ user_id = str(user.get("id") or "")
109
+ return load_mcp_policy(user_id)
110
+
111
+
112
+ @router.put("/api/user/mcp-policy")
113
+ async def put_mcp_policy(body: McpPolicy, http_request: Request):
114
+ user = await require_user_from_request(http_request)
115
+ user_id = str(user.get("id") or "")
116
+ saved = save_mcp_policy(user_id, body.model_dump())
117
+ return {"ok": True, "policy": saved}
docs/TROUBLESHOOTING.md CHANGED
@@ -37,6 +37,7 @@ The Settings → MCP “Test” button runs from your browser, so it is subject
37
 
38
  Also note:
39
  - `mcp.json` import only accepts `http://` / `https://` URLs.
 
40
 
41
  ## API errors are shown as JSON
42
 
 
37
 
38
  Also note:
39
  - `mcp.json` import only accepts `http://` / `https://` URLs.
40
+ - If MCP tool calls are blocked, check Settings → MCP → Tool Policy (server-enforced allow/deny list).
41
 
42
  ## API errors are shown as JSON
43
 
static/dashboard.html CHANGED
@@ -384,6 +384,35 @@
384
  <button onclick="saveMcpRegistryToServer()"
385
  class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Save (server)</button>
386
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  <div class="flex gap-2 mb-3">
388
  <input id="mcp-direct-url" type="text" placeholder="Direct MCP URL (https://...)"
389
  class="flex-grow bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500">
 
384
  <button onclick="saveMcpRegistryToServer()"
385
  class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Save (server)</button>
386
  </div>
387
+ <div class="bg-gray-900/30 border border-gray-700 rounded-lg p-3 mb-3 space-y-2">
388
+ <div class="flex items-center justify-between gap-2">
389
+ <div class="text-xs font-semibold text-gray-300 uppercase">Tool Policy</div>
390
+ <div class="flex gap-2">
391
+ <button onclick="loadMcpPolicyFromServer()"
392
+ class="bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded text-xs">Load</button>
393
+ <button onclick="saveMcpPolicyToServer()"
394
+ class="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 rounded text-xs">Save</button>
395
+ </div>
396
+ </div>
397
+ <div class="text-xs text-gray-400">Optional allow/deny list enforced server-side for <code>/api/mcp/call</code> and tool listing.</div>
398
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
399
+ <div>
400
+ <label class="block text-xs font-semibold text-gray-400 mb-1 uppercase">Allow tools (one per line)</label>
401
+ <textarea id="mcp-allow-tools" rows="3"
402
+ class="w-full bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500 font-mono"
403
+ placeholder="filesystem.read\nfilesystem.write"></textarea>
404
+ <div class="text-xs text-gray-500 mt-1">If non-empty, only these tools are allowed.</div>
405
+ </div>
406
+ <div>
407
+ <label class="block text-xs font-semibold text-gray-400 mb-1 uppercase">Deny tools (one per line)</label>
408
+ <textarea id="mcp-deny-tools" rows="3"
409
+ class="w-full bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500 font-mono"
410
+ placeholder="filesystem.write"></textarea>
411
+ <div class="text-xs text-gray-500 mt-1">Always blocked (overrides allow).</div>
412
+ </div>
413
+ </div>
414
+ <div id="mcp-policy-status" class="text-xs text-gray-500"></div>
415
+ </div>
416
  <div class="flex gap-2 mb-3">
417
  <input id="mcp-direct-url" type="text" placeholder="Direct MCP URL (https://...)"
418
  class="flex-grow bg-gray-700 text-sm rounded border border-gray-600 p-2 text-white outline-none focus:border-blue-500">
static/dashboard.js CHANGED
@@ -659,6 +659,8 @@ let supabase;
659
  initProviderPresets();
660
  // Best-effort: hydrate server-persisted MCP registry if local storage is empty.
661
  maybeHydrateMcpRegistryFromServer();
 
 
662
  // Restore last chat mode (chat/autonomous)
663
  const savedMode = localStorage.getItem('chat_mode_v1') || 'chat';
664
  setChatMode(savedMode);
@@ -2144,6 +2146,7 @@ let supabase;
2144
 
2145
  // --- MCP Admin ---
2146
  const MCP_STORAGE_KEY = 'mcp_servers_v1';
 
2147
 
2148
  async function maybeHydrateMcpRegistryFromServer() {
2149
  try {
@@ -2153,6 +2156,81 @@ let supabase;
2153
  } catch { }
2154
  }
2155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2156
  async function loadMcpRegistryFromServer({ quiet = false } = {}) {
2157
  const statusEl = document.getElementById('codex-mcp-list');
2158
  try {
 
659
  initProviderPresets();
660
  // Best-effort: hydrate server-persisted MCP registry if local storage is empty.
661
  maybeHydrateMcpRegistryFromServer();
662
+ writeMcpPolicyToInputs(loadMcpPolicyFromLocalStorage());
663
+ maybeHydrateMcpPolicyFromServer();
664
  // Restore last chat mode (chat/autonomous)
665
  const savedMode = localStorage.getItem('chat_mode_v1') || 'chat';
666
  setChatMode(savedMode);
 
2146
 
2147
  // --- MCP Admin ---
2148
  const MCP_STORAGE_KEY = 'mcp_servers_v1';
2149
+ const MCP_POLICY_STORAGE_KEY = 'mcp_policy_v1';
2150
 
2151
  async function maybeHydrateMcpRegistryFromServer() {
2152
  try {
 
2156
  } catch { }
2157
  }
2158
 
2159
+ async function maybeHydrateMcpPolicyFromServer() {
2160
+ try {
2161
+ const raw = localStorage.getItem(MCP_POLICY_STORAGE_KEY);
2162
+ if (raw && raw.trim() && raw.trim() !== '{}' && raw.trim() !== 'null') return;
2163
+ await loadMcpPolicyFromServer({ quiet: true });
2164
+ } catch { }
2165
+ }
2166
+
2167
+ function setMcpPolicyStatus(text) {
2168
+ const el = document.getElementById('mcp-policy-status');
2169
+ if (el) el.textContent = text || '';
2170
+ }
2171
+
2172
+ function readMcpPolicyFromInputs() {
2173
+ const allowEl = document.getElementById('mcp-allow-tools');
2174
+ const denyEl = document.getElementById('mcp-deny-tools');
2175
+ const splitLines = (v) => (v || '').split('\n').map((s) => s.trim()).filter(Boolean);
2176
+ const allow = splitLines(allowEl?.value || '');
2177
+ const deny = splitLines(denyEl?.value || '');
2178
+ return { version: 1, allow, deny };
2179
+ }
2180
+
2181
+ function writeMcpPolicyToInputs(policy) {
2182
+ const allowEl = document.getElementById('mcp-allow-tools');
2183
+ const denyEl = document.getElementById('mcp-deny-tools');
2184
+ const allow = Array.isArray(policy?.allow) ? policy.allow : [];
2185
+ const deny = Array.isArray(policy?.deny) ? policy.deny : [];
2186
+ if (allowEl) allowEl.value = allow.join('\n');
2187
+ if (denyEl) denyEl.value = deny.join('\n');
2188
+ }
2189
+
2190
+ function loadMcpPolicyFromLocalStorage() {
2191
+ try {
2192
+ return JSON.parse(localStorage.getItem(MCP_POLICY_STORAGE_KEY) || '{}');
2193
+ } catch {
2194
+ return {};
2195
+ }
2196
+ }
2197
+
2198
+ function saveMcpPolicyToLocalStorage(policy) {
2199
+ localStorage.setItem(MCP_POLICY_STORAGE_KEY, JSON.stringify(policy || {}));
2200
+ }
2201
+
2202
+ async function loadMcpPolicyFromServer({ quiet = false } = {}) {
2203
+ try {
2204
+ if (!quiet) setMcpPolicyStatus('Loading policy from server...');
2205
+ const res = await authFetch('/api/user/mcp-policy');
2206
+ if (!res.ok) throw new Error(await res.text());
2207
+ const data = await res.json();
2208
+ saveMcpPolicyToLocalStorage(data);
2209
+ writeMcpPolicyToInputs(data);
2210
+ if (!quiet) setMcpPolicyStatus('Loaded policy from server.');
2211
+ } catch (e) {
2212
+ if (!quiet) setMcpPolicyStatus(`Failed to load policy: ${e?.message || e}`);
2213
+ }
2214
+ }
2215
+
2216
+ async function saveMcpPolicyToServer() {
2217
+ try {
2218
+ setMcpPolicyStatus('Saving policy to server...');
2219
+ const policy = readMcpPolicyFromInputs();
2220
+ const res = await authFetch('/api/user/mcp-policy', {
2221
+ method: 'PUT',
2222
+ headers: { 'Content-Type': 'application/json' },
2223
+ body: JSON.stringify(policy),
2224
+ });
2225
+ if (!res.ok) throw new Error(await res.text());
2226
+ const data = await res.json();
2227
+ saveMcpPolicyToLocalStorage(data?.policy || policy);
2228
+ setMcpPolicyStatus('Saved policy to server.');
2229
+ } catch (e) {
2230
+ setMcpPolicyStatus(`Failed to save policy: ${e?.message || e}`);
2231
+ }
2232
+ }
2233
+
2234
  async function loadMcpRegistryFromServer({ quiet = false } = {}) {
2235
  const statusEl = document.getElementById('codex-mcp-list');
2236
  try {