ulysses/routes
Kenny Van de Maele 620fdd0859
feat(agent): confine agent file/shell tools to a selectable workspace (#3665)
* 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.
2026-06-11 18:17:54 +02:00
..
__init__.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
_validators.py fix(hwfit): validate remote SSH detection targets (#3718) 2026-06-11 00:43:49 +02:00
admin_wipe_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
api_token_routes.py fix(tokens): owner check on update and delete routes (#3899) 2026-06-11 15:34:44 +02:00
assistant_routes.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
auth_routes.py fix(uploads): migrate upload ownership on rename (#3617) 2026-06-11 16:01:04 +02:00
backup_routes.py fix: backup import dropping a user's skill on cross-tenant title/id collision (#2057) 2026-06-09 08:04:22 +02:00
calendar_routes.py fix(calanders): Removed/merged duplicate calender delete endpoints (#3682) 2026-06-09 22:35:55 +02:00
chat_helpers.py fix(chat): stabilize system prompt, sequence memory extraction, and send stable session id to preserve KV cache (#3360) 2026-06-09 22:46:54 +01:00
chat_routes.py feat(agent): confine agent file/shell tools to a selectable workspace (#3665) 2026-06-11 18:17:54 +02:00
chatgpt_subscription_routes.py feat: add ChatGPT Subscription provider (#2876) 2026-06-08 10:19:18 +02:00
cleanup_routes.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
codex_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
compare_routes.py fix(endpoint): scope secondary endpoint lookups by owner 2026-06-08 11:51:55 +01:00
contacts_routes.py fix(contacts): tolerate non-string body in /api/contacts/import (#3638) 2026-06-10 17:50:22 +02:00
cookbook_helpers.py fix(hwfit): validate remote SSH detection targets (#3718) 2026-06-11 00:43:49 +02:00
cookbook_routes.py fix(hwfit): validate remote SSH detection targets (#3718) 2026-06-11 00:43:49 +02:00
copilot_routes.py feat: add ChatGPT Subscription provider (#2876) 2026-06-08 10:19:18 +02:00
device_flow.py feat: add ChatGPT Subscription provider (#2876) 2026-06-08 10:19:18 +02:00
diagnostics_routes.py feat(diagnostics): add consolidated service health endpoint for degraded-state reporting (#964) 2026-06-09 16:00:24 +01:00
document_helpers.py fix: _resolve_user_upload_path crashes on a non-dict resolve_upload result (#1715) 2026-06-03 13:34:33 +09:00
document_routes.py refactor(tools): extract document tools to handle registry (#3666) 2026-06-10 10:41:52 +02:00
editor_draft_routes.py Ignore invalid editor draft payloads (#1533) 2026-06-03 14:07:03 +09:00
email_helpers.py fix(email): scope learned sender signatures by owner (#3724) 2026-06-11 13:26:59 +02:00
email_pollers.py fix: quote IMAP mailbox arguments (#2170) 2026-06-05 16:00:20 +02:00
email_routes.py fix(email): keep FETCH attributes Gmail sends after the header literal (all Gmail mail showed as unread) (#3785) 2026-06-11 16:12:39 +02:00
embedding_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
emoji_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
font_routes.py Keep compact font family names together (#1263) 2026-06-03 14:24:30 +09:00
gallery_helpers.py fix(gallery): fail closed for null-user owner scope (#3613) 2026-06-09 20:20:21 +02:00
gallery_routes.py fix(gallery): fail closed for null-user owner scope (#3613) 2026-06-09 20:20:21 +02:00
history_routes.py fix(sessions): copy message metadata when forking a session (#3409) 2026-06-08 20:49:15 +02:00
hwfit_routes.py fix(hwfit): tolerate non-numeric gpu_count in /api/hwfit/models (#3639) 2026-06-11 01:01:58 +02:00
mcp_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
memory_routes.py fix(memory): validate session owner on manual add (#3807) 2026-06-11 15:44:10 +02:00
model_routes.py fix(models): reassign default endpoint when current default is disabled (#3649) 2026-06-11 13:17:31 +02:00
note_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
personal_routes.py refactor(uploads): centralize upload byte-limits in upload_limits.py (#3364) (#3518) 2026-06-09 01:24:30 +02:00
prefs_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
preset_routes.py fix(presets): scope expand-prompt model resolution to owner (#3477) 2026-06-08 21:12:02 +02:00
research_routes.py fix(endpoint): scope secondary endpoint lookups by owner 2026-06-08 11:51:55 +01:00
search_routes.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
session_routes.py fix(sessions): use owner_filter for list_sessions queries when auth disabled (#3622) 2026-06-10 17:07:07 +02:00
shell_routes.py fix(cookbook): don't 500 the packages panel when an optional package crashes on import (#2618) 2026-06-07 18:14:43 +02:00
signature_routes.py Constrain signature uploads to PNG data (#2844) 2026-06-05 13:17:43 +02:00
skills_routes.py fix(presets): scope expand-prompt model resolution to owner (#3477) 2026-06-08 21:12:02 +02:00
stt_routes.py refactor(uploads): centralize upload byte-limits in upload_limits.py (#3364) (#3518) 2026-06-09 01:24:30 +02:00
task_routes.py Merge branch 'dev' 2026-06-09 09:36:01 +09:00
tts_routes.py Odysseus v1.0 2026-05-31 23:58:26 +09:00
upload_routes.py Scope vision model resolution by owner (#3009) 2026-06-07 12:39:02 +02:00
vault_routes.py refactor(constants): single source of truth for data dir (#3368) 2026-06-08 09:58:52 +02:00
webhook_routes.py feat: add ChatGPT Subscription provider (#2876) 2026-06-08 10:19:18 +02:00
workspace_routes.py feat(agent): confine agent file/shell tools to a selectable workspace (#3665) 2026-06-11 18:17:54 +02:00