diff --git a/README.md b/README.md index a04310d36..f6aad2027 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,19 @@ authentication, and execution details. - **Profiles:** named variations of the base config. Use profiles to vary the harness, model, MCP, tools, skills, telemetry, or environment context without editing `agent.yaml`. +- **Tools policy:** use top-level `tools.blocked` for harness-neutral blocked + tool policy. Names are interpreted by the selected adapter: + + ```yaml + tools: + blocked: + - browser + - shell + ``` + + Hermes maps these names to disabled toolsets, Claude maps them to + `disallowed_tools`, Deep Agents enforces them with middleware, and adapters + without a native deny mechanism route the policy as unsupported. - **Adapters:** harness-specific integrations selected by `harness.adapter_id`. The Hermes adapter lives under `adapters/hermes/`; the Codex CLI adapter lives under `adapters/codex-cli/`; the diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 7c6f0ff50..fb47c3b08 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -48,7 +48,8 @@ Configure portable capabilities through the normalized `FabricConfig` fields: `provider="anthropic"`; normalized hosted/custom provider resolution is tracked in [FABRIC-64](https://linear.app/nvidia/issue/FABRIC-64/add-normalized-model-provider-resolution-and-harness-compatibility). - `environment.workspace` sets the Claude working directory. -- `tools` sets the base Claude tool list. +- `tools.blocked` maps to Claude `disallowed_tools` using Claude-native tool + names. - `mcp` configures stdio, HTTP, streamable HTTP, or SSE servers. For stdio, Fabric parses `url` as a command plus arguments. - `skills.paths` names skill directories that contain `SKILL.md`. The adapter @@ -56,16 +57,16 @@ Configure portable capabilities through the normalized `FabricConfig` fields: Only Claude-specific controls belong in `harness.settings`: -- `system_prompt`, `allowed_tools`, `disallowed_tools`, and `permission_mode` +- `system_prompt`, `allowed_tools`, and `permission_mode` - `max_turns`, `max_budget_usd`, and `timeout_seconds` - `setting_sources` (defaults to `[]` for deterministic isolation) - `cli_path` for testing or an explicitly installed Claude Code executable - `nemo_relay_command` for an explicitly installed NeMo Relay CLI executable - `env` for variables explicitly forwarded to Claude Code -Putting `model_name`, `cwd`, `tools`, `mcp_servers`, or `skills` in -`harness.settings` is an error. Use the corresponding normalized field so the -same consumer configuration can compose with other adapters. +Putting `model_name`, `cwd`, `tools`, `disallowed_tools`, `mcp_servers`, or +`skills` in `harness.settings` is an error. Use the corresponding normalized +field so the same consumer configuration can compose with other adapters. The adapter filters the inherited environment before launching Claude Code. It retains portable OS/config variables, the selected model's `api_key_env`, @@ -116,6 +117,7 @@ from nemo_fabric import ( ModelConfig, RuntimeConfig, SkillConfig, + ToolsConfig, ) base_dir = Path("/workspace/review-agent") @@ -139,7 +141,7 @@ config = FabricConfig( }, runtime=RuntimeConfig(artifacts="./artifacts"), environment=EnvironmentConfig(provider="local", workspace="."), - tools=["Read", "Glob", "Grep"], + tools=ToolsConfig(blocked=["WebFetch"]), mcp=McpConfig( servers={ "repo": McpServerConfig( diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index 03406131e..ed36b146a 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -8,7 +8,7 @@ "callable": "run" }, "config": { - "accepts": ["models", "tools", "mcp", "skills", "telemetry"] + "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] }, "telemetry": { "providers": { diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 144639748..a1bb650c9 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -11,28 +11,26 @@ import os import shlex import shutil -from dataclasses import asdict, dataclass, is_dataclass +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import is_dataclass from hashlib import sha256 from pathlib import Path from typing import Any -from claude_agent_sdk import ( - CLIConnectionError, - CLIJSONDecodeError, - CLINotFoundError, - ClaudeAgentOptions, - ClaudeSDKError, - Message, - ProcessError, - ResultMessage, - query, -) +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKError +from claude_agent_sdk import CLIConnectionError +from claude_agent_sdk import CLIJSONDecodeError +from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import Message +from claude_agent_sdk import ProcessError +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import query from claude_agent_sdk._errors import MessageParseError - -import nemo_fabric_adapters.common.relay_gateway as relay_gateway -import nemo_fabric_adapters.common.relay_hooks as relay_hooks -import nemo_fabric_adapters.common.utils as common_utils - +from nemo_fabric_adapters.common import relay_gateway +from nemo_fabric_adapters.common import relay_hooks +from nemo_fabric_adapters.common import utils as common_utils PERMISSION_MODES = { "default", @@ -47,6 +45,7 @@ "model_name": "FabricConfig.models", "cwd": "FabricConfig.environment.workspace", "tools": "FabricConfig.tools", + "disallowed_tools": "FabricConfig.tools.blocked", "mcp_servers": "FabricConfig.mcp", "skills": "FabricConfig.skills", } @@ -267,33 +266,6 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: return result -def _normalized_tools( - payload: dict[str, Any], *, include_skills: bool -) -> list[str] | dict[str, Any] | None: - native = ( - _mapping(common_utils.capability_plan(payload), name="capability_plan").get( - "native" - ) - or {} - ) - if not _mapping(native, name="capability_plan.native").get("tools_configured"): - return None - tools = common_utils.fabric_config(payload).get("tools") - if tools is not None and not isinstance(tools, (list, dict)): - raise AdapterConfigError("claude_invalid_configuration", "tools is invalid") - if isinstance(tools, list): - normalized = _string_list(tools, name="tools") - if include_skills and "Skill" not in normalized: - normalized.append("Skill") - return normalized - if isinstance(tools, dict) and tools != {"type": "preset", "preset": "claude_code"}: - raise AdapterConfigError( - "claude_invalid_configuration", - "tools preset must be {'type': 'preset', 'preset': 'claude_code'}", - ) - return tools - - def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: native = ( _mapping(common_utils.capability_plan(payload), name="capability_plan").get( @@ -302,12 +274,8 @@ def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: or {} ) values = _mapping(native, name="capability_plan.native").get("skill_paths") or [] - if not isinstance(values, list) or any( - not isinstance(value, (str, Path)) for value in values - ): - raise AdapterConfigError( - "claude_invalid_configuration", "native skill_paths must be a list of paths" - ) + if not isinstance(values, list) or any(not isinstance(value, (str, Path)) for value in values): + raise AdapterConfigError("claude_invalid_configuration", "native skill_paths must be a list of paths") return [_resolve_path(payload, value) for value in values] @@ -516,11 +484,9 @@ def build_options( cwd=resolve_cwd(payload), model=selected_model(payload), system_prompt=system_prompt, - tools=_normalized_tools(payload, include_skills=has_skill_plugin), + tools=None, allowed_tools=_string_list(settings.get("allowed_tools"), name="allowed_tools"), - disallowed_tools=_string_list( - settings.get("disallowed_tools"), name="disallowed_tools" - ), + disallowed_tools=common_utils.blocked_tools(payload), permission_mode=permission_mode, max_turns=max_turns, max_budget_usd=max_budget, @@ -575,14 +541,10 @@ def load_claude_session_id( raise ValueError("missing Claude session") return session_id except (OSError, ValueError, json.JSONDecodeError) as error: - raise AdapterStateError( - "claude_invalid_runtime_state", "Claude runtime state is invalid" - ) from error + raise AdapterStateError("claude_invalid_runtime_state", "Claude runtime state is invalid") from error -def save_claude_session_id( - payload: dict[str, Any], fabric_runtime_id: str, claude_session_id: str -) -> None: +def save_claude_session_id(payload: dict[str, Any], fabric_runtime_id: str, claude_session_id: str) -> None: if not claude_session_id: raise AdapterStateError( "claude_invalid_runtime_state", "Claude session ID is missing" @@ -623,13 +585,9 @@ def normalize_message(message: Message) -> dict[str, Any]: return {"type": type(message).__name__, "message": _json_safe(message)} -def normalize_result( - payload: dict[str, Any], messages: list[Message], result: ResultMessage -) -> dict[str, Any]: +def normalize_result(payload: dict[str, Any], messages: list[Message], result: ResultMessage) -> dict[str, Any]: del payload - failed = bool(result.is_error) or ( - isinstance(result.subtype, str) and result.subtype.startswith("error_") - ) + failed = bool(result.is_error) or (isinstance(result.subtype, str) and result.subtype.startswith("error_")) error = None if failed: error = { @@ -847,20 +805,14 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: except ClaudeAdapterError as error: return adapter_failure(error) except Exception: # Adapter boundary must always return normalized JSON. - return _failure( - "claude_adapter_internal_error", "Claude adapter failed unexpectedly" - ) + return _failure("claude_adapter_internal_error", "Claude adapter failed unexpectedly") def main() -> None: try: payload = common_utils.load_payload() - except ( - Exception - ): # Malformed invocation input must still satisfy the process contract. - output = _failure( - "claude_adapter_internal_error", "Claude adapter failed unexpectedly" - ) + except Exception: # Malformed invocation input must still satisfy the process contract. + output = _failure("claude_adapter_internal_error", "Claude adapter failed unexpectedly") else: output = run(payload) print(json.dumps(output, sort_keys=True)) diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 50e667ca1..f815bfe53 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -45,9 +45,7 @@ def virtualenv_subprocess_env() -> dict[str, str]: scripts = virtualenv / ("Scripts" if os.name == "nt" else "bin") path = env.get("PATH") env["VIRTUAL_ENV"] = str(virtualenv) - env["PATH"] = os.pathsep.join( - part for part in (str(scripts), path) if part - ) + env["PATH"] = os.pathsep.join(part for part in (str(scripts), path) if part) env.pop("PYTHONHOME", None) return env @@ -166,6 +164,16 @@ def capability_plan(payload: dict[str, Any]) -> dict[str, Any]: return payload.get("capability_plan") or payload.get("capabilities") or {} +def tools_config(payload: dict[str, Any]) -> dict[str, Any]: + tools = fabric_config(payload).get("tools") or {} + return tools if isinstance(tools, dict) else {} + + +def blocked_tools(payload: dict[str, Any]) -> list[str]: + blocked = tools_config(payload).get("blocked") + return normalize_list(blocked) + + def normalize_list(value: Any) -> list[str]: if value is None: return [] @@ -176,6 +184,15 @@ def normalize_list(value: Any) -> list[str]: return [str(item) for item in value if str(item)] +def merge_unique(*values: Any) -> list[str]: + merged: list[str] = [] + for value in values: + for item in normalize_list(value): + if item not in merged: + merged.append(item) + return merged + + def without_none(mapping: dict[str, Any]) -> dict[str, Any]: return {key: value for key, value in mapping.items() if value is not None} diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index c05ad54ea..e233d1ba7 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -52,12 +52,9 @@ Fabric maps the following into the harness: - Configured MCP servers are loaded as Deep Agents tools via `langchain-mcp-adapters`. A misconfigured server (non-mapping, empty target, unsupported transport) is a normalized configuration failure, not a silent drop. -- `tools` (Fabric's `config.tools` allow-list) is enforced by a gating middleware - across the full tool surface — Deep Agents built-ins (including `task`), MCP - tools, and **delegated subagents** alike; tool calls whose name is not on the - list are blocked, so tools routed through the `task` tool cannot run ungated. A - non-list `tools` value is a normalized configuration failure rather than a - silently disabled allow-list. +- `tools.blocked` is enforced by middleware across the full tool surface — Deep + Agents built-ins (including `task`), MCP tools, and **delegated subagents** + alike. Use Deep Agents/native tool names in the blocked list. - `harness.settings.deepagents` forwards a small set of **documented, JSON-serializable** `create_deep_agent` options (currently `subagents` and `interrupt_on`). It is not a general Python-object escape hatch: the SDK config @@ -72,20 +69,20 @@ Fabric maps the following into the harness: Deep Agents can delegate to subagents through its built-in `task` tool. Subagents **inherit** the parent run's model, tools, skills, workspace, telemetry, and -permissions — in particular, the parent `config.tools` allow-list applies to -delegated execution, so a subagent cannot broaden capabilities beyond the parent. -Independently configured subagent tools, skills, models, MCP servers, middleware, -or permissions are **not** exposed through the Fabric SDK yet; a `subagents` -definition here only carries JSON-shaped fields. Deterministic verification of -delegated tool-gating is currently mock-based (see the adapter tests); a fuller -real-subagent contract is future work. +permissions. When `tools.blocked` is configured, Fabric supplies an explicitly +gated `general-purpose` subagent and gates every declarative local subagent, so +delegation cannot broaden capabilities beyond the parent. Remote and precompiled +subagents are rejected in that case because their execution cannot be governed by +the local middleware. Independently configured subagent tools, skills, models, +MCP servers, middleware, or permissions are **not** exposed through the Fabric SDK +yet; a `subagents` definition here only carries JSON-shaped fields. The normalized result includes the final response, buffered messages and per-step events, LangGraph thread id, token usage (and cost when the provider reports it), and errors. Usage aggregates the current turn across the main agent and any delegated subagents (streamed with `subgraphs=True`). Configuration and preflight failures (a missing credential, an absent `deepagents` package, an -invalid allow-list, MCP server, or passthrough option) are returned as a +invalid MCP server, or a passthrough option) are returned as a normalized failure result rather than a raw traceback. ## Runtime Modes diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index c69e3b842..78a09fe35 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -9,7 +9,7 @@ }, "requirements": {}, "config": { - "accepts": ["models", "tools", "mcp", "skills", "telemetry"] + "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] }, "telemetry": { "providers": { diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index bdc191977..f35e9c709 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -69,9 +69,7 @@ def resolve_api_key_env(settings: dict[str, Any], model_config: dict[str, Any]) provider = (settings.get("provider") or model_config.get("provider") or "").lower() default = PROVIDER_DEFAULT_API_KEY_ENV.get(provider) if default is None: - raise AdapterConfigError( - f"models.default.api_key_env is required for provider '{provider}'." - ) + raise AdapterConfigError(f"models.default.api_key_env is required for provider '{provider}'.") return default @@ -128,9 +126,7 @@ def selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: def resolve_base_url(settings: dict[str, Any], model_config: dict[str, Any]) -> str | None: base_url = ( - settings.get("base_url") - or (model_config.get("settings") or {}).get("base_url") - or model_config.get("base_url") + settings.get("base_url") or (model_config.get("settings") or {}).get("base_url") or model_config.get("base_url") ) if base_url: return base_url @@ -211,54 +207,43 @@ async def resolve_tools(payload: dict[str, Any]) -> list[Any] | None: return tools or None -def _allowed_tool_names(payload: dict[str, Any]) -> set[str] | None: - # The routed plan only marks ``native.tools_configured``; the value lives in - # ``effective_config.config.tools``. Return ``None`` only when tools are not - # configured; an explicitly empty list is a deny-all allow-list, not "no list". - # A wrong-shaped value must fail loudly rather than silently disable gating. - tools = common_utils.fabric_config(payload).get("tools") - if tools is None: - return None - if not isinstance(tools, (list, str)): - raise AdapterConfigError( - "config.tools must be a list of tool names (a deny/allow-list), " - f"not {type(tools).__name__}." - ) - return set(common_utils.normalize_list(tools)) - +def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: + return set(common_utils.blocked_tools(payload)) -def allowed_tools_middleware(allowed: set[str]) -> Any: - """Middleware that blocks tool calls whose name is not in the allow-list. - - Enforces Fabric's ``config.tools`` allow-list across the *full* tool surface - (Deep Agents built-ins such as ``write_file``/``execute``/``task`` and MCP - tools alike), since the built-ins are contributed by middleware rather than the - ``tools`` argument and cannot be pre-filtered out of the ``tools`` list. - """ +def _tool_gate_middleware(is_blocked: Any, message: Any) -> Any: from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ToolMessage def _blocked(request: Any) -> Any: name = request.tool_call.get("name") return ToolMessage( - content=f"Tool '{name}' is not permitted by the configured tools allow-list.", + content=message(name), tool_call_id=request.tool_call.get("id", ""), status="error", ) - class AllowedToolsMiddleware(AgentMiddleware): # type: ignore[misc] + class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] async def awrap_tool_call(self, request: Any, handler: Any) -> Any: - if request.tool_call.get("name") not in allowed: + if is_blocked(request.tool_call.get("name")): return _blocked(request) return await handler(request) def wrap_tool_call(self, request: Any, handler: Any) -> Any: - if request.tool_call.get("name") not in allowed: + if is_blocked(request.tool_call.get("name")): return _blocked(request) return handler(request) - return AllowedToolsMiddleware() + return ToolGateMiddleware() + + +def blocked_tools_middleware(blocked: set[str]) -> Any: + """Middleware that blocks explicitly denied tool calls across the full tool surface.""" + + return _tool_gate_middleware( + lambda name: name in blocked, + lambda name: f"Tool '{name}' is blocked by the configured tools policy.", + ) def resolve_skills(payload: dict[str, Any]) -> list[str] | None: @@ -298,9 +283,7 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: if transport in ("", "http", "streamable_http", "streamablehttp"): transport = "streamable_http" if transport not in VALID_MCP_TRANSPORTS: - raise AdapterConfigError( - f"MCP server '{name}' has unsupported transport '{transport}'." - ) + raise AdapterConfigError(f"MCP server '{name}' has unsupported transport '{transport}'.") return {"transport": transport, "url": target} @@ -337,9 +320,7 @@ def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: if not json_path.is_file(): return None value = json.loads(json_path.read_text(encoding="utf-8")) - if not isinstance(value, dict) or value.get("runtime_id") != runtime_id or not value.get( - "thread_id" - ): + if not isinstance(value, dict) or value.get("runtime_id") != runtime_id or not value.get("thread_id"): raise RuntimeError(f"invalid Deep Agents runtime state in {json_path}") return str(value["thread_id"]) @@ -349,9 +330,7 @@ def save_thread_id(payload: dict[str, Any], runtime_id: str, thread_id: str) -> json_path.parent.mkdir(parents=True, exist_ok=True) invocation_id = common_utils.runtime_context(payload).get("invocation_id") or "pending" tmp = json_path.with_suffix(f".{invocation_id}.tmp") - tmp.write_text( - json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), encoding="utf-8" - ) + tmp.write_text(json.dumps({"runtime_id": runtime_id, "thread_id": thread_id}, indent=2), encoding="utf-8") os.replace(tmp, json_path) @@ -381,9 +360,7 @@ async def close_checkpointer(checkpointer: Any) -> None: # --- invocation ------------------------------------------------------------ -async def build_agent_kwargs( - payload: dict[str, Any], model: Any, settings: dict[str, Any] -) -> dict[str, Any]: +async def build_agent_kwargs(payload: dict[str, Any], model: Any, settings: dict[str, Any]) -> dict[str, Any]: kwargs: dict[str, Any] = { "model": model, "tools": await resolve_tools(payload), @@ -397,18 +374,12 @@ async def build_agent_kwargs( extra = settings.get("deepagents") if extra is not None: kwargs.update(_validated_passthrough(extra)) - allowed = _allowed_tool_names(payload) - if allowed is not None: - # Gate the main agent AND every configured subagent, so the allow-list - # covers the full tool surface. The built-in ``task`` tool (which spawns - # the general-purpose subagent) is itself gated on the main agent, so a - # list that omits ``task`` blocks all delegation. + blocked = _blocked_tool_names(payload) + if blocked: middleware = list(kwargs.get("middleware") or []) - middleware.append(allowed_tools_middleware(allowed)) + middleware.append(blocked_tools_middleware(blocked)) kwargs["middleware"] = middleware - subagents = kwargs.get("subagents") - if isinstance(subagents, list): - kwargs["subagents"] = [_gate_subagent(sub, allowed) for sub in subagents] + kwargs["subagents"] = _gated_subagents(kwargs.get("subagents"), blocked) return {key: value for key, value in kwargs.items() if value is not None} @@ -423,8 +394,7 @@ def _validated_passthrough(extra: Any) -> dict[str, Any]: if not isinstance(extra, dict): raise AdapterConfigError( - "harness.settings.deepagents must be a mapping of JSON-serializable options, " - f"not {type(extra).__name__}." + f"harness.settings.deepagents must be a mapping of JSON-serializable options, not {type(extra).__name__}." ) reserved = sorted(FABRIC_OWNED_AGENT_KEYS.intersection(extra)) if reserved: @@ -441,11 +411,37 @@ def _validated_passthrough(extra: Any) -> dict[str, Any]: return dict(extra) -def _gate_subagent(subagent: Any, allowed: set[str]) -> Any: - if not isinstance(subagent, dict): - return subagent +def _block_subagent(subagent: dict[str, Any], blocked: set[str]) -> dict[str, Any]: gated = dict(subagent) - gated["middleware"] = [*(gated.get("middleware") or []), allowed_tools_middleware(allowed)] + gated["middleware"] = [*(gated.get("middleware") or []), blocked_tools_middleware(blocked)] + return gated + + +def _gated_subagents(subagents: Any, blocked: set[str]) -> list[dict[str, Any]]: + if subagents is None: + configured: list[Any] = [] + elif isinstance(subagents, list): + configured = subagents + else: + raise AdapterConfigError( + "harness.settings.deepagents.subagents must be a list when tools.blocked is configured." + ) + + gated: list[dict[str, Any]] = [] + for subagent in configured: + if not isinstance(subagent, dict): + raise AdapterConfigError("Deep Agents subagents must be mappings when tools.blocked is configured.") + name = str(subagent.get("name") or "") + if "graph_id" in subagent: + raise AdapterConfigError(f"tools.blocked cannot be enforced for remote Deep Agents subagent '{name}'.") + if "runnable" in subagent: + raise AdapterConfigError(f"tools.blocked cannot be enforced for precompiled Deep Agents subagent '{name}'.") + gated.append(_block_subagent(subagent, blocked)) + + if not any(subagent.get("name") == "general-purpose" for subagent in gated): + from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT + + gated.insert(0, _block_subagent(dict(GENERAL_PURPOSE_SUBAGENT), blocked)) return gated @@ -496,13 +492,9 @@ async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: wrapped = add_nemo_relay_integration(agent_kwargs) async with plugin.plugin(api_config): - result_state, events, turn_messages = await invoke_agent( - wrapped, user_message, thread_id - ) + result_state, events, turn_messages = await invoke_agent(wrapped, user_message, thread_id) else: - result_state, events, turn_messages = await invoke_agent( - agent_kwargs, user_message, thread_id - ) + result_state, events, turn_messages = await invoke_agent(agent_kwargs, user_message, thread_id) except Exception as exc: # normalized adapter failure error = f"{type(exc).__name__}: {exc}" finally: diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index 585932667..d9b62c2ac 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: Apache-2.0 # Hermes Agent Adapter -This adapter runs Hermes Agent through its Python SDK. +This adapter runs Hermes Agent through its Python SDK. Fabric invokes the adapter module with `python -m` through the core runtime lifecycle. The module entry point and the descriptor's callable route use the @@ -20,6 +20,8 @@ configuration for: - workspace and terminal settings; - Fabric skills as Hermes external skill directories; - Fabric MCP servers as Hermes MCP server config; +- `tools.blocked` as Hermes disabled toolsets, unioned with + `harness.settings.disabled_toolsets`; - optional NeMo Relay telemetry plugin configuration. `hermes_home` configures a base directory. The adapter creates a child under diff --git a/adapters/hermes/fabric-adapter.json b/adapters/hermes/fabric-adapter.json index 59dd0e0dd..88a41040b 100644 --- a/adapters/hermes/fabric-adapter.json +++ b/adapters/hermes/fabric-adapter.json @@ -15,6 +15,7 @@ "config": { "accepts": [ "tools", + "tools.blocked", "mcp", "skills", "telemetry" diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 85c1baab7..b7fc9f403 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -35,6 +35,14 @@ def validate_hermes_telemetry_provider(payload: dict[str, Any]) -> None: raise ValueError("only relay telemetry is supported for Hermes") +def disabled_toolsets(payload: dict[str, Any]) -> list[str]: + settings = common_utils.settings_payload(payload) + return common_utils.merge_unique( + common_utils.blocked_tools(payload), + settings.get("disabled_toolsets"), + ) + + def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) -> dict[str, Any]: settings = common_utils.settings_payload(payload) model_config = common_utils.selected_model_config(payload) @@ -44,6 +52,7 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) model_name = settings.get("model_name") or model_config.get("model", "") provider = settings.get("provider") or model_config.get("provider") base_url = common_utils.get_base_url(settings, model_config) + blocked_toolsets = disabled_toolsets(payload) config: dict[str, Any] = { "model": common_utils.without_none( @@ -56,7 +65,7 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) "agent": common_utils.without_none( { "max_turns": settings.get("max_iterations"), - "disabled_toolsets": settings.get("disabled_toolsets"), + "disabled_toolsets": blocked_toolsets or None, } ), "terminal": common_utils.without_none( @@ -124,6 +133,7 @@ def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: "mcp_servers": sorted((config.get("mcp_servers") or {}).keys()), "plugins": (config.get("plugins") or {}).get("enabled", []), "platform_toolsets": config.get("platform_toolsets", {}), + "disabled_toolsets": (config.get("agent") or {}).get("disabled_toolsets", []), } @@ -281,6 +291,7 @@ def _invoke_hermes( discover_plugins(force=True) loaded_hermes_config = load_config() enabled_toolsets = resolve_hermes_toolsets(settings, loaded_hermes_config) + blocked_toolsets = disabled_toolsets(payload) session_id = common_utils.runtime_id(payload) session_db = SessionDB() conversation_history = load_runtime_history(session_db, session_id) @@ -298,7 +309,7 @@ def _invoke_hermes( model=settings.get("model_name") or model_config.get("model", ""), max_iterations=int(max_iterations), enabled_toolsets=enabled_toolsets, - disabled_toolsets=settings.get("disabled_toolsets"), + disabled_toolsets=blocked_toolsets or None, quiet_mode=True, skip_context_files=True, skip_memory=True, diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index ff74fc2ad..a40d3abce 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -52,7 +52,7 @@ pub struct FabricConfig { pub environment: Option, /// Tool capability configuration. #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub tools: Option, /// Skill capability configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, @@ -90,6 +90,17 @@ impl ProfileRegistryConfig { } } +/// Harness-neutral tool capability configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolsConfig { + /// Adapter-native tool names or toolset names to block. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked: Vec, + /// Additive tool configuration fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + /// Human-readable metadata. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct MetadataConfig { @@ -347,7 +358,7 @@ pub struct AdapterRequirements { /// Adapter config support. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterConfigSupport { - /// Fabric config areas accepted by this adapter. + /// Fabric config areas or policy paths accepted by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub accepts: Vec, /// Harness-native files generated by this adapter. @@ -418,7 +429,7 @@ struct ProfileConfigSchema { /// Partial environment overlay. environment: Option>, /// Tool capability overlay. - tools: Option, + tools: Option, /// Partial skill overlay. skills: Option>, /// Partial MCP overlay. @@ -1593,28 +1604,35 @@ fn resolve_capability_plan( .collect() }) .unwrap_or_default(); - let tools_are_native = config.tools.is_some() && accepts("tools"); + let blocked_tools = config + .tools + .as_ref() + .map(|tools| tools.blocked.clone()) + .unwrap_or_default(); + let tools_configured = !blocked_tools.is_empty(); + let tools_are_native = tools_configured && accepts("tools.blocked"); let mut native = CapabilityTargetPlan::default(); let managed = CapabilityTargetPlan::default(); let mut unsupported = CapabilityTargetPlan::default(); let mut routes = Vec::new(); - if config.tools.is_some() { + if tools_configured { if tools_are_native { native.tools_configured = true; routes.push(CapabilityRoute { kind: CapabilityKind::Tools, - name: "tools".to_string(), + name: "tools.blocked".to_string(), target: CapabilityTarget::HarnessNative, - reason: "selected adapter accepts Fabric tools config".to_string(), + reason: "selected adapter explicitly supports the Fabric blocked-tools policy" + .to_string(), }); } else { unsupported.tools_configured = true; routes.push(CapabilityRoute { kind: CapabilityKind::Tools, - name: "tools".to_string(), + name: "tools.blocked".to_string(), target: CapabilityTarget::Unsupported, - reason: "selected adapter does not declare native tools support and Fabric-managed tools are not implemented".to_string(), + reason: "selected adapter does not explicitly declare blocked-tools policy support and Fabric-managed enforcement is not implemented".to_string(), }); } } @@ -1670,7 +1688,10 @@ fn resolve_capability_plan( } CapabilityPlan { - tools_configured: config.tools.is_some(), + tools: ToolsPlan { + blocked: blocked_tools, + }, + tools_configured, skill_paths, mcp_servers, native, @@ -1896,6 +1917,9 @@ pub struct EnvironmentPlan { /// Resolved capability configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CapabilityPlan { + /// Normalized tool policy. + #[serde(default)] + pub tools: ToolsPlan, /// Whether tool configuration was provided. #[serde(default)] pub tools_configured: bool, @@ -1919,6 +1943,14 @@ pub struct CapabilityPlan { pub routes: Vec, } +/// Normalized tool policy for a run. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolsPlan { + /// Adapter-native tool names or toolset names to block. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked: Vec, +} + /// Capabilities routed to one target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CapabilityTargetPlan { @@ -2032,8 +2064,8 @@ runtime: input_schema: chat output_schema: message tools: - enabled: [base] - cleared: + blocked: [base] + future: value: true future_top_level: base: true @@ -2052,8 +2084,8 @@ harness: runtime: input_schema: prompt tools: - enabled: [profile] - cleared: null + blocked: [profile] + future: null future_top_level: profile: true nested: @@ -2074,8 +2106,8 @@ future_top_level: assert_eq!(value["runtime"]["input_schema"], "prompt"); assert_eq!(value["harness"]["settings"]["workspace"], "./workspace"); assert_eq!(value["harness"]["settings"]["timeout_seconds"], 30); - assert_eq!(value["tools"]["enabled"], serde_json::json!(["profile"])); - assert!(value["tools"]["cleared"].is_null()); + assert_eq!(value["tools"]["blocked"], serde_json::json!(["profile"])); + assert!(value["tools"]["future"].is_null()); assert_eq!(value["future_top_level"]["base"], true); assert_eq!(value["future_top_level"]["profile"], true); assert_eq!(value["future_top_level"]["nested"]["first"], 1); @@ -2623,7 +2655,8 @@ runtime: input_schema: text output_schema: text tools: - - name: shell + blocked: + - shell skills: paths: - ./skills/review @@ -2652,6 +2685,7 @@ mcp: assert!(!plan.capability_plan.managed.tools_configured); assert!(plan.capability_plan.managed.skill_paths.is_empty()); assert!(plan.capability_plan.managed.mcp_servers.is_empty()); + assert_eq!(plan.capability_plan.tools.blocked, vec!["shell"]); assert!(plan.capability_plan.unsupported.tools_configured); assert_eq!(plan.capability_plan.unsupported.skill_paths.len(), 1); assert!( @@ -2672,6 +2706,94 @@ mcp: let _ = std::fs::remove_dir_all(root); } + #[test] + fn blocked_tools_require_policy_specific_adapter_support() { + let root = std::env::temp_dir().join(format!( + "fabric-blocked-tools-capability-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("adapters/tools")).expect("create adapters"); + std::fs::write( + root.join("adapters/tools/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.tools", + "harness": "tools", + "adapter_kind": "process", + "config": {"accepts": ["tools"]} +}"#, + ) + .expect("write adapter descriptor"); + std::fs::write( + root.join("agent.yaml"), + r#"schema_version: fabric.agent/v1alpha1 +metadata: + name: blocked-tools-agent +harness: + adapter_id: acme.fabric.tools +runtime: +tools: + blocked: + - browser +"#, + ) + .expect("write agent config"); + + let plan = resolve_run_plan(&root, None).expect("run plan"); + + assert_eq!(plan.capability_plan.tools.blocked, vec!["browser"]); + assert!(plan.capability_plan.tools_configured); + assert!(!plan.capability_plan.native.tools_configured); + assert!(plan.capability_plan.unsupported.tools_configured); + assert!(plan.capability_plan.routes.iter().any(|route| { + route.name == "tools.blocked" && route.target == CapabilityTarget::Unsupported + })); + + std::fs::write( + root.join("adapters/tools/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.tools", + "harness": "tools", + "adapter_kind": "process", + "config": {"accepts": ["tools", "tools.blocked"]} +}"#, + ) + .expect("write policy-aware adapter descriptor"); + + let plan = resolve_run_plan(&root, None).expect("policy-aware run plan"); + + assert!(plan.capability_plan.native.tools_configured); + assert!(!plan.capability_plan.unsupported.tools_configured); + assert!(plan.capability_plan.routes.iter().any(|route| { + route.name == "tools.blocked" && route.target == CapabilityTarget::HarnessNative + })); + + std::fs::write( + root.join("agent.yaml"), + r#"schema_version: fabric.agent/v1alpha1 +metadata: + name: blocked-tools-agent +harness: + adapter_id: acme.fabric.tools +runtime: +tools: + blocked: [] +"#, + ) + .expect("write empty tools config"); + + let plan = resolve_run_plan(&root, None).expect("run plan"); + + assert!(plan.capability_plan.tools.blocked.is_empty()); + assert!(!plan.capability_plan.tools_configured); + assert!(!plan.capability_plan.native.tools_configured); + assert!(plan.capability_plan.routes.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn later_profiles_override_earlier_profiles() { let profiles = vec!["env_opensandbox".to_string(), "env_local".to_string()]; diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index 01c859e4e..7524d2e87 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::config::{ - AdapterKind, CapabilityTarget, ControlLocation, EnvironmentOwnership, ResolutionStrategy, - RunPlan, + AdapterKind, CapabilityKind, CapabilityTarget, ControlLocation, EnvironmentOwnership, + ResolutionStrategy, RunPlan, }; /// Diagnostic status. @@ -186,9 +186,14 @@ fn check_capability_routes(plan: &RunPlan) -> Vec { .iter() .filter(|route| route.target == CapabilityTarget::Unsupported) .map(|route| { + let status = if route.kind == CapabilityKind::Tools { + DoctorStatus::Fail + } else { + DoctorStatus::Warn + }; check( "capability.unsupported", - DoctorStatus::Warn, + status, format!( "{:?} capability `{}` is configured but not executable: {}", route.kind, route.name, route.reason @@ -469,7 +474,10 @@ mod tests { use serde_json::Value; use super::*; - use crate::config::{AdapterKind, ResolutionStrategy, resolve_run_plan}; + use crate::config::{ + AdapterKind, CapabilityKind, CapabilityRoute, CapabilityTarget, ResolutionStrategy, + resolve_run_plan, + }; fn file_config_agent_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/file-config-agent") @@ -578,4 +586,24 @@ mod tests { && check.message.contains("service") })); } + + #[test] + fn unsupported_blocked_tools_fail_doctor() { + let mut plan = resolve_run_plan(file_config_agent_dir(), None).expect("run plan"); + plan.capability_plan.routes.push(CapabilityRoute { + kind: CapabilityKind::Tools, + name: "blocked".to_string(), + target: CapabilityTarget::Unsupported, + reason: "adapter does not accept tools".to_string(), + }); + + let report = doctor_plan(&plan); + + assert_eq!(report.status, DoctorStatus::Fail); + assert!(report.checks.iter().any(|check| { + check.name == "capability.unsupported" + && check.status == DoctorStatus::Fail + && check.message.contains("adapter does not accept tools") + })); + } } diff --git a/crates/fabric-core/src/error.rs b/crates/fabric-core/src/error.rs index f2656233b..c98664c58 100644 --- a/crates/fabric-core/src/error.rs +++ b/crates/fabric-core/src/error.rs @@ -109,6 +109,14 @@ pub enum FabricError { /// Adapter kind. adapter_kind: AdapterKind, }, + /// The selected harness cannot enforce the configured blocked-tools policy. + #[error("harness `{harness}` cannot enforce configured blocked tools: {reason}")] + UnsupportedToolsPolicy { + /// Harness type. + harness: String, + /// Capability-routing explanation. + reason: String, + }, /// A runtime handle was used with a different run plan than the one that created it. #[error( "runtime handle does not match run plan for `{field}`: expected `{expected}` but found `{actual}` (runtime `{runtime_id}`)" diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 54f79c4fe..c2c11523d 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::config::{ - AdapterKind, CapabilityPlan, ControlLocation, EffectiveConfig, EnvironmentOwnership, RunPlan, - TelemetryPlan, + AdapterKind, CapabilityKind, CapabilityPlan, CapabilityTarget, ControlLocation, + EffectiveConfig, EnvironmentOwnership, RunPlan, TelemetryPlan, }; use crate::error::{FabricError, Result}; @@ -408,6 +408,7 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { /// Start or connect to a harness runtime. pub fn start_runtime(plan: &RunPlan) -> Result { + validate_blocked_tools_support(plan)?; let environment = prepare_environment(plan)?; match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.start(plan, environment), @@ -425,6 +426,7 @@ pub fn invoke_runtime( runtime: &RuntimeHandle, request: RunRequest, ) -> Result { + validate_blocked_tools_support(plan)?; validate_runtime_handle(plan, runtime)?; match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.invoke(plan, runtime, request), @@ -436,6 +438,18 @@ pub fn invoke_runtime( } } +fn validate_blocked_tools_support(plan: &RunPlan) -> Result<()> { + if let Some(route) = plan.capability_plan.routes.iter().find(|route| { + route.kind == CapabilityKind::Tools && route.target == CapabilityTarget::Unsupported + }) { + return Err(FabricError::UnsupportedToolsPolicy { + harness: harness(plan), + reason: route.reason.clone(), + }); + } + Ok(()) +} + /// Stop or detach from a harness runtime. pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result> { validate_runtime_handle(plan, runtime)?; @@ -2036,6 +2050,37 @@ environment: let _ = fs::remove_dir_all(root); } + #[test] + fn runtime_rejects_blocked_tools_when_adapter_cannot_enforce_them() { + let root = temp_process_agent_dir(); + let config_path = root.join("agent.yaml"); + let mut config = fs::read_to_string(&config_path).expect("read config"); + config.push_str("tools:\n blocked:\n - shell\n"); + fs::write(&config_path, config).expect("write blocked tools config"); + fs::write( + root.join("adapters/process/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.process", + "harness": "process", + "adapter_kind": "process", + "config": {"accepts": ["tools"]} +}"#, + ) + .expect("write generic tools descriptor"); + let plan = resolve_run_plan(&root, None).expect("run plan"); + + let error = start_runtime(&plan).expect_err("unsupported tools policy must fail closed"); + + assert!(matches!(error, FabricError::UnsupportedToolsPolicy { .. })); + assert!( + error + .to_string() + .contains("cannot enforce configured blocked tools") + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn independent_runtimes_use_distinct_artifact_paths() { let root = temp_process_agent_dir(); diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index c2b93b66c..a9194c666 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -46,6 +46,7 @@ SPDX-License-Identifier: Apache-2.0 */} - [`models.SkillConfig`](./nemo_fabric.models.md#class-skillconfig): Skill capability configuration. - [`models.TelemetryConfig`](./nemo_fabric.models.md#class-telemetryconfig): Telemetry configuration. - [`models.TelemetryProviderConfig`](./nemo_fabric.models.md#class-telemetryproviderconfig): Provider-specific telemetry configuration. +- [`models.ToolsConfig`](./nemo_fabric.models.md#class-toolsconfig): Harness-neutral tool capability configuration. - [`types.AdapterInfo`](./nemo_fabric.types.md#class-adapterinfo): Resolved adapter identity attached to a run plan. - [`types.ArtifactManifest`](./nemo_fabric.types.md#class-artifactmanifest): Normalized collection of artifacts produced by a run. - [`types.ArtifactRef`](./nemo_fabric.types.md#class-artifactref): Reference to one artifact produced by a run. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 39ca5d2ab..540cb5820 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -1390,6 +1390,67 @@ Returns the set of fields that have been explicitly set on this model instance. +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `ToolsConfig` + +Harness-neutral tool capability configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + **Returns:** A set of strings representing the fields that have been set, i.e. that were not filled from defaults. @@ -1486,6 +1547,17 @@ Add a skill path and return this config. --- +### method `block_tools` + +```python +block_tools(*tools: 'str') → Self +``` + +Block adapter-native tool names or toolsets and return this config. + +--- + + ### method `enable_relay` ```python diff --git a/docs/reference/api/python-library-reference/nemo_fabric.types.md b/docs/reference/api/python-library-reference/nemo_fabric.types.md index 3b05fbfc4..6d70a2676 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.types.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md @@ -856,7 +856,6 @@ Return a detached, JSON-compatible mapping for serialization. ## class `RunOutput` - Normalized adapter output. ``response`` is a known adapter response field whose value follows the core Fabric JSON contract. Other keys are adapter-specific extensions. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx index 3b19223b2..907e75f2b 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 40 +position: 42 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx index 0ee4a6260..a3274ce53 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Capability routing target." -position: 41 +position: 43 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx index f81358900..6949a4a6f 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 46 +position: 48 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx index 89e2e527e..a8f5c25cf 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Field Name Policy" sidebar-title: "RelayAtofEndpointFieldNamePolicy" description: "Relay ATOF endpoint field-name policy." -position: 47 +position: 49 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx index 1374c2980..63323c443 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Endpoint Transport" sidebar-title: "RelayAtofEndpointTransport" description: "Relay ATOF endpoint transport." -position: 48 +position: 50 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx index 202286f11..ed6a73497 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 49 +position: 51 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx index 9f9a7964f..f22ab424b 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 50 +position: 52 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx index 1360c4910..b1f45b3ae 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 51 +position: 53 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx index 4905c7147..fed29257e 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx @@ -50,6 +50,8 @@ Fabric config models and loading helpers. - [TelemetryConfig](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig): Telemetry configuration. - [TelemetryPlan](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan): Resolved telemetry plan. - [TelemetryProviderConfig](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig): Provider-specific telemetry configuration. +- [ToolsConfig](/reference/api/rust-library-reference/fabric-core/config/struct-toolsconfig): Harness-neutral tool capability configuration. +- [ToolsPlan](/reference/api/rust-library-reference/fabric-core/config/struct-toolsplan): Normalized tool policy for a run. ## Enums diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx index c5df2a07b..d300e4079 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx @@ -17,7 +17,7 @@ Adapter config support. ### `accepts: Vec` -Fabric config areas accepted by this adapter. +Fabric config areas or policy paths accepted by this adapter. ### `generates: Vec` diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-capabilityplan.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-capabilityplan.mdx index 35648ded6..50c54c4e1 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-capabilityplan.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-capabilityplan.mdx @@ -9,12 +9,16 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
bool,\n    pub skill_paths: Vec<PathBuf>,\n    pub mcp_servers: BTreeMap<String, McpServerPlan>,\n    pub native: CapabilityTargetPlan,\n    pub managed: CapabilityTargetPlan,\n    pub unsupported: CapabilityTargetPlan,\n    pub routes: Vec<CapabilityRoute>,\n}"}} />
+
ToolsPlan,\n    pub tools_configured: bool,\n    pub skill_paths: Vec<PathBuf>,\n    pub mcp_servers: BTreeMap<String, McpServerPlan>,\n    pub native: CapabilityTargetPlan,\n    pub managed: CapabilityTargetPlan,\n    pub unsupported: CapabilityTargetPlan,\n    pub routes: Vec<CapabilityRoute>,\n}"}} />
Resolved capability configuration. ## Fields +### `tools: ToolsPlan` + +Normalized tool policy. + ### `tools_configured: bool` Whether tool configuration was provided. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx index b0ad8de9c..1d2623e7d 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
String,\n    pub metadata: MetadataConfig,\n    pub harness: HarnessConfig,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<Value>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub relay: Option<RelayConfig>,\n    pub profiles: ProfileRegistryConfig,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub metadata: MetadataConfig,\n    pub harness: HarnessConfig,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<ToolsConfig>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub relay: Option<RelayConfig>,\n    pub profiles: ProfileRegistryConfig,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Versioned Fabric agent config. @@ -39,7 +39,7 @@ Runtime input/output contract. Environment where the harness or its tools execute. -### `tools: Option` +### `tools: Option` Tool capability configuration. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsconfig.mdx new file mode 100644 index 000000000..a18c95df5 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsconfig.mdx @@ -0,0 +1,106 @@ +--- +title: "Struct Tools Config" +sidebar-title: "ToolsConfig" +description: "Harness-neutral tool capability configuration." +position: 38 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Harness-neutral tool capability configuration. + +## Fields + +### `blocked: Vec` + +Adapter-native tool names or toolset names to block. + +### `extensions: BTreeMap` + +Additive tool configuration fields. + +## Trait Implementations + +### `impl Clone for ToolsConfig` + +
Clone for ToolsConfig"}} />
+ +#### `clone` + +
clone(&self) -> ToolsConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ToolsConfig` + +
Debug for ToolsConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for ToolsConfig` + +
Default for ToolsConfig"}} />
+ +#### `default` + +
default() -> ToolsConfig"}} />
+ +### `impl<'de> Deserialize<'de> for ToolsConfig` + +
Deserialize<'de> for ToolsConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ToolsConfig` + +
ToolsConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ToolsConfig` + +
PartialEq for ToolsConfig"}} />
+ +#### `eq` + +
eq(&self, other: &ToolsConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ToolsConfig` + +
Serialize for ToolsConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for ToolsConfig` + +
StructuralPartialEq for ToolsConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsplan.mdx new file mode 100644 index 000000000..12daa525f --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-toolsplan.mdx @@ -0,0 +1,102 @@ +--- +title: "Struct Tools Plan" +sidebar-title: "ToolsPlan" +description: "Normalized tool policy for a run." +position: 39 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
Vec<String>,\n}"}} />
+ +Normalized tool policy for a run. + +## Fields + +### `blocked: Vec` + +Adapter-native tool names or toolset names to block. + +## Trait Implementations + +### `impl Clone for ToolsPlan` + +
Clone for ToolsPlan"}} />
+ +#### `clone` + +
clone(&self) -> ToolsPlan"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ToolsPlan` + +
Debug for ToolsPlan"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for ToolsPlan` + +
Default for ToolsPlan"}} />
+ +#### `default` + +
default() -> ToolsPlan"}} />
+ +### `impl<'de> Deserialize<'de> for ToolsPlan` + +
Deserialize<'de> for ToolsPlan"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ToolsPlan` + +
ToolsPlan"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ToolsPlan` + +
PartialEq for ToolsPlan"}} />
+ +#### `eq` + +
eq(&self, other: &ToolsPlan) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ToolsPlan` + +
Serialize for ToolsPlan"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for ToolsPlan` + +
StructuralPartialEq for ToolsPlan"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx index 9c40ce633..55f417b5a 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
PathBuf),\n    MissingEntrypoint(PathBuf),\n    UnsupportedExtension(PathBuf),\n    UnknownProfile {\n        profile: String,\n        agent: String,\n        available: Vec<String>,\n    },\n    ProfileError {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    ProfileTargetNotConfig {\n        profile: String,\n        path: PathBuf,\n    },\n    ProfileSelectionNotSupported(PathBuf),\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidAdapterPython {\n        value: String,\n        path: PathBuf,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseYaml {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
+
PathBuf),\n    MissingEntrypoint(PathBuf),\n    UnsupportedExtension(PathBuf),\n    UnknownProfile {\n        profile: String,\n        agent: String,\n        available: Vec<String>,\n    },\n    ProfileError {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    ProfileTargetNotConfig {\n        profile: String,\n        path: PathBuf,\n    },\n    ProfileSelectionNotSupported(PathBuf),\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedToolsPolicy {\n        harness: String,\n        reason: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidAdapterPython {\n        value: String,\n        path: PathBuf,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseYaml {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
Errors raised by Fabric config loading and validation. @@ -199,6 +199,22 @@ Harness type. Adapter kind. +### `UnsupportedToolsPolicy` + +
+ +The selected harness cannot enforce the configured blocked-tools policy. + +#### Fields + +### `harness: String` + +Harness type. + +### `reason: String` + +Capability-routing explanation. + ### `RuntimeHandleMismatch`
diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 356cf6fb0..96cceeaa0 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -35,6 +35,7 @@ from nemo_fabric.models import SkillConfig from nemo_fabric.models import TelemetryConfig from nemo_fabric.models import TelemetryProviderConfig +from nemo_fabric.models import ToolsConfig from nemo_fabric.runtime import Runtime from nemo_fabric.runtime import RuntimeStatus from nemo_fabric.types import AdapterInfo @@ -101,4 +102,5 @@ "TelemetryConfig", "TelemetryProviderConfig", "TelemetryRef", + "ToolsConfig", ] diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index 2bcf09a34..ee6b956a8 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -394,6 +394,12 @@ class ProfileRegistryConfig(FabricBaseModel): directories: list[str | Path] = Field(default_factory=list) +class ToolsConfig(FabricBaseModel): + """Harness-neutral tool capability configuration.""" + + blocked: list[str] = Field(default_factory=list) + + class FabricConfig(FabricBaseModel): """SDK-facing typed Fabric agent configuration.""" @@ -408,7 +414,7 @@ class FabricConfig(FabricBaseModel): telemetry: TelemetryConfig | None = None relay: RelayConfig | dict[str, Any] | None = None profiles: ProfileRegistryConfig | dict[str, Any] | None = None - tools: Any = None + tools: ToolsConfig | dict[str, Any] | None = None @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> Self: @@ -472,6 +478,18 @@ def remove_skill_path(self, path: str | Path) -> Self: self.skills = None return self + def block_tools(self, *tools: str) -> Self: + """Block adapter-native tool names or toolsets and return this config.""" + + if self.tools is None or isinstance(self.tools, dict): + self.tools = ToolsConfig.model_validate(self.tools or {}) + existing = list(self.tools.blocked) + for tool in tools: + if tool not in existing: + existing.append(tool) + self.tools.blocked = existing + return self + def enable_relay( self, *, @@ -522,7 +540,7 @@ class FabricProfileConfig(FabricBaseModel): skills: SkillConfig | dict[str, Any] | None = None telemetry: TelemetryConfig | dict[str, Any] | None = None relay: RelayConfig | dict[str, Any] | None = None - tools: Any = None + tools: ToolsConfig | dict[str, Any] | None = None class RunRequest(FabricBaseModel): diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index f75018d61..1298b71af 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -369,6 +369,48 @@ def remove_path(self, path: str | Path) -> "_SkillConfig": return self +class _ToolsConfig(_ConfigMapping): + """Harness-neutral tool capability configuration.""" + + _fields = frozenset({"blocked"}) + _omit_if_empty = frozenset({"blocked"}) + + def __init__( + self, + *, + blocked: Sequence[str] | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + if blocked is not None and (isinstance(blocked, (str, bytes)) or not isinstance(blocked, Sequence)): + raise FabricConfigError("tools blocked must be an ordered sequence of strings") + values: dict[str, Any] = {"blocked": [_required_text(tool, "blocked tool") for tool in (blocked or [])]} + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> _ToolsConfig: + """Validate a tools mapping and preserve extension fields.""" + + data = _mapping(value, "tools") + blocked = data.get("blocked", []) + if isinstance(blocked, (str, bytes)) or not isinstance(blocked, Sequence): + raise FabricConfigError("tools blocked must be an ordered sequence of strings") + return cls( + blocked=blocked, + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + def block(self, *tools: str) -> _ToolsConfig: + """Block adapter-native tool names or toolsets.""" + + blocked = list(self.get("blocked", [])) + for tool in tools: + value = _required_text(tool, "blocked tool") + if value not in blocked: + blocked.append(value) + self["blocked"] = blocked + return self + + class _McpConfig(_ConfigMapping): """MCP capability configuration with authoring helpers.""" @@ -563,7 +605,7 @@ def __init__( telemetry: Mapping[str, Any] | None = None, relay: Mapping[str, Any] | None = None, profiles: Mapping[str, Any] | None = None, - tools: Any = None, + tools: Mapping[str, Any] | None = None, extra_fields: Mapping[str, Any] | None = None, ) -> None: metadata_value = _coerce(_MetadataConfig, metadata, "metadata") @@ -578,6 +620,7 @@ def __init__( skills_value = None if skills is None else _coerce(_SkillConfig, skills, "skills") telemetry_value = None if telemetry is None else _coerce(_TelemetryConfig, telemetry, "telemetry") relay_value = None if relay is None else _mapping(relay, "relay") + tools_value = None if tools is None else _coerce(_ToolsConfig, tools, "tools") values: dict[str, Any] = { "schema_version": _required_text(schema_version, "schema_version"), "metadata": metadata_value, @@ -592,7 +635,7 @@ def __init__( ("telemetry", telemetry_value), ("relay", relay_value), ("profiles", profiles), - ("tools", tools), + ("tools", tools_value), ): if item is not None: values[key] = item @@ -643,6 +686,16 @@ def skills(self) -> _SkillConfig: def skills(self, value: _SkillConfig | Mapping[str, Any]) -> None: self["skills"] = _coerce(_SkillConfig, value, "skills") + @property + def tools(self) -> _ToolsConfig: + """Mutable tool capability config, created on first access.""" + + return self._ensure_section("tools", _ToolsConfig) + + @tools.setter + def tools(self, value: _ToolsConfig | Mapping[str, Any]) -> None: + self["tools"] = _coerce(_ToolsConfig, value, "tools") + @property def telemetry(self) -> _TelemetryConfig: """Mutable telemetry config, created on first access.""" @@ -689,6 +742,12 @@ def add_skill_path(self, path: str | Path) -> "_ResolvedFabricConfig": self.skills.add_path(path) return self + def block_tools(self, *tools: str) -> _ResolvedFabricConfig: + """Block adapter-native tool names or toolsets and return this config.""" + + self.tools.block(*tools) + return self + def enable_relay( self, *, diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index 9362e01f2..7eb806702 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -5,7 +5,7 @@ "description": "Adapter config support.", "properties": { "accepts": { - "description": "Fabric config areas accepted by this adapter.", + "description": "Fabric config areas or policy paths accepted by this adapter.", "items": { "type": "string" }, @@ -217,4 +217,4 @@ ], "title": "AdapterDescriptor", "type": "object" -} \ No newline at end of file +} diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 488e511f5..d1624c1ca 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -108,6 +108,11 @@ }, "type": "array" }, + "tools": { + "$ref": "#/$defs/ToolsPlan", + "default": {}, + "description": "Normalized tool policy." + }, "tools_configured": { "default": false, "description": "Whether tool configuration was provided.", @@ -459,6 +464,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1486,6 +1499,33 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolsPlan": { + "description": "Normalized tool policy for a run.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1500,6 +1540,7 @@ "native": { "tools_configured": false }, + "tools": {}, "tools_configured": false, "unsupported": { "tools_configured": false diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index a15c83d31..f664b81ed 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -912,6 +912,20 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1001,6 +1015,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index 60fb1062d..6fc533a5b 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -170,6 +170,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1010,6 +1018,20 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/profile.schema.json b/schemas/profile.schema.json index 085fd50df..63ebc6e0d 100644 --- a/schemas/profile.schema.json +++ b/schemas/profile.schema.json @@ -1,4 +1,20 @@ { + "$defs": { + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": true, "properties": { @@ -88,6 +104,14 @@ ] }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability overlay." } }, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index fe8805ace..a5f6efd4e 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -5,7 +5,7 @@ "description": "Adapter config support.", "properties": { "accepts": { - "description": "Fabric config areas accepted by this adapter.", + "description": "Fabric config areas or policy paths accepted by this adapter.", "items": { "type": "string" }, @@ -258,6 +258,11 @@ }, "type": "array" }, + "tools": { + "$ref": "#/$defs/ToolsPlan", + "default": {}, + "description": "Normalized tool policy." + }, "tools_configured": { "default": false, "description": "Whether tool configuration was provided.", @@ -609,6 +614,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1594,6 +1607,33 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolsPlan": { + "description": "Normalized tool policy for a run.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1631,6 +1671,7 @@ "native": { "tools_configured": false }, + "tools": {}, "tools_configured": false, "unsupported": { "tools_configured": false @@ -1707,4 +1748,4 @@ ], "title": "RunPlan", "type": "object" -} \ No newline at end of file +} diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 811401074..3ec6455c6 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -39,6 +39,8 @@ perl -0pi -e 's/\A\s+//' "$out"/*.md # lazydocs nests properties at h4 directly under h2 class sections. Flatten # those headings to h3 so generated pages satisfy markdown heading order. perl -pi -e 's/^#### (property<\/kbd>)/### $1/' "$out"/*.md +# lazydocs emits the ToolsConfig class heading without a separating blank line. +perl -0pi -e 's/(^## class<\/kbd> `ToolsConfig`\n)(?!\n)/$1\n/m' "$out/nemo_fabric.models.md" add_frontmatter() { local file="$1" diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index edfd9b0f5..ec9947d95 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -12,17 +12,15 @@ from unittest.mock import MagicMock import pytest -from claude_agent_sdk import ( - AssistantMessage, - CLIConnectionError, - CLIJSONDecodeError, - CLINotFoundError, - ClaudeSDKError, - ProcessError, - ResultMessage, - SystemMessage, - TextBlock, -) +from claude_agent_sdk import AssistantMessage +from claude_agent_sdk import ClaudeSDKError +from claude_agent_sdk import CLIConnectionError +from claude_agent_sdk import CLIJSONDecodeError +from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import ProcessError +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import SystemMessage +from claude_agent_sdk import TextBlock from claude_agent_sdk._errors import MessageParseError from nemo_fabric_adapters.claude import adapter @@ -42,7 +40,16 @@ def test_claude_descriptor_is_narrow_and_versioned(): "module": "nemo_fabric_adapters.claude.adapter", "callable": "run", }, - "config": {"accepts": ["models", "tools", "mcp", "skills", "telemetry"]}, + "config": { + "accepts": [ + "models", + "tools", + "tools.blocked", + "mcp", + "skills", + "telemetry", + ] + }, "telemetry": { "providers": { "relay": { @@ -71,7 +78,6 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "settings": { "system_prompt": "Review carefully.", "allowed_tools": ["Read"], - "disallowed_tools": ["WebFetch"], "permission_mode": "dontAsk", "max_turns": 4, "max_budget_usd": 1.5, @@ -87,7 +93,7 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "api_key_env": "ANTHROPIC_API_KEY", } }, - "tools": ["Read", "Glob", "Grep"], + "tools": {"blocked": ["Bash"]}, }, }, "runtime_context": { @@ -127,9 +133,9 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p ) assert options.model == "claude-test-model" assert options.system_prompt == "Review carefully." - assert options.tools == ["Read", "Glob", "Grep", "Skill"] + assert options.tools is None assert options.allowed_tools == ["Read"] - assert options.disallowed_tools == ["WebFetch"] + assert options.disallowed_tools == ["Bash"] assert options.permission_mode == "dontAsk" assert options.max_turns == 4 assert options.max_budget_usd == 1.5 @@ -300,24 +306,32 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( options = adapter.build_options(relay_payload, resume=None, relay=relay) - assert options.tools == ["Read", "Glob", "Grep"] + assert options.tools is None assert options.skills is None assert options.plugins == [{"type": "local", "path": str(relay.plugin_path)}] +def test_build_options_maps_blocked_tools_to_disallowed_tools(claude_payload): + claude_payload["effective_config"]["config"]["tools"] = {"blocked": ["Bash", "WebFetch"]} + + options = adapter.build_options(claude_payload, resume=None) + + assert options.tools is None + assert options.disallowed_tools == ["Bash", "WebFetch"] + + @pytest.mark.parametrize( ("name", "normalized_field"), [ ("model_name", "FabricConfig.models"), ("cwd", "FabricConfig.environment.workspace"), ("tools", "FabricConfig.tools"), + ("disallowed_tools", "FabricConfig.tools.blocked"), ("mcp_servers", "FabricConfig.mcp"), ("skills", "FabricConfig.skills"), ], ) -def test_build_options_rejects_normalized_capabilities_in_harness_settings( - claude_payload, name, normalized_field -): +def test_build_options_rejects_normalized_capabilities_in_harness_settings(claude_payload, name, normalized_field): claude_payload["effective_config"]["config"]["harness"]["settings"][name] = [] with pytest.raises( @@ -334,16 +348,6 @@ def test_build_options_rejects_skill_path_without_skill_manifest(claude_payload) adapter.build_options(claude_payload, resume=None) -def test_build_options_rejects_unknown_normalized_tool_preset(claude_payload): - claude_payload["effective_config"]["config"]["tools"] = { - "type": "preset", - "preset": "unknown", - } - - with pytest.raises(adapter.AdapterConfigError, match="tools preset"): - adapter.build_options(claude_payload, resume=None) - - def test_selected_model_rejects_unsupported_provider(claude_payload): model = claude_payload["effective_config"]["config"]["models"]["default"] model["provider"] = "nvidia" @@ -352,14 +356,6 @@ def test_selected_model_rejects_unsupported_provider(claude_payload): adapter.selected_model(claude_payload) -def test_build_options_ignores_tools_not_routed_to_adapter(claude_payload): - claude_payload["capability_plan"]["native"]["tools_configured"] = False - - options = adapter.build_options(claude_payload, resume=None) - - assert options.tools is None - - def test_state_round_trip_is_keyed_by_fabric_runtime(claude_payload): runtime_id = adapter.runtime_id(claude_payload) adapter.save_claude_session_id(claude_payload, runtime_id, "claude-session") diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 941678dec..86d486965 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -20,7 +20,6 @@ from unittest.mock import MagicMock import pytest - from nemo_fabric_adapters.deepagents import adapter # noqa: E402 @@ -68,9 +67,20 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): deepagents_mod.create_deep_agent = MagicMock(side_effect=build_agent) backends_mod = types.ModuleType("deepagents.backends") backends_mod.FilesystemBackend = mock_fs_backend + middleware_mod = types.ModuleType("deepagents.middleware") + subagents_mod = types.ModuleType("deepagents.middleware.subagents") + subagents_mod.GENERAL_PURPOSE_SUBAGENT = { + "name": "general-purpose", + "description": "General-purpose delegated agent.", + "system_prompt": "Handle the delegated task.", + } deepagents_mod.backends = backends_mod + deepagents_mod.middleware = middleware_mod + middleware_mod.subagents = subagents_mod monkeypatch.setitem(sys.modules, "deepagents", deepagents_mod) monkeypatch.setitem(sys.modules, "deepagents.backends", backends_mod) + monkeypatch.setitem(sys.modules, "deepagents.middleware", middleware_mod) + monkeypatch.setitem(sys.modules, "deepagents.middleware.subagents", subagents_mod) langchain_openai_mod = types.ModuleType("langchain_openai") langchain_openai_mod.ChatOpenAI = mock_chat_openai @@ -267,9 +277,7 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay ): artifacts = [{"kind": "atof", "path": str(tmp_path / "events.atof.jsonl")}] - monkeypatch.setattr( - adapter.common_utils, "load_relay_plugin_config", lambda _p: {"version": 1, "components": []} - ) + monkeypatch.setattr(adapter.common_utils, "load_relay_plugin_config", lambda _p: {"version": 1, "components": []}) monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) monkeypatch.setattr(adapter.common_utils, "collect_relay_artifacts", lambda _c: artifacts) payload = make_payload(tmp_path) @@ -297,9 +305,7 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( assert "relay-mw" in fake_sdks["create_kwargs"]["middleware"] -async def test_native_telemetry_exports_without_artifacts( - tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay -): +async def test_native_telemetry_exports_without_artifacts(tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay): monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) payload = make_payload(tmp_path) @@ -351,9 +357,7 @@ async def test_workspace_roots_filesystem_backend(tmp_path, make_payload, fake_s assert backend_kwargs["virtual_mode"] is True -async def test_checkpointer_closed_on_success_and_failure( - tmp_path, make_payload, monkeypatch, fake_sdks -): +async def test_checkpointer_closed_on_success_and_failure(tmp_path, make_payload, monkeypatch, fake_sdks): # The async checkpointer must be closed on both the success and error paths. await adapter.run_deepagents(make_payload(tmp_path)) assert fake_sdks["saver_exits"] == 1 @@ -369,9 +373,7 @@ def boom(**_kwargs): assert fake_sdks["saver_exits"] == 2 -async def test_mcp_servers_become_tools_filtered_by_allowed( - tmp_path, make_payload, monkeypatch, fake_sdks -): +async def test_mcp_servers_become_adapter_tools(tmp_path, make_payload, monkeypatch, fake_sdks): tool_read = MagicMock() tool_read.name = "read_file" tool_write = MagicMock() @@ -409,30 +411,16 @@ async def test_mcp_servers_become_tools_filtered_by_allowed( @pytest.mark.usefixtures("use_real_langgraph") -async def test_allowed_tools_recorded_as_middleware(tmp_path, make_payload, fake_sdks): - # An allow-list is enforced by a gating middleware over the full tool surface, - # not by filtering the passed tools list. - pytest.importorskip("langchain.agents.middleware") - pytest.importorskip("langgraph.checkpoint.sqlite.aio") - payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = ["read_file"] - - await adapter.run_deepagents(payload) - - assert fake_sdks["create_kwargs"]["middleware"], "gating middleware not attached" - - -@pytest.mark.usefixtures("use_real_langgraph") -async def test_allowed_tools_middleware_blocks_disallowed_tools(): +async def test_blocked_tools_middleware_blocks_configured_tools(): pytest.importorskip("langchain.agents.middleware") from langchain_core.messages import ToolMessage - middleware = adapter.allowed_tools_middleware({"read_file"}) + middleware = adapter.blocked_tools_middleware({"write_file"}) - async def handler(_request): + async def handler(_request: types.SimpleNamespace) -> str: return "executed" - def request(name): + def request(name: str) -> types.SimpleNamespace: return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) blocked = await middleware.awrap_tool_call(request("write_file"), handler) @@ -442,18 +430,6 @@ def request(name): allowed = await middleware.awrap_tool_call(request("read_file"), handler) assert allowed == "executed" - # An explicitly empty allow-list denies every tool. - deny_all = adapter.allowed_tools_middleware(set()) - denied = await deny_all.awrap_tool_call(request("read_file"), handler) - assert isinstance(denied, ToolMessage) - assert denied.status == "error" - - -def test_empty_tools_is_deny_all_not_none(): - # An explicitly empty tools list is a deny-all allow-list, not "no allow-list". - assert adapter._allowed_tool_names({"effective_config": {"config": {"tools": []}}}) == set() - assert adapter._allowed_tool_names({"effective_config": {"config": {}}}) is None - @pytest.mark.usefixtures("use_real_langgraph") async def test_real_langgraph_async_checkpointer(tmp_path, make_payload, monkeypatch): @@ -601,31 +577,69 @@ async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): assert fake_sdks["subgraphs"] is True -async def test_subagents_are_gated_by_allow_list(tmp_path, make_payload, fake_sdks): - # The allow-list must gate delegated subagents too, not only the main agent, - # so tools routed through the ``task`` tool cannot run ungated. +@pytest.mark.usefixtures("use_real_langgraph") +async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload): + pytest.importorskip("langchain.agents.middleware") + from langchain_core.messages import ToolMessage + payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = ["read_file"] + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { "subagents": [{"name": "researcher", "prompt": "research"}] } - await adapter.run_deepagents(payload) - - create_kwargs = fake_sdks["create_kwargs"] - assert create_kwargs["middleware"], "main agent gating middleware not attached" + settings = payload["effective_config"]["config"]["harness"]["settings"] + create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) + assert create_kwargs["middleware"], "main agent blocked-tools middleware not attached" subagents = create_kwargs["subagents"] - assert len(subagents) == 1 - assert subagents[0]["middleware"], "subagent gating middleware not attached" + assert [subagent["name"] for subagent in subagents] == ["general-purpose", "researcher"] + assert all(subagent["middleware"] for subagent in subagents) + async def handler(_request: types.SimpleNamespace) -> str: + return "executed" -async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): - # Documented JSON-serializable options reach create_deep_agent unchanged. + def request(name: str) -> types.SimpleNamespace: + return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) + + gates = [create_kwargs["middleware"][-1]] + gates.extend(subagent["middleware"][-1] for subagent in subagents) + for middleware in gates: + blocked = await middleware.awrap_tool_call(request("write_file"), handler) + assert isinstance(blocked, ToolMessage) + assert blocked.status == "error" + assert await middleware.awrap_tool_call(request("read_file"), handler) == "executed" + + +@pytest.mark.usefixtures("use_real_langgraph") +async def test_default_subagent_is_gated_by_blocked_tools(tmp_path, make_payload): + payload = make_payload(tmp_path) + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} + + settings = payload["effective_config"]["config"]["harness"]["settings"] + create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) + + assert [subagent["name"] for subagent in create_kwargs["subagents"]] == ["general-purpose"] + assert create_kwargs["subagents"][0]["middleware"] + + +@pytest.mark.parametrize("unsupported", [{"graph_id": "remote"}, {"runnable": "compiled"}]) +async def test_blocked_tools_reject_unenforceable_subagents(tmp_path, make_payload, unsupported): payload = make_payload(tmp_path) + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { - "interrupt_on": {"write_file": True} + "subagents": [{"name": "worker", **unsupported}] } + settings = payload["effective_config"]["config"]["harness"]["settings"] + with pytest.raises(adapter.AdapterConfigError, match="cannot be enforced"): + await adapter.build_agent_kwargs(payload, MagicMock(), settings) + + +async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): + # Documented JSON-serializable options reach create_deep_agent unchanged. + payload = make_payload(tmp_path) + payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = {"interrupt_on": {"write_file": True}} + await adapter.run_deepagents(payload) assert fake_sdks["create_kwargs"]["interrupt_on"] == {"write_file": True} @@ -635,9 +649,7 @@ async def test_deepagents_passthrough_cannot_override_fabric_owned_keys(tmp_path # Overriding a Fabric-owned key (here backend) would defeat workspace confinement; # it must fail loudly rather than silently replacing the derived value. payload = make_payload(tmp_path) - payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { - "backend": {"root_dir": "/etc"} - } + payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = {"backend": {"root_dir": "/etc"}} output = await adapter.run_deepagents(payload) @@ -701,17 +713,6 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): assert any(evt.get("subgraph") == "task:researcher" for evt in output["events"]) -async def test_dict_tools_is_normalized_failure(tmp_path, make_payload): - # A dict-shaped tools value must fail loudly, not silently disable gating. - payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = {"read_file": True} - - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "tools" in output["error"] - - async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): # A misconfigured MCP server must fail loudly, not be silently dropped. payload = make_payload(tmp_path) @@ -727,9 +728,7 @@ async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): async def test_empty_mcp_url_is_normalized_failure(tmp_path, make_payload): payload = make_payload(tmp_path) - payload["capability_plan"] = { - "native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}} - } + payload["capability_plan"] = {"native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}}} output = await adapter.run_deepagents(payload) @@ -753,9 +752,7 @@ async def test_unknown_provider_requires_api_key_env(tmp_path, make_payload, mon assert "api_key_env" in output["error"] -async def test_openai_provider_defaults_to_openai_key( - tmp_path, make_payload, monkeypatch, fake_sdks -): +async def test_openai_provider_defaults_to_openai_key(tmp_path, make_payload, monkeypatch, fake_sdks): # provider openai with no explicit api_key_env defaults to OPENAI_API_KEY, never # NVIDIA_API_KEY, and keeps ChatOpenAI's own endpoint. monkeypatch.delenv("NVIDIA_API_KEY", raising=False) diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index deb618fa7..7fdf5d892 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -79,6 +79,7 @@ def test_build_hermes_config_maps_fabric_config_to_hermes_config(): "plugins_enabled": ["custom/plugin"], } }, + "tools": {"blocked": ["shell", "browser"]}, "models": { "review": { "provider": "nvidia", @@ -100,7 +101,7 @@ def test_build_hermes_config_maps_fabric_config_to_hermes_config(): }, "agent": { "max_turns": 4, - "disabled_toolsets": ["browser"], + "disabled_toolsets": ["shell", "browser"], }, "terminal": { "backend": "local", @@ -340,6 +341,7 @@ def test_summarize_hermes_config(): "mcp_servers": ["a", "z"], "plugins": ["observability/nemo_relay"], "platform_toolsets": {"cli": ["git"]}, + "disabled_toolsets": [], } diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index b7d74a69d..d007b8953 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -24,7 +24,6 @@ RuntimeConfig, ) - ROOT = Path(__file__).resolve().parents[2] MOCK_CLAUDE_CLI = ROOT / "tests" / "fixtures" / "claude" / "mock-claude-cli.py" SESSION_ID = "11111111-1111-4111-8111-111111111111" @@ -94,9 +93,7 @@ def fabric_config( resolution="preinstalled", settings=settings, ), - models={ - "default": ModelConfig(provider="anthropic", model="claude-test-model") - }, + models={"default": ModelConfig(provider="anthropic", model="claude-test-model")}, runtime=RuntimeConfig(artifacts=tmp_path / "artifacts"), environment=EnvironmentConfig( provider="local", @@ -108,7 +105,6 @@ def fabric_config( skill_path = tmp_path / "skills" / "review" skill_path.mkdir(parents=True) (skill_path / "SKILL.md").write_text("# Review\n", encoding="utf-8") - config.tools = ["Read", "Glob", "Grep"] config.add_skill_path(skill_path) config.add_mcp_server( "docs", @@ -141,21 +137,14 @@ async def test_fabric_session_launches_fresh_processes_and_resumes(tmp_path): assert first.output["usage"] == {"input_tokens": 1, "output_tokens": 2} assert first.output["cost_usd"] == 0.001 assert [event["type"] for event in first.output["events"]] == ["AssistantMessage"] - arguments = [ - json.loads(line) - for line in (tmp_path / "claude-args.jsonl").read_text().splitlines() - ] + arguments = [json.loads(line) for line in (tmp_path / "claude-args.jsonl").read_text().splitlines()] assert len(arguments) == 2 assert "--resume" not in arguments[0] assert arguments[1][arguments[1].index("--resume") + 1] == SESSION_ID - assert all( - args[args.index("--tools") + 1] == "Read,Glob,Grep,Skill" for args in arguments - ) assert all("--mcp-config" in args for args in arguments) assert all("--plugin-dir" in args for args in arguments) plugin_paths = [args[args.index("--plugin-dir") + 1] for args in arguments] assert plugin_paths[0] == plugin_paths[1] - assert all("Skill" in args[args.index("--allowedTools") + 1] for args in arguments) assert not any(artifact.kind == "stderr" for artifact in second.artifacts.artifacts) @@ -238,9 +227,7 @@ async def test_live_claude_one_shot_and_session(tmp_path): assert one_shot.status == "succeeded" session_root = tmp_path / "session" - async with await fabric.start_runtime( - fabric_config(session_root), base_dir=session_root - ) as session: + async with await fabric.start_runtime(fabric_config(session_root), base_dir=session_root) as session: first = await session.invoke(input="Remember token FABRIC-CONTINUITY-7") second = await session.invoke( input="Reply only with the token I asked you to remember" diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index 153d63324..f66f93692 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -45,7 +45,9 @@ from nemo_fabric import RuntimeHandle from nemo_fabric import SkillConfig from nemo_fabric import TelemetryConfig +from nemo_fabric import ToolsConfig from nemo_fabric.types import _ResolvedFabricConfig +from nemo_fabric.types import _ToolsConfig from pydantic import ValidationError @@ -141,11 +143,14 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): project="fabric-tests", output_dir="./artifacts/relay", ) + config.block_tools("browser", "shell", "browser") assert isinstance(config.mcp, McpConfig) assert isinstance(config.skills, SkillConfig) assert isinstance(config.telemetry, TelemetryConfig) + assert isinstance(config.tools, ToolsConfig) + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} assert config.to_mapping()["skills"] == {"paths": ["./skills/review"]} assert config.to_mapping()["mcp"] == { "servers": { @@ -183,6 +188,31 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): TelemetryConfig(providers={"sideways": {}}) +def test_typed_tools_config_serializes_blocked_policy(): + config = FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + tools=ToolsConfig(blocked=["browser"]), + ) + + config.block_tools("shell", "browser") + + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} + + +def test_run_plan_config_block_tools_emits_canonical_shape(): + config = _ResolvedFabricConfig.from_mapping(_plan()["config"]) + + config.block_tools("browser", "shell", "browser") + + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} + + +def test_run_plan_tools_config_rejects_scalar_blocked_value(): + with pytest.raises(FabricConfigError, match="tools blocked"): + _ToolsConfig(blocked="browser") # type: ignore[arg-type] + + def test_fabric_config_authors_first_class_relay_observability(): config = _fabric_config()