feats: permissions hooks mcp_rt plugins#915
Conversation
…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>
…t-report guidance
…haracter for report qa
…at/enhance_dsv2
- 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: {}
…ent GC cancellation
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
…agent into hanzhou/0413
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
…nch filesystem_tool) Made-with: Cursor
Replace bench-specific filesystem_tool with feat/git version; accept behavior differences vs prior tavily bench worktree. Made-with: Cursor
…ack for edit_file Made-with: Cursor
…_file 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>
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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,
)| if subdir: | ||
| subprocess.run( | ||
| ['git', '-C', str(clone_dir), 'sparse-checkout', 'set', subdir], | ||
| check=True, |
There was a problem hiding this comment.
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)| except asyncio.TimeoutError: | ||
| if proc is not None: | ||
| proc.kill() |
There was a problem hiding this comment.
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.
| 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 |
| 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'): |
There was a problem hiding this comment.
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| def _parse_version(value: str) -> tuple[int, int, int] | None: | ||
| match = _VERSION_RE.match(str(value).strip()) | ||
| if not match: |
There was a problem hiding this comment.
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.
| 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 |
| 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 |
There was a problem hiding this comment.
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
No description provided.