mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-18 10:45:31 -04:00
8ce945d338
* feat: Add plan mode to the chat agent
Adds a plan mode: the agent investigates read-only, proposes a checklist, and
waits for approval before changing anything. On approval it runs with full
tools and checks items off as it goes. Enforcement reuses the existing
disabled_tools gate.
Includes a slash command: `/plan [on|off]` (and `/toggle plan`) to flip the
plan toggle from the chat input.
- src/tool_security.py, src/mcp_manager.py: read-only allowlist (tools + MCP).
- src/agent_loop.py, routes/chat_routes.py: union the disabled set, prepend the
plan directive, force agent mode.
- static/: plan toggle pill, Approve & Run, dockable plan window, task-list
checkboxes, and the /plan slash command.
- tests/test_plan_mode.py.
* Plan mode: persistent re-referenceable plan + agent write-back
Three improvements so a long plan survives a weak model and stays in reach:
1. Re-reference the plan (out-of-context fix). On the execution turn the frontend
sends the approved checklist back (`approved_plan`); the backend pins it as a
top-of-context `## ACTIVE PLAN` system note (kept by the context trimmer), so
the agent can always re-read the plan instead of losing the thread on a long
run. New `build_active_plan_note()` (unit-tested).
2. Re-open / dock the plan anytime. The plan checklist is stored per-session
(localStorage). When a plan exists, the plan-mode button opens a small menu
("Show plan" / "Plan mode: On/Off") that re-opens the side-dockable plan
window — so it can stay docked while the agent works. The window live-refreshes
as the plan changes.
3. Agent write-back: new `update_plan` tool. The agent calls it to tick steps
`- [x]` after finishing them, or to revise steps when the user asks. Marker
tool (no I/O) → `plan_update` SSE event → the stored plan + docked window
update live. The ACTIVE PLAN note instructs the agent to use it.
Backend: src/agent_loop.py (param + pin + note builder + emit + prompt blurb),
src/tool_execution.py (update_plan handler), routes/chat_routes.py (parse
`approved_plan`, relay `plan_update`), registration in tool_schemas / agent_tools
/ tool_index (always-available, not admin-gated).
Frontend: static/js/chat.js (plan store, send `approved_plan`, handle
`plan_update`, capture restated checklists), static/app.js (plan-button menu),
static/js/planWindow.js (`isPlanWindowOpen`), static/js/storage.js (PLAN key).
Tests: tests/test_plan_mode.py (plan-note), tests/test_update_plan_tool.py.
* Plan mode: drop bash/python, rely on read-only discovery tools
Shell can mutate (write files, hit the network) and can't be constrained to
read-only at the tool layer, so plan mode no longer relies on a prompt to keep
it well-behaved — bash/python are removed from the read-only allowlist and added
to the fail-closed block set. Discovery is covered by the dedicated read-only
tools (read_file, grep, glob, ls) instead.
Rewrites the plan-mode directive to state shell is disabled and lists the
available read-only tools positively. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Comment: note _MCP_READONLY_VERBS are prefixes not whole words
Clarifies that entries like "summar" are intentional stems matched via
startswith (covers summarise/summarize/summary), not typos. Addresses review
feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: clarify why gating inverts the allowlist into a denylist
Rename _PLAN_MODE_FALLBACK_BLOCK -> _PLAN_MODE_KNOWN_MUTATORS and rewrite the
comments. The tool gate is a denylist (disabled_tools); plan mode's policy is an
allowlist, so it returns the inverse (all known tool names minus the allowlist).
The static mutator set is a backstop for the schema-derived name list, which
misses XML-only tools and can fail to import. Addresses review feedback on #638.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Plan mode: stop hardcoding the read-only tool list in the directive
The model is already shown its available (read-only) tools by _assemble_prompt,
which removes every disabled tool. Enumerating them again in the directive only
duplicated that list and would drift as tools change. Point at the tools listed
below instead. Addresses review feedback on #638.
183 lines
6.8 KiB
Python
183 lines
6.8 KiB
Python
"""Server-side tool safety policy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Optional, Set
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Tools regular/public users must not execute directly. These either expose
|
|
# server/runtime access, sensitive user data, external messaging, persistent
|
|
# state changes, or generic loopback/integration surfaces.
|
|
NON_ADMIN_BLOCKED_TOOLS = {
|
|
"bash",
|
|
"python",
|
|
"read_file",
|
|
"write_file",
|
|
"edit_file",
|
|
"grep",
|
|
"glob",
|
|
"ls",
|
|
"search_chats",
|
|
"manage_memory",
|
|
"manage_skills",
|
|
"manage_tasks",
|
|
"manage_endpoints",
|
|
"manage_mcp",
|
|
"manage_webhooks",
|
|
"manage_tokens",
|
|
"manage_documents",
|
|
"manage_settings",
|
|
"api_call",
|
|
"app_api",
|
|
"send_email",
|
|
"reply_to_email",
|
|
"list_emails",
|
|
"read_email",
|
|
"resolve_contact",
|
|
"manage_contact",
|
|
"manage_calendar",
|
|
"vault_search",
|
|
"vault_get",
|
|
"vault_unlock",
|
|
"download_model",
|
|
"serve_model",
|
|
"serve_preset",
|
|
"stop_served_model",
|
|
"cancel_download",
|
|
"adopt_served_model",
|
|
}
|
|
|
|
|
|
# Plan mode: the agent may investigate but must not mutate anything. Only these
|
|
# read-only/inspection tools stay enabled; everything else (writes, sends,
|
|
# manage_*, model serving, MCP, etc.) is blocked. Allowlist rather than blocklist
|
|
# so any newly added tool defaults to BLOCKED in plan mode — fail safe.
|
|
#
|
|
# bash/python are deliberately NOT here: the shell can mutate (write files, hit
|
|
# the network) and can't be constrained to read-only at the tool layer, so plan
|
|
# mode blocks it outright rather than relying on a prompt to keep it well-behaved.
|
|
# Code/file discovery is covered by the dedicated read-only tools below
|
|
# (read_file, grep, glob, ls) instead of freestyle shell.
|
|
PLAN_MODE_READONLY_TOOLS = {
|
|
"read_file",
|
|
"grep",
|
|
"glob",
|
|
"ls",
|
|
"web_search",
|
|
"web_fetch",
|
|
"search_chats",
|
|
"list_models",
|
|
"list_sessions",
|
|
"list_emails",
|
|
"read_email",
|
|
"list_served_models",
|
|
"list_downloads",
|
|
"list_cached_models",
|
|
"search_hf_models",
|
|
"list_serve_presets",
|
|
"list_cookbook_servers",
|
|
"resolve_contact",
|
|
"chat_with_model",
|
|
"ask_teacher",
|
|
}
|
|
|
|
|
|
# The agent's tool gate is a DENYLIST: execute_tool_block blocks any tool whose
|
|
# name is in `disabled_tools`. Plan mode's policy is the opposite — an allowlist
|
|
# (PLAN_MODE_READONLY_TOOLS). To apply an allowlist through a denylist, plan mode
|
|
# returns the inverse: every known tool name minus the allowlist.
|
|
#
|
|
# Known tool names come from FUNCTION_TOOL_SCHEMAS, but that source is imperfect:
|
|
# some tools are only XML-invocable (e.g. manage_notes, generate_image) and never
|
|
# appear there, and the import can fail outright. Either gap would drop a mutating
|
|
# tool from the subtraction and silently leave it enabled. This set is the static
|
|
# backstop for both: union it in so known mutators are always subtracted, and so a
|
|
# failed import still blocks them (fail closed, never open). Only mutators belong
|
|
# here — read-only tools are covered by the allowlist. Keep in sync when adding
|
|
# new mutating tools.
|
|
_PLAN_MODE_KNOWN_MUTATORS = {
|
|
"write_file", "create_document", "edit_document", "update_document",
|
|
"suggest_document", "manage_documents", "create_session", "manage_session",
|
|
"send_to_session", "pipeline", "manage_memory", "manage_skills",
|
|
"manage_tasks", "manage_notes", "manage_endpoints", "manage_mcp",
|
|
"manage_webhooks", "manage_tokens", "manage_settings", "manage_contact",
|
|
"manage_calendar", "api_call", "app_api", "ui_control",
|
|
"send_email", "reply_to_email", "bulk_email", "delete_email",
|
|
"archive_email", "mark_email_read", "download_model", "serve_model",
|
|
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
|
"generate_image", "edit_image", "trigger_research", "manage_research",
|
|
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
|
# mode even if the schema list fails to load.
|
|
"bash", "python",
|
|
}
|
|
|
|
|
|
def plan_mode_disabled_tools() -> Set[str]:
|
|
"""Tool names to add to the denylist in plan mode.
|
|
|
|
Plan mode allows only PLAN_MODE_READONLY_TOOLS. The gate is a denylist, so
|
|
return the inverse: every known tool name minus the allowlist. Known names
|
|
come from the function-tool schemas, backstopped by _PLAN_MODE_KNOWN_MUTATORS
|
|
(see above) so XML-only tools and a failed schema import can't leave a mutator
|
|
enabled. MCP tools are handled separately — the loop drops the MCP manager
|
|
entirely in plan mode."""
|
|
try:
|
|
# agent_tools / tool_parsing / tool_schemas form a mutually-circular
|
|
# cluster that only resolves cleanly when entered via agent_tools.
|
|
# Import it first so the lazy schema import works even from a cold
|
|
# import (e.g. tests) — not just after the app has wired everything up.
|
|
import src.agent_tools # noqa: F401
|
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
|
|
|
all_names = {
|
|
(t.get("function") or {}).get("name")
|
|
for t in FUNCTION_TOOL_SCHEMAS
|
|
}
|
|
all_names.discard(None)
|
|
except Exception as exc:
|
|
logger.warning("Unable to load tool schemas for plan-mode gating: %s", exc)
|
|
all_names = set()
|
|
# Subtract the allowlist from all known tool names (schema-derived plus the
|
|
# static mutator backstop). Fail closed: if the schema import failed above,
|
|
# the backstop alone still blocks known mutators.
|
|
return (all_names | _PLAN_MODE_KNOWN_MUTATORS) - PLAN_MODE_READONLY_TOOLS
|
|
|
|
|
|
def is_public_blocked_tool(tool_name: Optional[str]) -> bool:
|
|
"""Return True when a non-admin/public user must not execute this tool.
|
|
|
|
This is a security gate, so it fails CLOSED: a malformed non-string tool
|
|
name can't be matched against the blocklist or the ``mcp__`` namespace, so
|
|
it is treated as blocked rather than silently allowed through. ``None`` /
|
|
empty string means there is no tool to gate.
|
|
"""
|
|
if tool_name is None or tool_name == "":
|
|
return False
|
|
if not isinstance(tool_name, str):
|
|
return True
|
|
return tool_name in NON_ADMIN_BLOCKED_TOOLS or tool_name.startswith("mcp__")
|
|
|
|
|
|
def owner_is_admin_or_single_user(owner: Optional[str]) -> bool:
|
|
"""Return True for admins, or when auth is not configured yet."""
|
|
try:
|
|
from core.auth import AuthManager
|
|
|
|
auth = AuthManager()
|
|
if not auth.is_configured:
|
|
return True
|
|
return bool(owner and auth.is_admin(owner))
|
|
except Exception as exc:
|
|
logger.warning("Unable to evaluate owner admin status: %s", exc)
|
|
return False
|
|
|
|
|
|
def blocked_tools_for_owner(owner: Optional[str]) -> Set[str]:
|
|
"""Tools to hide/disable for this owner under public-user policy."""
|
|
if owner_is_admin_or_single_user(owner):
|
|
return set()
|
|
return set(NON_ADMIN_BLOCKED_TOOLS)
|