Skip to content

feats: permissions hooks mcp_rt plugins#915

Open
suluyana wants to merge 65 commits into
modelscope:mainfrom
suluyana:feat/permissions_hooks_mcpRT_plugins
Open

feats: permissions hooks mcp_rt plugins#915
suluyana wants to merge 65 commits into
modelscope:mainfrom
suluyana:feat/permissions_hooks_mcpRT_plugins

Conversation

@suluyana

Copy link
Copy Markdown
Collaborator

No description provided.

suluyan and others added 30 commits February 6, 2026 15:03
…date reporter delivery flow, and improve the quality check module (54.51)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
- Add snapshot.py: isolated git repo under output_dir/.ms_agent_snapshots,
  stores message_count per commit for history truncation on rollback
- Auto-snapshot on every user turn via on_task_begin (enable_snapshots=True by default)
- Add list_snapshots()/rollback() to Agent base and LLMAgent:
  rollback restores files, truncates saved history, clears _read_cache
- Refactor filesystem_tool.py: remove replace_file_contents, rewrite
  edit_file with old_string→new_string exact replace, quote-normalization
  fallback, smart delete, trailing-whitespace strip, staleness check,
  multi-type read (images/binary), read dedup cache
- Add smoke tests (20 cases, all offline)
- AgentTool now handles dynamic mode (split_to_sub_task) internally,
  replacing the standalone SplitTask class. Backward compat preserved:
  configs with tools.split_task auto-register the built-in dynamic spec.
- Fix execution_mode missing from split_to_sub_task schema (was silently
  ignored before; now exposed as enum field with sequential/parallel).
- Increase max_subtask_output_chars default from 2048 to 8192.
- Add disallowed_tools to _AgentToolSpec to prevent recursive tool calls
  in sub-agents.
- Add sub-agent transcript persistence: in-process runs write messages
  to output_dir/subagents/<agent_tag>.jsonl for debugging.
- Add TaskManager (ms_agent/utils/task_manager.py): agent-level registry
  for background tasks with notification queue. LLMAgent initializes it
  in run_loop, wires it into AgentTool instances, and drains notifications
  at the top of each while-loop iteration. Supports future BashTool
  background mode via the same interface.
- diversity.py: replace SplitTask dependency with inline _run_tasks_sequential
  helper using LLMAgent directly.
When a spec has run_in_background=true, call_tool fires off the
subprocess and returns immediately with {status: async_launched,
task_id, tool_name}. A background asyncio watcher task polls the
result queue and calls task_manager.complete/fail when the process
exits. LLMAgent drains the TaskManager notification queue at the
top of each run_loop iteration, injecting <task-notification> XML
into the conversation so the model sees the result on the next turn.

run_in_background is opt-in per agent_tools definition:
  agent_tools:
    definitions:
      - tool_name: my_agent
        config_path: my_agent.yaml
        run_in_background: true
Exposes two tools to the model when tools.task_control is configured:
- list_tasks: show all background tasks with status and duration
- cancel_task: kill a running task by task_id

TaskControlTool receives the TaskManager reference via set_task_manager(),
which LLMAgent already calls for all extra_tools in run_loop. Enable with:

  tools:
    task_control: {}
suluyan and others added 28 commits April 13, 2026 10:29
Add WorkspacePolicyKernel (allow-roots from output_dir), ArtifactManager for large shell outputs, TaskManager with shell background support and asyncio process kill, WorkspaceSearchTool (grep_files/glob_files). Wire TaskManager into LLMAgent (prepare_tools, cleanup, task notifications in step) and extend LocalCodeExecutionTool with policy checks, artifact spill, run_in_background shell, sh -lc wrapping. ToolManager registers WorkspaceSearchTool by default and injects __call_id for shell_executor. Add tests for workspace policy. Document implementation map in shell-grep-glob-workspace-policy.md.

Made-with: Cursor
…ools

Replace removed file_system tools (list_files, delete_file_or_dir) with workspace_search (glob/grep) and/or code_executor (shell, file_operation). Update deep_research prompts and callbacks for read_file offset/limit and edit_file. fin_research: aggregator adds python_env shell/file_operation; collector exposes shell and file_operation in sandbox; file_system keeps read/write/edit. code_genesis: prompts use glob_files/shell for listing; orchestrator_callback uses os.makedirs instead of removed create_directory(). singularity registers workspace_search.

