feat: plan mode for chat agent (#939) #954

Closed
sleepy wants to merge 4 commits from fix/939-plan-mode into dev
Owner

Port plan mode logic from upstream. Closes #939

Port plan mode logic from upstream. Closes #939
- Port edit_file with unified diff output (exact string replacement, replace_all support)
- Port grep tool (regex search, ripgrep-first with Python fallback, .gitignore-aware)
- Port glob tool (recursive file pattern matching, skips hidden dirs)
- Port ls tool (directory listing, folders-first, sizes)
- Port get_workspace tool (report active workspace folder)
- Add line-range support to read_file (offset/limit parameters)
- Add diff output to write_file (unified diff when overwriting)
- Register all new tools in tool handler registry and execution dispatch
- Add schemas for all new tools in core_schemas.py
- Add new tools to NON_ADMIN_BLOCKED_TOOLS security policy
- Add diff display support in format_tool_result
- 38 comprehensive tests covering all new tools

Refs: #936
Add workspace folder selection and confinement for agent tools:

- Add workspace_folder setting (settings.json) with resolution chain:
  TOOL_SANDBOX_DIRS env > workspace_folder setting > process cwd
- Gate bash tool to workspace cwd (starts in workspace, can still reach
  outside with absolute paths — not fully sandboxed)
- Gate file tools (read/write/edit/grep/glob/ls) to workspace sandbox
- Symlink escape prevention: follow symlinks before sandbox check,
  detect and block symlinks that resolve outside workspace
- Path validation uses both abspath and realpath to handle platform
  differences (e.g. macOS /etc → /private/etc)
- Inject workspace path and confinement rules into agent system prompt
- Add settings tool aliases: 'workspace' / 'workspace folder' → workspace_folder
- Validate workspace_folder setting (must be absolute existing directory)
- Add comprehensive test suite (39 tests) for workspace resolution,
  path validation, symlink escapes, bash cwd confinement, and
  settings integration

Fixes:
- Blocked path check now uses both abspath + realpath for symlinked dirs
- Sandbox dirs normalized with realpath for consistent comparison
- Write path validation detects symlink targets outside sandbox
- macOS /var → /private/var symlink handled consistently
RCE prevention:
- Port MCP command allowlist (_validate_mcp_command) to management_tools.py
  Hard-deny interpreters, shells, runtimes, package runners even if
  ODYSSEUS_MCP_ALLOWED_COMMANDS lists them. Reject code-exec flags (-c, -e,
  -m, --eval), remote URL args, shell metacharacters, and dangerous env vars
  (LD_PRELOAD, NODE_OPTIONS, PYTHONPATH, PATH, etc.). Validation runs BEFORE
  any DB write or subprocess spawn, so rejected registrations leave no trace.

Prompt-injection hardening:
- Wrap non-native tool results via untrusted_context_message (metadata.trusted=False)
  so prompt-injection in tool output is treated as data, not instructions.
  Matches the existing skill-wrapping (#788) and escalation-trace wrapping (#275).
- Update _recent_context_for_retrieval to skip metadata.trusted=False envelopes
  (plus legacy [Tool execution results] prefix) so tool output cannot pollute
  the RAG/tool-retrieval query.

Tool security:
- is_public_blocked_tool() fails CLOSED on non-string tool names
- Add plan_mode_disabled_tools() with PLAN_MODE_READONLY_TOOLS allowlist and
  _PLAN_MODE_KNOWN_MUTATORS backstop (fail-closed on schema import failure)
- owner_is_admin_or_single_user() uses _auth_disabled() for single-user mode
  and fails CLOSED on pre-setup (auth enabled but no admin created)
- Add get_workspace, serve_preset to NON_ADMIN_BLOCKED_TOOLS

Webhook SSRF:
- _ip_is_private() now handles IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
  and checks is_unspecified, is_multicast, is_reserved flags
- Use _utcnow() for naive UTC timestamps (replaces deprecated datetime.utcnow())
- Broaden error sanitization with _IP_CANDIDATE regex for IPv6 redaction

Download budgets:
- Add WEB_FETCH_SOFT_MAX_BYTES (2 MB) and WEB_FETCH_HARD_MAX_BYTES (20 MB)
  constants to tools/_shared.py
- Port web_fetch JSON args support: "full": true raises to hard cap,
  explicit max_bytes clamped to hard cap. Partial content notice with
  re-call hint when download stops before EOF.

Auth helpers:
- Add _auth_disabled() helper (mirrors AUTH_ENABLED env var parse)

Tests:
- test_manage_mcp_command_allowlist.py — 20+ RCE form rejections
- test_tool_output_prompt_injection.py — wrapping + retrieval exclusion
- test_tool_security_hardening.py — fail-closed, plan-mode, admin-scope
- test_webhook_ssrf_resilience.py — IPv6 SSRF bypasses + naive timestamps
Plan mode lets the agent investigate read-only and propose a step-by-step
plan before executing any mutating actions. The user can review, edit,
and approve the plan before the agent proceeds.

Changes:
- src/tool_policy.py: new ToolPolicy class with guide-only detection
  and per-turn policy composition
- src/agent/core.py: plan_mode parameter, PLAN_MODE_DIRECTIVE injection,
  build_active_plan_note for approved plans, MCP plan mode blocking,
  ask_user and plan_update event handling
- src/agent_loop.py: export plan mode symbols from agent.core
- src/mcp_manager.py: mcp_tool_is_readonly() classifier and
  plan_mode_blocked_mcp() method on McpManager
- src/request_models.py: plan_mode and approved_plan fields
- routes/chat_routes/stream.py: parse plan_mode/approved_plan, apply
  plan mode tool restrictions, pass to stream generator
- routes/chat_routes/stream_generator.py: forward plan_mode and
  approved_plan to stream_agent_loop, handle plan_update SSE events
- static/js/chat.js: plan panel rendering, ask_user question rendering,
  plan mode toggle in form data
- static/js/init.js: plan mode button click handler
- static/index.html: plan mode toggle button in input bar
- static/style.css: plan panel and ask-user question styles
- tests/test_plan_mode.py: 12 regression tests covering allowlist,
  denylist, MCP classification, guide-only policy, and fail-closed behavior
sleepy referenced this pull request from a commit 2026-06-18 18:31:40 +02:00
sleepy closed this pull request 2026-06-18 18:33:46 +02:00

Pull request closed

Sign in to join this conversation.
No description provided.