mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 01:35:36 -04:00
620fdd0859
* feat(agent): workspace confinement via context-local binding + get_workspace tool Bind the per-turn workspace once in execute_tool_block; the shared path resolvers (_resolve_tool_path / _resolve_search_root) and the subprocess cwd helper (agent_cwd) read it, so file tools + bash/python are confined centrally and a new tool that uses the shared helpers cannot accidentally bypass it. Adds the admin-gated /api/workspace/browse picker, a workspace pill + directory modal (reusing existing modal/button CSS), the /workspace slash command, and a get_workspace tool (replaces a system-prompt block). Confinement is OS-agnostic (realpath/normcase/commonpath) and docker-safe (container paths, no host assumptions). Reopens #2023. * ux(workspace): clarify workspace is not a sandbox Picker modal note + pill tooltip + get_workspace tool/output wording now state plainly: read_file/write_file/edit_file/grep/glob/ls are confined to the folder, but bash/python only start there (cwd) and are not sandboxed. Modal note reuses the existing .muted class. * fix(agent): treat an active workspace as file-work intent A vague low-signal message (e.g. "look at the local project") matches no domain keywords, so tool retrieval is skipped and only always-available tools are offered — leaving the agent with no file access even though a workspace is set. When a workspace is active, include the file/code tools (incl. get_workspace) on low-signal turns so the agent can act on the folder. Also requires the tool index (ChromaDB) to be reachable for normal retrieval; that is an environment dependency, not part of this change. * ux(workspace): hide pill + overflow entry in chat mode Workspace only scopes the agent's file/shell tools, so the pill and the overflow 'Workspace' entry are agent-only now — hidden in chat mode like the bash toggle. Mode read from the DOM in syncWorkspaceIndicator; applyMode() is called from the agent/chat setMode handler. * prompt(tools): steer bash/python to defer to the dedicated file tools bash/python schema descriptions (what native-tool-calling models read) were bare and gave no steer, so models would do file ops via the shell (e.g. writing SVG/HTML, which then dumps raw markup into the tool preview). Tell bash/python in the schema + tool-index + prompt section to prefer read_file/write_file/ edit_file/grep/glob/ls and only be used for what those do not cover. * prompt(tools): keep bash/python deferral generic (no hardcoded tool names) Reference 'a dedicated tool' rather than listing read_file/write_file/grep/etc. by name, so the guidance does not go stale if those tools are renamed. * style(workspace): drop em-dashes from added code comments/strings * ux(workspace): terser non-sandbox note in picker (no tool-name list) * ux(workspace): mirror terse non-sandbox wording in pill tooltip * chore: untrack local venv symlink (run-only, not part of the feature) * prompt(workspace): keep get_workspace text generic (no hardcoded tool names) * fix(agent): low-signal + workspace surfaces only read-only file tools Intersect the files tool group with PLAN_MODE_READONLY_TOOLS so a vague message in a workspace exposes read_file/grep/glob/ls/get_workspace for exploration, but not write_file/edit_file/bash/python -- those wait for a request that actually calls for them (RAG retrieval still adds them on a real ask). * feat(workspace): cap browse listing at 500 dirs with a truncated hint Mirror the filesystem_tools._CODENAV_MAX_HITS pattern with a module-local _MAX_BROWSE_DIRS so a directory with thousands of children does not dump every row into the picker; the response carries a truncated flag and the modal tells the user to type a path to jump in. * chore: untrack local venv symlink (run-only artifact) * fix(workspace): vet the workspace root against the sensitive-path deny list at bind time The in-workspace resolver deny-lists sensitive paths inside the workspace, but the empty-path search root is the workspace itself, so a workspace of ~/.ssh could be listed via ls with no path. vet_workspace() (public, in tool_execution next to the resolvers) rejects non-directories and sensitive roots before the path is ever bound; chat_routes uses it instead of its inline isdir check. * fix(workspace): reject filesystem roots and stop showing rejected workspaces as active Review findings from #3665: P2: vet_workspace accepted / (and would accept drive/UNC roots), which makes every absolute path 'inside' the workspace and collapses confinement into host-wide file access. A root is its own dirname, so reject when dirname(resolved) == resolved; the browse response now carries a selectable flag and the picker disables 'Use this folder' on unselectable dirs. P3: /workspace set stored any string client-side and the chat route silently dropped rejected values, so the pill could claim a confinement that was not in effect. New admin-gated /api/workspace/vet validates manual paths before they persist (canonical path returned), and when a posted workspace is rejected at send time the stream emits workspace_rejected so the client clears the stored value and toasts instead of continuing silently. * fix(workspace): check caller privilege before vetting the posted workspace Review finding: /api/chat_stream called vet_workspace() on the posted value for every caller and emitted workspace_rejected on failure, so a non-admin who can chat but cannot use file/shell tools could distinguish existing directories from missing/file/sensitive/root paths by whether the event appeared. The resolution now lives in _resolve_request_workspace, which drops the submitted value uniformly for non-admin callers, with no vetting and no event, before the path ever touches the filesystem. Admin and single-user behavior is unchanged. Test pins that valid and invalid paths are indistinguishable for a non-admin and that vet_workspace is never invoked for them.
86 lines
4.0 KiB
Python
86 lines
4.0 KiB
Python
"""Workspace API - browse server directories to pick a tool workspace folder."""
|
|
import os
|
|
from fastapi import APIRouter, Request, HTTPException, Query
|
|
|
|
from src.auth_helpers import get_current_user
|
|
from src.tool_security import owner_is_admin_or_single_user
|
|
|
|
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
|
|
# A huge directory shouldn't dump thousands of rows into the picker; the user can
|
|
# type/paste a path to jump straight in instead.
|
|
_MAX_BROWSE_DIRS = 500
|
|
|
|
|
|
def setup_workspace_routes():
|
|
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
|
|
|
@router.get("/browse")
|
|
def browse(request: Request, path: str = Query(default="")):
|
|
"""List subdirectories of `path` (default: home) so the UI can navigate
|
|
the server filesystem and pick a workspace folder. Directories only.
|
|
|
|
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
|
|
same way the file/shell tools are (read_file/write_file/bash are in
|
|
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
|
be able to map the host's directory tree either.
|
|
"""
|
|
owner = get_current_user(request)
|
|
if not owner_is_admin_or_single_user(owner):
|
|
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
|
|
|
# Resolve symlinks so the reported path is canonical and the UI navigates
|
|
# real directories (defends against symlink games in displayed paths).
|
|
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
|
|
if not os.path.isdir(target):
|
|
target = os.path.realpath(os.path.expanduser("~"))
|
|
|
|
dirs = []
|
|
try:
|
|
with os.scandir(target) as it:
|
|
for entry in it:
|
|
try:
|
|
# Don't follow symlinks when classifying - a symlinked
|
|
# dir is skipped rather than letting the browser wander
|
|
# off via a link. Hidden entries are omitted.
|
|
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
|
|
# Build the child path server-side with os.path.join
|
|
# so it's correct on Windows (backslashes) and Linux.
|
|
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
|
|
except OSError:
|
|
continue
|
|
except (PermissionError, OSError):
|
|
dirs = []
|
|
|
|
dirs_sorted = sorted(dirs, key=lambda d: d["name"].lower())
|
|
truncated = len(dirs_sorted) > _MAX_BROWSE_DIRS
|
|
parent = os.path.dirname(target)
|
|
from src.tool_execution import vet_workspace
|
|
return {
|
|
"path": target,
|
|
"parent": parent if parent and parent != target else None,
|
|
"dirs": dirs_sorted[:_MAX_BROWSE_DIRS],
|
|
"truncated": truncated,
|
|
# Whether this directory may be bound as a workspace (filesystem
|
|
# roots and sensitive dirs may be browsed through but not chosen).
|
|
"selectable": vet_workspace(target) is not None,
|
|
}
|
|
|
|
@router.get("/vet")
|
|
def vet(request: Request, path: str = Query(default="")):
|
|
"""Validate a workspace path without binding it.
|
|
|
|
The UI calls this before persisting a manually typed path (/workspace
|
|
set) so a typo, file path, deleted folder, sensitive dir, or filesystem
|
|
root is rejected up front with the canonical path returned on success,
|
|
instead of being stored client-side and silently dropped at chat time.
|
|
Admin-gated like /browse: it confirms path existence on the host.
|
|
"""
|
|
owner = get_current_user(request)
|
|
if not owner_is_admin_or_single_user(owner):
|
|
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")
|
|
from src.tool_execution import vet_workspace
|
|
resolved = vet_workspace(path)
|
|
return {"ok": resolved is not None, "path": resolved}
|
|
|
|
return router
|