Made-with: Cursor
Remove WorkspaceSearchTool and register grep and glob on the file_system
server alongside read/write/edit. Add read/edit/write include aliases and
optional grep_head_limit, glob_max_files, and grep_timeout_s on file_system.

Update project YAML and prompts to drop workspace_search blocks; document
the mapping in shell-grep-glob-workspace-policy.md. Add tests for include
aliases and grep/glob filtering.

Made-with: Cursor
Add Tavily HTTP client, search/extract schema, WebSearchTool integration,
optional large-result spill, researcher/searcher Tavily YAML presets, and
run_benchmark env hooks for RESEARCHER_CONFIG / BENCH paths.

Made-with: Cursor
…ack)

WebSearchTool imports fetch_single_text_with_meta; add tiered fetch helpers
and optional Playwright fallback module used by jina_reader.

Made-with: Cursor
Replace bench-specific filesystem_tool with feat/git version; accept
behavior differences vs prior tavily bench worktree.

Made-with: Cursor
AgentTool overhaul: stream files, TaskControlTool, TaskManager wiring,
SplitTask removal from default tool path. Excludes decision_chain_transparency.

Made-with: Cursor
Align edit/write with disk-backed validation (Claude Code style): remove
_check_staleness and post-write cache pops that caused redundant read_file
round-trips and noisy errors.

test: use tool_manager.extra_tools in rollback read_cache smoke (matches
LLMAgent.rollback).

Made-with: Cursor
…nfigs

- Subagent snapshot defaults and snapshot repo hook bypass
- FileSystemTool read_file path alias; grep newline guard
- Evidence write_note optional title; report commit_outline coercion and report_generator load_index
- Reporter todo_list; Tavily-only searcher yaml; exp_nosnap configs
- Searcher JSON parse resilience in callback

Made-with: Cursor
Resolve conflicts in llm_agent (omit bench-only knowledge_search init),
llm/utils (union UI-stripped keys), and tool_manager (keep LocalSearchTool
and TaskControlTool).

Made-with: Cursor
This reverts commit 67668e9.
Terminal-Bench Docker containers don't install the mcp package
(agent yaml sets mcp: false). The unconditional import caused
ms-agent to fail to load inside Docker. Now gracefully degrades
to MCPClient = None when mcp is not available.
Introduce SafetyGuard and PermissionEnforcer at ToolManager entry, migrate
WorkspacePolicyKernel to WorkspaceContext, and align path validation with
shell/file tool cwd via resolve_workspace_root. Includes design doc and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce ms_agent/hooks for PreToolUse/PostToolUse lifecycle events,
integrate with ToolManager and LLMAgent (UserPromptSubmit, Stop, SessionStart),
and merge hook decisions with the existing permission layer.

Co-authored-by: Cursor <cursoragent@cursor.com>
…sync

Introduce MCPConfigManager, ConfigResolver, and MCPRuntime for persistent
CRUD, multi-layer merge, connect_policy=skip, degraded tracking, and hot
tool index sync integrated with LLMAgent and the existing Hooks pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep permission/hooks/mcp runtime on WorkspaceContext; adopt upstream
rollback API, SirchmunkSearch, DashScope SSE fix, and raw read_file output.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve conflicts in ConfigResolver, llm_agent, and config exports so
playground hooks/MCP layering coexists with main's skill runtime refactor.

Co-authored-by: Cursor <cursoragent@cursor.com>
    Implement install/load/runtime for Claude-format plugins, wire contributions
    into skills, hooks, MCP, and commands, and add ms-agent plugin CLI plus
    live verification for hookify@claude-plugins-official.

    Co-authored-by: Cursor <cursoragent@cursor.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive plugin compatibility layer, a dual-layer permission control system (SafetyGuard and PermissionEnforcer), and an MCP runtime management system with ToolManager synchronization. It also integrates lifecycle hooks into the agent execution loop and replaces the legacy WorkspacePolicyKernel with a lightweight WorkspaceContext. The review feedback highlights several critical issues and optimization opportunities: ensuring proper initialization of PluginRuntime with existing runtimes, handling commit SHAs during Git clone operations, avoiding zombie processes by awaiting process termination on timeout, casting extracted tool arguments to strings to prevent type errors during wildcard matching, supporting 'v'-prefixed semver strings, and optimizing recursive variable expansion to eliminate redundant disk I/O.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

