Updated to version 1.2.6
This commit is contained in:
+164
-2
@@ -849,11 +849,12 @@ async def list_all_runs(
|
||||
|
||||
@router.get("/agent-runs/{run_id}")
|
||||
async def get_agent_run(request: Request, run_id: str):
|
||||
_require_auth(request)
|
||||
user = _require_auth(request)
|
||||
from ..agents.tasks import get_run
|
||||
run = await get_run(run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
_check_agent_access(await agent_store.get_agent(run["agent_id"]), user)
|
||||
return run
|
||||
|
||||
|
||||
@@ -1475,6 +1476,49 @@ async def refresh_mcp_server(request: Request, server_id: str):
|
||||
|
||||
# ── Security settings ─────────────────────────────────────────────────────────
|
||||
|
||||
_UPLOAD_DEFAULT_EXTENSIONS: list[str] = [
|
||||
# Text / code
|
||||
"txt", "md", "csv", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg",
|
||||
"html", "css", "js", "ts", "jsx", "tsx", "py", "sh", "bash", "log", "sql",
|
||||
"env", "rst", "diff", "patch", "tsv", "nfo", "gitignore", "dockerfile",
|
||||
"rb", "go", "java", "c", "cpp", "h", "rs", "swift", "kt",
|
||||
# Images
|
||||
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "avif", "heic", "heif",
|
||||
# Documents
|
||||
"pdf",
|
||||
]
|
||||
|
||||
# Exact filenames (no extension) that are always allowed regardless of extension rules.
|
||||
# Checked by lowercase basename — not editable via the policy UI.
|
||||
_UPLOAD_ALLOWED_EXACT_NAMES: frozenset[str] = frozenset([
|
||||
"known_hosts", "authorized_keys", "config", # SSH
|
||||
"makefile", "procfile", "dockerfile", # build
|
||||
".env", ".gitignore", ".htaccess", # config dotfiles
|
||||
])
|
||||
_UPLOAD_DEFAULT_MAX_MB = 50
|
||||
_UPLOAD_DEFAULT_MAX_FILES = 20
|
||||
|
||||
|
||||
async def _get_upload_policy() -> dict:
|
||||
import json as _json
|
||||
raw_ext = await credential_store.get("system:upload_allowed_extensions")
|
||||
raw_mb = await credential_store.get("system:upload_max_file_size_mb")
|
||||
raw_n = await credential_store.get("system:upload_max_files")
|
||||
try:
|
||||
exts = _json.loads(raw_ext) if raw_ext else _UPLOAD_DEFAULT_EXTENSIONS
|
||||
except Exception:
|
||||
exts = _UPLOAD_DEFAULT_EXTENSIONS
|
||||
try:
|
||||
max_mb = int(raw_mb) if raw_mb else _UPLOAD_DEFAULT_MAX_MB
|
||||
except (ValueError, TypeError):
|
||||
max_mb = _UPLOAD_DEFAULT_MAX_MB
|
||||
try:
|
||||
max_files = int(raw_n) if raw_n else _UPLOAD_DEFAULT_MAX_FILES
|
||||
except (ValueError, TypeError):
|
||||
max_files = _UPLOAD_DEFAULT_MAX_FILES
|
||||
return {"allowed_extensions": exts, "max_file_size_mb": max_mb, "max_files": max_files}
|
||||
|
||||
|
||||
class SecuritySettingsIn(BaseModel):
|
||||
sanitize_enhanced: Optional[bool] = None
|
||||
canary_enabled: Optional[bool] = None
|
||||
@@ -1487,6 +1531,9 @@ class SecuritySettingsIn(BaseModel):
|
||||
max_email_chars: Optional[int] = None
|
||||
max_file_chars: Optional[int] = None
|
||||
max_subject_chars: Optional[int] = None
|
||||
upload_allowed_extensions: Optional[list[str]] = None
|
||||
upload_max_file_size_mb: Optional[int] = None
|
||||
upload_max_files: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/settings/security")
|
||||
@@ -1506,7 +1553,7 @@ async def get_security_settings(request: Request):
|
||||
sanitize_enhanced, canary_enabled, output_validation_enabled,
|
||||
truncation_enabled, llm_screen_enabled, llm_screen_block,
|
||||
max_web_chars, max_email_chars, max_file_chars, max_subject_chars,
|
||||
llm_screen_model,
|
||||
llm_screen_model, upload_policy,
|
||||
) = await asyncio_gather(
|
||||
_bool("system:security_sanitize_enhanced"),
|
||||
_bool("system:security_canary_enabled"),
|
||||
@@ -1519,6 +1566,7 @@ async def get_security_settings(request: Request):
|
||||
_int("system:security_max_file_chars", 20000),
|
||||
_int("system:security_max_subject_chars", 200),
|
||||
credential_store.get("system:security_llm_screen_model"),
|
||||
_get_upload_policy(),
|
||||
)
|
||||
return {
|
||||
"sanitize_enhanced": sanitize_enhanced,
|
||||
@@ -1532,6 +1580,7 @@ async def get_security_settings(request: Request):
|
||||
"llm_screen_enabled": llm_screen_enabled,
|
||||
"llm_screen_model": llm_screen_model or "google/gemini-flash-1.5",
|
||||
"llm_screen_block": llm_screen_block,
|
||||
**upload_policy,
|
||||
}
|
||||
|
||||
|
||||
@@ -1574,6 +1623,14 @@ async def save_security_settings(request: Request, body: SecuritySettingsIn):
|
||||
if body.llm_screen_block is not None:
|
||||
ops.append(credential_store.set("system:security_llm_screen_block", "1" if body.llm_screen_block else "0", "LLM screening block mode (vs flag mode)"))
|
||||
_invalidate_toggle_cache("system:security_llm_screen_block")
|
||||
if body.upload_allowed_extensions is not None:
|
||||
import json as _json
|
||||
exts = [e.lstrip(".").lower().strip() for e in body.upload_allowed_extensions if e.strip()]
|
||||
ops.append(credential_store.set("system:upload_allowed_extensions", _json.dumps(exts), "Allowed file extensions for user file uploads"))
|
||||
if body.upload_max_file_size_mb is not None:
|
||||
ops.append(credential_store.set("system:upload_max_file_size_mb", str(max(1, body.upload_max_file_size_mb)), "Max file size in MB for user uploads"))
|
||||
if body.upload_max_files is not None:
|
||||
ops.append(credential_store.set("system:upload_max_files", str(max(1, body.upload_max_files)), "Max number of files per upload request"))
|
||||
|
||||
if ops:
|
||||
await asyncio_gather(*ops)
|
||||
@@ -2780,6 +2837,58 @@ async def get_my_data_folder(request: Request):
|
||||
return {"data_folder": folder or ""}
|
||||
|
||||
|
||||
# ── Per-user SSH key ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/my/ssh/pubkey")
|
||||
async def get_my_ssh_pubkey(request: Request):
|
||||
user = _require_auth(request)
|
||||
from ..users import get_user_folder
|
||||
from pathlib import Path
|
||||
folder = await get_user_folder(user.id)
|
||||
if not folder:
|
||||
return {"exists": False, "pubkey": None, "no_folder": True}
|
||||
pubkey_path = Path(folder) / ".ssh" / "id_ed25519.pub"
|
||||
if not pubkey_path.exists():
|
||||
return {"exists": False, "pubkey": None}
|
||||
return {"exists": True, "pubkey": pubkey_path.read_text().strip()}
|
||||
|
||||
|
||||
@router.post("/my/ssh/generate")
|
||||
async def generate_my_ssh_key(request: Request):
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
user = _require_auth(request)
|
||||
body = await request.json()
|
||||
force = bool(body.get("force", False))
|
||||
from ..users import get_user_folder
|
||||
folder = await get_user_folder(user.id)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="No user folder configured. Ask your administrator to set system:users_base_folder.")
|
||||
ssh_dir = Path(folder) / ".ssh"
|
||||
key_path = ssh_dir / "id_ed25519"
|
||||
pubkey_path = ssh_dir / "id_ed25519.pub"
|
||||
if key_path.exists() and not force:
|
||||
raise HTTPException(status_code=409, detail="SSH key already exists.")
|
||||
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if force:
|
||||
key_path.unlink(missing_ok=True)
|
||||
pubkey_path.unlink(missing_ok=True)
|
||||
email = getattr(user, "email", "") or getattr(user, "username", "agent")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ssh-keygen", "-t", "ed25519", "-C", email,
|
||||
"-f", str(key_path), "-N", "",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"ssh-keygen failed: {stderr.decode()}")
|
||||
key_path.chmod(0o600)
|
||||
pubkey_path.chmod(0o644)
|
||||
ssh_dir.chmod(0o700)
|
||||
return {"pubkey": pubkey_path.read_text().strip()}
|
||||
|
||||
|
||||
# ── Per-user file browser ──────────────────────────────────────────────────────
|
||||
|
||||
import io as _io
|
||||
@@ -2943,6 +3052,59 @@ async def download_my_zip(request: Request, path: str = ""):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my/files/upload-policy")
|
||||
async def get_upload_policy(request: Request):
|
||||
_require_auth(request)
|
||||
return await _get_upload_policy()
|
||||
|
||||
|
||||
@router.post("/my/files/upload")
|
||||
async def upload_my_files(request: Request, path: str = ""):
|
||||
user = _require_auth(request)
|
||||
from ..users import get_user_folder
|
||||
base = await get_user_folder(user.id)
|
||||
if not base:
|
||||
raise HTTPException(status_code=404, detail="No files folder configured")
|
||||
target_dir = _resolve_user_path(base, path)
|
||||
if not _os.path.isdir(target_dir):
|
||||
raise HTTPException(status_code=400, detail="Target path is not a directory")
|
||||
|
||||
policy = await _get_upload_policy()
|
||||
allowed_exts = {e.lower() for e in policy["allowed_extensions"]}
|
||||
max_bytes = policy["max_file_size_mb"] * 1024 * 1024
|
||||
max_files = policy["max_files"]
|
||||
|
||||
form = await request.form()
|
||||
fields = [f for f in form.values() if hasattr(f, "filename") and f.filename]
|
||||
if len(fields) > max_files:
|
||||
raise HTTPException(status_code=400, detail=f"Too many files — max {max_files} per upload")
|
||||
|
||||
uploaded = []
|
||||
rejected = []
|
||||
for field in fields:
|
||||
safe_name = _os.path.basename(field.filename)
|
||||
if not safe_name:
|
||||
continue
|
||||
has_ext = "." in safe_name
|
||||
ext = safe_name.rsplit(".", 1)[-1].lower() if has_ext else ""
|
||||
name_lower = safe_name.lower()
|
||||
allowed = (ext in allowed_exts) if has_ext else (name_lower in _UPLOAD_ALLOWED_EXACT_NAMES)
|
||||
if not allowed:
|
||||
reason = f"File type .{ext} not allowed" if has_ext else f"Extensionless file '{safe_name}' not permitted"
|
||||
rejected.append({"name": safe_name, "reason": reason})
|
||||
continue
|
||||
content = await field.read()
|
||||
if len(content) > max_bytes:
|
||||
rejected.append({"name": safe_name, "reason": f"File exceeds {policy['max_file_size_mb']} MB limit"})
|
||||
continue
|
||||
dest = _resolve_user_path(base, _os.path.join(path, safe_name))
|
||||
with open(dest, "wb") as f:
|
||||
f.write(content)
|
||||
uploaded.append(safe_name)
|
||||
|
||||
return {"ok": True, "uploaded": uploaded, "rejected": rejected}
|
||||
|
||||
|
||||
@router.delete("/my/files")
|
||||
async def delete_my_file(request: Request, path: str):
|
||||
"""Delete a file from the user's data folder.
|
||||
|
||||
Reference in New Issue
Block a user