raw_hooks = OmegaConf.to_container(self.config.hooks, resolve=True) or {}
enabled_executors = frozenset(
raw_hooks.get('enabled_executors', ['command']) or ['command'])
self._plugin_runtime = PluginRuntime()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The PluginRuntime is initialized without passing the existing _skill_runtime or mcp_runtime. If prepare_skills runs before prepare_tools, the plugin runtime's skill synchronization will be skipped because self.skill_runtime is still None when start_sync is called. Pass skill_runtime=self._skill_runtime and mcp_runtime=self.mcp_runtime to ensure proper synchronization.

        self._plugin_runtime = PluginRuntime(
            skill_runtime=self._skill_runtime,
            mcp_runtime=self.mcp_runtime,
        )

Comment on lines +461 to +464
if subdir:
subprocess.run(
['git', '-C', str(clone_dir), 'sparse-checkout', 'set', subdir],
check=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If ref is a commit SHA (e.g., a 40-character hex string), git clone --branch will fail because Git does not support cloning a specific commit hash directly via the --branch option. Check if ref is a commit SHA first, and if so, clone without --branch and perform a git checkout afterward.

    is_sha = ref and bool(re.match(r'^[0-9a-f]{7,40}$', ref, re.IGNORECASE))
    if ref and not is_sha:
        clone_cmd.extend(['--branch', ref])
    clone_cmd.extend([f'https://github.com/{repo}.git', str(clone_dir)])
    subprocess.run(clone_cmd, check=True, capture_output=True, text=True)
    if is_sha:
        subprocess.run(['git', '-C', str(clone_dir), 'fetch', 'origin', ref], check=False)
        subprocess.run(['git', '-C', str(clone_dir), 'checkout', ref], check=True, capture_output=True)

Comment on lines +100 to +102
except asyncio.TimeoutError:
if proc is not None:
proc.kill()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When a hook execution times out, proc.kill() is called to terminate the subprocess. However, proc.kill() does not wait for the process to terminate or reap the zombie process. You must call await proc.wait() after killing the process to prevent accumulating zombie processes and leaking system resources.

Suggested change
except asyncio.TimeoutError:
if proc is not None:
proc.kill()
except asyncio.TimeoutError:
if proc is not None:
try:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass

Comment on lines +11 to +26
from ms_agent.utils.pattern_matcher import match_pattern


TOOL_SPLITER = '---'
CONTENT_SEP = ':'


def _extract_content(tool_name: str, tool_args: dict[str, Any]) -> str | None:
"""Extract the primary content string from tool args for content-pattern matching."""
if tool_name.endswith(f'{TOOL_SPLITER}shell_executor'):
return tool_args.get('command')
if tool_name.endswith(f'{TOOL_SPLITER}write_file'):
return tool_args.get('path')
if tool_name.endswith(f'{TOOL_SPLITER}read_file'):
return tool_args.get('path')
if tool_name.endswith(f'{TOOL_SPLITER}edit_file'):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If tool_args contains non-string values (such as lists or dictionaries) for keys like path, command, or query, _extract_content will return those raw objects. This causes fnmatch.fnmatch to raise a TypeError during matching, crashing the entire tool execution. Cast the extracted value to str to ensure robustness.

    val = None
    if tool_name.endswith(f'{TOOL_SPLITER}shell_executor'):
        val = tool_args.get('command')
    elif tool_name.endswith(f'{TOOL_SPLITER}write_file'):
        val = tool_args.get('path')
    elif tool_name.endswith(f'{TOOL_SPLITER}read_file'):
        val = tool_args.get('path')
    elif tool_name.endswith(f'{TOOL_SPLITER}edit_file'):
        val = tool_args.get('path')
    elif tool_name.endswith(f'{TOOL_SPLITER}grep'):
        val = tool_args.get('pattern')
    elif tool_name.endswith(f'{TOOL_SPLITER}glob'):
        val = tool_args.get('pattern')
    else:
        for key in ('path', 'command', 'query', 'url', 'pattern'):
            if key in tool_args:
                val = tool_args[key]
                break
    return str(val) if val is not None else None

Comment on lines +84 to +86
def _parse_version(value: str) -> tuple[int, int, int] | None:
match = _VERSION_RE.match(str(value).strip())
if not match:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _parse_version function fails to parse version strings prefixed with a leading 'v' or 'V' (e.g., v1.2.3), which is a very common format in semver. Strip the leading 'v' or 'V' before matching against _VERSION_RE to ensure robust version parsing.

Suggested change
def _parse_version(value: str) -> tuple[int, int, int] | None:
match = _VERSION_RE.match(str(value).strip())
if not match:
val = str(value).strip().lstrip('vV')
match = _VERSION_RE.match(val)
if not match:
return None

Comment on lines +431 to +463
def _expand_vars(
value: Any,
plugin_root: Path,
plugin_data_dir: Path,
project_path: Path,
) -> Any:
if isinstance(value, str):
expanded = (
value
.replace('${MS_AGENT_PLUGIN_ROOT}', str(plugin_root))
.replace('${CLAUDE_PLUGIN_ROOT}', str(plugin_root))
.replace('${MS_AGENT_PLUGIN_DATA}', str(plugin_data_dir))
.replace('${CLAUDE_PLUGIN_DATA}', str(plugin_data_dir))
.replace('${MS_AGENT_PROJECT_DIR}', str(project_path))
.replace('${CLAUDE_PROJECT_DIR}', str(project_path))
)
user_config = _load_user_config(plugin_data_dir)
for key, item in user_config.items():
expanded = expanded.replace(f'${{user_config.{key}}}', str(item))
expanded = expanded.replace(
f'${{CLAUDE_PLUGIN_OPTION_{key.upper()}}}', str(item))
return expanded
if isinstance(value, list):
return [
_expand_vars(item, plugin_root, plugin_data_dir, project_path)
for item in value
]
if isinstance(value, dict):
return {
key: _expand_vars(item, plugin_root, plugin_data_dir, project_path)
for key, item in value.items()
}
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _expand_vars function recursively calls itself on lists and dictionaries, and loads the user configuration from disk (_load_user_config(plugin_data_dir)) on every single string evaluation. This causes massive, redundant disk I/O. Load the user configuration once at the start of the expansion process and pass it down through the recursive calls.

def _expand_vars(
    value: Any,
    plugin_root: Path,
    plugin_data_dir: Path,
    project_path: Path,
    user_config: dict[str, Any] | None = None,
) -> Any:
    if isinstance(value, str):
        expanded = (
            value
            .replace('${MS_AGENT_PLUGIN_ROOT}', str(plugin_root))
            .replace('${CLAUDE_PLUGIN_ROOT}', str(plugin_root))
            .replace('${MS_AGENT_PLUGIN_DATA}', str(plugin_data_dir))
            .replace('${CLAUDE_PLUGIN_DATA}', str(plugin_data_dir))
            .replace('${MS_AGENT_PROJECT_DIR}', str(project_path))
            .replace('${CLAUDE_PROJECT_DIR}', str(project_path))
        )
        if user_config is None:
            user_config = _load_user_config(plugin_data_dir)
        for key, item in user_config.items():
            expanded = expanded.replace(f'${{user_config.{key}}}', str(item))
            expanded = expanded.replace(
                f'${{CLAUDE_PLUGIN_OPTION_{key.upper()}}}', str(item))
        return expanded
    if isinstance(value, list):
        return [
            _expand_vars(item, plugin_root, plugin_data_dir, project_path, user_config)
            for item in value
        ]
    if isinstance(value, dict):
        return {
            key: _expand_vars(item, plugin_root, plugin_data_dir, project_path, user_config)
            for key, item in value.items()
        }
    return value

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant