# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tool call policy system for the Google Antigravity SDK. Provides a declarative API for expressing tool call policies (APPROVE, DENY, ASK_USER) that are enforced via the hooks system. Policies are evaluated using a priority-based model where specificity and safety determine precedence: Specific Deny > Specific Ask > Specific Allow > Wildcard Deny > Wildcard Ask > Wildcard Allow Within each priority group, first match wins, enabling short-circuit evaluation. Default Behavior: ``LocalAgentConfig`` uses ``confirm_run_command()`` as its default policy. This denies ``run_command`` (the most dangerous tool) while allowing all other tools. To enable autonomous shell access, explicitly pass ``policies=[policy.allow_all()]``. Policy Denial vs. Disabling Tools: Policies operate at the hook layer: a denied tool is still *visible* to the model in its tool list. If the model calls a policy-denied tool, the SDK rejects the call and returns a denial message. The model may then retry or choose another approach, but each attempt costs tokens. To remove a tool from the model's context entirely — so it never sees the tool and never wastes tokens on it — use ``CapabilitiesConfig.disabled_tools`` (or ``enabled_tools``) instead. **Use policies** when the restriction is conditional or context-dependent (e.g., denying ``run_command`` only for dangerous arguments, or requiring user approval for certain operations). **Use CapabilitiesConfig** when the tool is simply irrelevant to the agent's purpose and should not appear in its context at all. Usage: from google.antigravity.hooks import policy policies = [ policy.deny("*"), # Block everything by default policy.allow("view_file"), # Except viewing files policy.deny("run_command", # Block dangerous commands when=lambda args: "rm" in args.get("CommandLine", "")), policy.ask_user("run_command", # Ask for other commands handler=my_approval_fn), ] hook = policy.enforce(policies) # Register hook with HookRunner's pre_tool_call_decide_hooks """ from __future__ import annotations from collections.abc import Awaitable, Callable, Sequence import dataclasses import enum import inspect import logging import os import typing from typing import Any, Optional, Union, overload import pydantic from google.antigravity.proto import localharness_pb2 from google.antigravity import types from google.antigravity.hooks import hooks _logger = logging.getLogger(__name__) # A predicate receives the tool call's argument dict (or a Pydantic model, # if the predicate's first parameter is annotated with a BaseModel subclass) # and returns whether the policy applies. Supports both sync and async. Predicate = Callable[..., bool | Awaitable[bool]] # An ask_user handler receives the pending ToolCall and an optional reason # string supplied by the policy evaluation runtime (e.g. safety assessment in # auto policy mode) explaining why confirmation is requested, returning whether # the user approved execution. Supports both sync and async. # # Custom handlers can define signatures such as: # def my_handler(tc: types.ToolCall, reason: str = "") -> bool: ... # def my_handler(tc: types.ToolCall) -> bool: ... AskUserHandler = Union[ Callable[[types.ToolCall], Union[bool, Awaitable[bool]]], Callable[[types.ToolCall, Optional[str]], Union[bool, Awaitable[bool]]], ] _WILDCARD = "*" WORKSPACE_ONLY_POLICY_NAME = "workspace_only" _WORKSPACE_ONLY_POLICY_NAME = WORKSPACE_ONLY_POLICY_NAME class Decision(enum.Enum): """Outcome a policy can produce.""" APPROVE = "APPROVE" DENY = "DENY" ASK_USER = "ASK_USER" @dataclasses.dataclass(frozen=True) class Policy: """A single tool call policy rule. Attributes: tool: Tool name this policy targets, or "*" for all tools. decision: The outcome when this policy matches. when: Optional predicate on the tool call's arguments. If None the policy matches any call to the named tool. ask_user: Handler invoked when decision is ASK_USER. Must be provided for ASK_USER policies (validated at enforce() time). name: Human-readable label used in logging and deny reasons. reason: Optional human-readable explanation for why the policy matched (forwarded to ask_user handlers or used as a deny reason). """ tool: str decision: Decision when: Predicate | None = None ask_user: AskUserHandler | None = None name: str = "" reason: str = "" def __post_init__(self) -> None: object.__setattr__( self, "tool", types.normalize_builtin_tool_name(self.tool) ) @property def auto(self) -> bool: """Whether this policy enables auto policy mode safety evaluation.""" return False @dataclasses.dataclass(frozen=True) class AutoPolicy(Policy): """A policy rule enabling auto policy mode safety evaluation. When running in auto policy mode, commands and tool calls are assessed for safety by pre-tool safety assessors before execution. If flagged, execution is blocked or user confirmation is requested. Which tools are evaluated? Auto policy mode evaluates tools with inherent execution or network risk, such as shell command execution and external URL fetching. Standard read-only workspace operations (such as reading files or listing directories) are allowed without prompting. For file-write containment, pair ``policy.auto(...)`` with ``policy.workspace_only(workspaces)``. Evaluation order when combined with other policies: 1. Specific tool rules and server prefix rules (e.g. ``policy.allow("run_command")``, ``policy.deny("run_command")``, ``policy.workspace_only(...)``) run first and take precedence. 2. ``policy.auto(...)`` runs next for assessed tools and safety-flagged invocations. 3. Global wildcard rules (``policy.deny_all()``, ``policy.allow_all()``) run last as catch-all fallbacks for unassessed tools. """ tool: str = dataclasses.field(default="*", init=False, repr=False) decision: Decision = dataclasses.field( default=Decision.DENY, init=False, repr=False ) when: Predicate | None = dataclasses.field( default=None, init=False, repr=False ) reason: str = dataclasses.field(default="", init=False, repr=False) name: str = "auto" ask_user: AskUserHandler | None = None model: str | None = None @property def auto(self) -> bool: """Whether this policy enables auto policy mode safety evaluation.""" return True def __post_init__(self): decision = Decision.ASK_USER if self.ask_user is not None else Decision.DENY object.__setattr__(self, "decision", decision) # --------------------------------------------------------------------------- # Builder helpers # --------------------------------------------------------------------------- def _mcp_policies( decision: Decision, mcp_config: types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, when: Predicate | None = None, name: str = "", handler: AskUserHandler | None = None, reason: str = "", ) -> list[Policy]: """Generates MCP-specific policies. Commonly used by allow(), deny(), and ask_user() builders. Translates the config and tools into the structured 'server/tool' or 'server/*' target formats expected by the runtime hook. Args: decision: The Decision outcome when the policy matches. mcp_config: The BaseMcpServerConfig of the target MCP server. mcp_tools: Optional sequence of tool names to allow/deny/ask for. If None, applies to all tools on this server. when: Optional argument predicate. name: Optional human-readable label. handler: Optional AskUserHandler for ASK_USER policies. reason: Optional human-readable explanation for why the policy matched. Returns: A list of Policy objects. """ server = mcp_config.name if isinstance(mcp_tools, str): raise ValueError( f"mcp_tools must be a sequence of strings (e.g., ['{mcp_tools}']), " "not a single string." ) if mcp_tools is None: # Server-wide wildcard policy (covers all tools of this MCP server). # Lowercase prefix matching is purely for debug-logging consistency. policy_name = name or f"{decision.value.lower()}_{server}_all" return [ Policy( tool=f"{server}/*", decision=decision, when=when, name=policy_name, ask_user=handler, reason=reason, ) ] policies = [] for t in mcp_tools: policy_name = ( f"{name}_{t}" if name else f"{decision.value.lower()}_{server}_{t}" ) policies.append( Policy( tool=f"{server}/{t}", decision=decision, when=when, name=policy_name, ask_user=handler, reason=reason, ) ) return policies @overload def allow( tool: str, *, when: Predicate | None = None, name: str = "", ) -> Policy: ... @overload def allow( tool: types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, when: Predicate | None = None, name: str = "", ) -> list[Policy]: ... def allow( tool: str | types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, when: Predicate | None = None, name: str = "", ) -> Any: """Creates an APPROVE policy. Args: tool: Tool name, "*" for all tools, or BaseMcpServerConfig. mcp_tools: Optional list of tool names if BaseMcpServerConfig is provided. when: Optional argument predicate. name: Human-readable label. Returns: A Policy or a list of Policies. """ if isinstance(tool, str): if mcp_tools is not None: raise ValueError("mcp_tools cannot be specified when tool is a string.") return Policy(tool=tool, decision=Decision.APPROVE, when=when, name=name) # pytype: disable=bad-return-type # False positive: pytype fails to narrow overloaded types in implementation body. return _mcp_policies(Decision.APPROVE, tool, mcp_tools, when=when, name=name) # pytype: disable=bad-return-type # False positive: pytype fails to narrow overloaded types in implementation body. @overload def deny( tool: str, *, when: Predicate | None = None, name: str = "", reason: str = "", ) -> Policy: ... @overload def deny( tool: types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, when: Predicate | None = None, name: str = "", reason: str = "", ) -> list[Policy]: ... def deny( tool: str | types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, when: Predicate | None = None, name: str = "", reason: str = "", ) -> Any: """Creates a DENY policy. Args: tool: Tool name, "*" for all tools, or BaseMcpServerConfig. mcp_tools: Optional list of tool names if BaseMcpServerConfig is provided. when: Optional argument predicate. name: Human-readable label. reason: Optional human-readable explanation returned when the tool call is denied. Returns: A Policy or a list of Policies. """ if isinstance(tool, str): if mcp_tools is not None: raise ValueError("mcp_tools cannot be specified when tool is a string.") return Policy( tool=tool, decision=Decision.DENY, when=when, name=name, reason=reason ) return _mcp_policies( Decision.DENY, tool, mcp_tools, when=when, name=name, reason=reason ) @overload def ask_user( tool: str, *, handler: AskUserHandler | None = None, when: Predicate | None = None, name: str = "", reason: str = "", ) -> Policy: ... @overload def ask_user( tool: types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, handler: AskUserHandler | None = None, when: Predicate | None = None, name: str = "", reason: str = "", ) -> list[Policy]: ... def ask_user( tool: str | types.BaseMcpServerConfig, mcp_tools: Sequence[str] | None = None, *, handler: AskUserHandler | None = None, when: Predicate | None = None, name: str = "", reason: str = "", ) -> Any: """Creates an ASK_USER policy. Args: tool: Tool name, "*" for all tools, or BaseMcpServerConfig. mcp_tools: Optional list of tool names if BaseMcpServerConfig is provided. handler: Optional callable invoked to obtain user approval for the tool call. If omitted, confirmation is delegated to the host platform. Custom handlers receive the pending `ToolCall` and can optionally accept a `reason: str = ""` parameter explaining why confirmation was requested by the runtime (e.g. from safety assessment in auto policy mode). when: Optional argument predicate. name: Human-readable label. reason: Optional human-readable explanation passed to the handler when prompting the user. Returns: A Policy or a list of Policies. """ if isinstance(tool, str): if mcp_tools is not None: raise ValueError("mcp_tools cannot be specified when tool is a string.") return Policy( # pytype: disable=bad-return-type # False positive: pytype fails to narrow overloaded types in implementation body. tool=tool, decision=Decision.ASK_USER, when=when, ask_user=handler, name=name, reason=reason, ) return _mcp_policies( # pytype: disable=bad-return-type # False positive: pytype fails to narrow overloaded types in implementation body. Decision.ASK_USER, tool, mcp_tools, when=when, name=name, handler=handler, reason=reason, ) def allow_all() -> Policy: """Creates a policy that approves all tool calls without confirmation. Intended for autonomous agents and local development where interactive confirmation is not needed. Equivalent to ``allow("*")``. Returns: A Policy that approves every tool call. """ return allow(_WILDCARD, name="allow_all") def safe_defaults(handler: AskUserHandler) -> list[Policy]: """Creates a set of safe default policies. Allows all read-only tools and asks the user for any other tool calls. Args: handler: The handler to invoke for ASK_USER decisions. Returns: A list of Policies. """ read_only_tools = list(types.BuiltinTools.read_only()) + list( types.BuiltinTools.deprecated() ) return [allow(t.value) for t in read_only_tools] + [ ask_user("*", handler=handler) ] def deny_all() -> Policy: """Creates a policy that denies all tool calls. Use as a base rule with specific ``allow()`` overrides for a deny-by-default posture. Specific policies always take priority over wildcard policies, so ``[deny_all(), allow("view_file")]`` will allow only ``view_file`` and deny everything else. Returns: A Policy that denies every tool call. """ return deny(_WILDCARD, name="deny_all") def confirm_run_command( handler: AskUserHandler | None = None, ) -> list[Policy]: """Safe default: allows all tools, denies or confirms run_command. When no handler is given, ``run_command`` is denied outright — the agent sees the tool but calls are rejected with a clear message explaining how to enable it. When a handler is given, ``run_command`` calls trigger an ASK_USER flow instead. All other tools (file read/write, subagents, image generation, etc.) are allowed. This is the default policy for ``LocalAgentConfig``. Args: handler: Optional handler for ASK_USER on run_command. If None, run_command is denied. Returns: A list of Policies. """ if handler is not None: return [ ask_user( types.BuiltinTools.RUN_COMMAND.value, handler=handler, name="confirm_run_command", ), allow(_WILDCARD, name="confirm_run_command"), ] return [ deny(types.BuiltinTools.RUN_COMMAND.value, name="confirm_run_command"), allow(_WILDCARD, name="confirm_run_command"), ] PathOrStr = Union[str, os.PathLike[str]] def workspace_only(workspaces: Sequence[PathOrStr]) -> list[Policy]: """Restricts file tools to the given workspace directories. File read/write/create operations targeting paths outside any of the configured workspace directories are denied. Other tools are unaffected. Args: workspaces: Absolute paths of allowed workspace directories. Returns: A list of Policies. """ del workspaces file_tools = [t.value for t in types.BuiltinTools.file_tools()] return [deny(tool, name=_WORKSPACE_ONLY_POLICY_NAME) for tool in file_tools] def auto( *, name: str = "auto", handler: AskUserHandler | None = None, model: str | None = None, ) -> AutoPolicy: """Creates a policy enabling auto policy mode safety evaluation. When running in auto policy mode, commands and tool calls are assessed for safety by pre-tool safety assessors before execution. If flagged, execution is blocked or user confirmation is requested. Which tools are evaluated? Auto policy mode evaluates tools with inherent execution or network risk, such as shell command execution and external URL fetching. Standard read-only workspace operations (such as reading files or listing directories) are allowed without prompting. For file-write containment, pair ``policy.auto(...)`` with ``policy.workspace_only(workspaces)``. Evaluation order when combined with other policies: 1. Specific tool rules and server prefix rules (e.g. ``policy.allow("run_command")``, ``policy.deny("run_command")``, ``policy.workspace_only(...)``) run first and take precedence. 2. ``policy.auto(...)`` runs next for assessed tools and safety-flagged invocations. 3. Global wildcard rules (``policy.deny_all()``, ``policy.allow_all()``) run last as catch-all fallbacks for unassessed tools. Args: name: Human-readable label for logging. handler: Optional AskUserHandler invoked when auto policy mode flags a tool call. If None, flagged tool calls are denied. model: Optional Gemini model name used for safety evaluation. If None, the runtime's default safety assessment model configuration is used. Returns: An AutoPolicy object configured for auto policy mode. """ return AutoPolicy( name=name, ask_user=handler, model=model, ) # --------------------------------------------------------------------------- # Dynamic evaluation helpers # --------------------------------------------------------------------------- async def _evaluate_predicate( policy: Policy, tool_call: types.ToolCall ) -> bool: """Evaluates a policy's predicate. If the predicate is None, the policy always matches. Exceptions are propagated to the caller. Args: policy: The policy being evaluated. tool_call: The ToolCall instance. Returns: True if the predicate matches, False otherwise. """ if policy.when is None: return True sig = inspect.signature(policy.when) params = list(sig.parameters.values()) if params: first_param = params[0] # Resolve string annotations if future annotations are active try: hints = typing.get_type_hints(policy.when) annotation = hints.get(first_param.name, first_param.annotation) except (TypeError, NameError): annotation = first_param.annotation if isinstance(annotation, type) and issubclass( annotation, pydantic.BaseModel ): if issubclass(annotation, types.ToolCall): raw_result = policy.when(tool_call) else: typed_args = annotation.model_validate(tool_call.args) raw_result = policy.when(typed_args) else: raw_result = policy.when(tool_call.args) else: raw_result = policy.when() result = await raw_result if inspect.isawaitable(raw_result) else raw_result return bool(result) async def _execute_ask_user( policy: Policy, tool_call: types.ToolCall, reason: str = "", ) -> bool: """Invokes the policy's ask_user handler, propagating exceptions.""" assert policy.ask_user is not None handler = typing.cast(Callable[..., Any], policy.ask_user) try: sig = inspect.signature(handler) except (ValueError, TypeError): sig = None if sig is not None: try: bound = sig.bind(tool_call, reason=reason) except TypeError: try: bound = sig.bind(tool_call, reason) except TypeError: bound = sig.bind(tool_call) result = handler(*bound.args, **bound.kwargs) else: # If signature inspection is unavailable (e.g. C extensions or built-ins), # call without reason for stability. result = handler(tool_call) if inspect.isawaitable(result): result = await result return bool(result) def _matches_target(policy_tool: str, tool_call: types.ToolCall) -> bool: """Matches a policy tool definition against a tool call target.""" if policy_tool == _WILDCARD: return True if tool_call.server_name: if policy_tool.endswith("/*"): return policy_tool[:-2] == tool_call.server_name return policy_tool == f"{tool_call.server_name}/{tool_call.name}" return policy_tool == tool_call.name # --------------------------------------------------------------------------- # Hook implementation for in-process policy evaluation # --------------------------------------------------------------------------- class _PolicyDecideHook(hooks.PreToolCallDecideHook): """PreToolCallDecideHook that evaluates dynamic policies in-process.""" def __init__(self, policies: Sequence[Policy]): self._policies = list(policies) async def run( self, context: hooks.HookContext, data: types.ToolCall ) -> hooks.HookResult: """Evaluates dynamic policies sequentially against the tool call.""" del context tool_call = data for p in self._policies: if p.name == _WORKSPACE_ONLY_POLICY_NAME or p.auto: # Workspace boundary containment and auto policy mode safety evaluation # are enforced at the platform/execution layer. continue if not _matches_target(p.tool, tool_call): continue try: if not await _evaluate_predicate(p, tool_call): continue label = p.name or p.tool if p.decision == Decision.DENY: _logger.info("Policy %r denied tool %r.", label, tool_call.name) return hooks.HookResult( allow=False, message=p.reason or f"Denied by policy '{label}'.", ) if p.decision == Decision.APPROVE: _logger.info("Policy %r approved tool %r.", label, tool_call.name) return hooks.HookResult(allow=True) if p.decision == Decision.ASK_USER and p.ask_user is not None: _logger.info( "Policy %r requesting user approval for tool %r.", label, tool_call.name, ) approved = await _execute_ask_user(p, tool_call, reason=p.reason) if approved: return hooks.HookResult(allow=True) return hooks.HookResult( allow=False, message=( p.reason or f"User denied tool '{tool_call.name}' (policy '{label}')." ), ) except Exception as e: # pylint: disable=broad-exception-caught _logger.error( "Exception during policy %r evaluation — failing closed.", p.name or p.tool, exc_info=True, ) return hooks.HookResult( allow=False, message=( f"Policy evaluation failed for policy '{p.name or p.tool}':" f" {repr(e)}" ), ) return hooks.HookResult(allow=True) # --------------------------------------------------------------------------- # Private helpers for hook construction & proto serialization # --------------------------------------------------------------------------- def flatten_policies( policies: Sequence[Policy | Sequence[Policy]], ) -> list[Policy]: """Flattens nested sequences of policies into a flat list. This allows combining single Policy objects and lists of Policies (e.g. returned by overloaded builders) seamlessly in config declarations. Args: policies: A sequence of Policy objects or nested sequences of Policies. Returns: A flat list of Policy objects. """ flat = [] for p in policies: if isinstance(p, Policy): flat.append(p) elif isinstance(p, Sequence) and not isinstance(p, (str, bytes)): for sub_p in p: if not isinstance(sub_p, Policy): raise ValueError(f"Expected Policy, got {type(sub_p)}") flat.extend(p) else: raise ValueError( f"Expected Policy or Sequence of Policies, got {type(p)}" ) return flat # --------------------------------------------------------------------------- # Proto serialization # --------------------------------------------------------------------------- _DECISION_TO_PROTO = { Decision.APPROVE: localharness_pb2.POLICY_DECISION_ALLOW, Decision.DENY: localharness_pb2.POLICY_DECISION_DENY, Decision.ASK_USER: localharness_pb2.POLICY_DECISION_ASK_USER, } # TODO: Add explicit server_name field to Policy dataclass to # eliminate slashed "server/tool" string packing and remove _parse_tool_target. def _parse_tool_target(tool: str) -> tuple[str, str]: """Decomposes 'server/tool' format into (tool_name, server_name) for proto. Args: tool: The tool target string (e.g., "run_command", "*", "server/tool", "server/*"). Returns: A (tool_name, server_name) tuple for the PolicyRule proto fields. """ if tool == _WILDCARD: return ("*", "") if "/" in tool: server, tool_name = tool.split("/", 1) return (tool_name, server) return (tool, "") def _to_policy_config_proto( policies: Sequence[Policy | Sequence[Policy]], ) -> tuple[localharness_pb2.PolicyConfig, dict[str, Policy]]: """Serializes Python Policy objects into a PolicyConfig proto. Static rules (no condition function, not ASK_USER) are handled entirely by localharness with zero wire roundtrips. Dynamic rules (with dynamic callbacks or ASK_USER handlers) are tagged with a rule_id and require the SDK to evaluate the condition when localharness sends a PolicyDecisionRequest. Args: policies: The policies to serialize (can be nested). Returns: A tuple of (PolicyConfig proto, dynamic_policy_map). The dynamic_policy_map maps rule_id -> Policy for dynamic rules, used by the event processor to handle incoming PolicyDecisionRequest messages. """ flat = flatten_policies(policies) dynamic_policy_map: dict[str, Policy] = {} proto_rules: list[localharness_pb2.PolicyRule] = [] auto_policy: AutoPolicy | None = None has_workspace_only = False has_allow_all = False for i, p in enumerate(flat): if isinstance(p, AutoPolicy): if auto_policy is not None: raise ValueError( "Multiple AutoPolicy rules found; at most one policy.auto()" " rule may be specified." ) auto_policy = p continue # TODO: Remove _parse_tool_target once Policy has server_name. tool_name, server_name = _parse_tool_target(p.tool) is_workspace_only = p.name == _WORKSPACE_ONLY_POLICY_NAME if is_workspace_only: has_workspace_only = True if ( p.name == "allow_all" and p.tool == _WILDCARD and p.decision == Decision.APPROVE and p.when is None ): has_allow_all = True is_dynamic = ( p.when is not None or p.decision == Decision.ASK_USER ) and not is_workspace_only rule_id = "" if is_dynamic: rule_id = f"rule_{i}" dynamic_policy_map[rule_id] = p proto_rules.append( localharness_pb2.PolicyRule( tool=tool_name, server_name=server_name, decision=_DECISION_TO_PROTO[p.decision], name=p.name or p.tool, is_dynamic=is_dynamic, rule_id=rule_id, deny_reason=p.reason, ) ) auto_config = None if auto_policy is not None: auto_config = localharness_pb2.AutoPolicyConfig( enabled=True, model=auto_policy.model or "", ) dynamic_policy_map["auto"] = auto_policy workspace_containment = ( localharness_pb2.PolicyConfig.WORKSPACE_CONTAINMENT_UNSPECIFIED ) if has_allow_all and not has_workspace_only: workspace_containment = ( localharness_pb2.PolicyConfig.WORKSPACE_CONTAINMENT_DISABLED ) config = localharness_pb2.PolicyConfig( rules=proto_rules, workspace_containment=workspace_containment, auto_config=auto_config, ) return config, dynamic_policy_map # --------------------------------------------------------------------------- # Public factory # --------------------------------------------------------------------------- def _policy_sort_key(p: Policy) -> tuple[int, int]: """Returns a sort key ordering specific before wildcards, and DENY before APPROVE.""" scope = 2 if p.tool == _WILDCARD else (1 if p.tool.endswith("/*") else 0) decision_order = { Decision.DENY: 0, Decision.ASK_USER: 1, Decision.APPROVE: 2, } return (scope, decision_order.get(p.decision, 3)) def enforce( policies: Sequence[Policy | Sequence[Policy]], *, mcp_servers: Sequence[types.BaseMcpServerConfig] | None = None, ) -> hooks.PreToolCallDecideHook: """Creates a PreToolCallDecideHook that evaluates dynamic policies. Args: policies: The policies to enforce (can be nested). mcp_servers: Optional registered MCP server configurations to validate against MCP-targeted policies. Returns: A PreToolCallDecideHook ready for registration. Raises: ValueError: If any ASK_USER policy is missing a handler. """ flat_policies = sorted(flatten_policies(policies), key=_policy_sort_key) # Validate MCP policies against mcp_servers (Fail-Closed Security Guard) has_mcp_policy = any( ("/" in p.tool and p.tool != _WILDCARD) for p in flat_policies ) if has_mcp_policy and mcp_servers is None: raise ValueError( "MCP policies (containing '/') were detected, but 'mcp_servers' was not" " provided to enforce(). You must pass the registered MCP servers to" " enable secure policy matching and prevent silent bypasses." ) # Startup validation. for p in flat_policies: if p.decision == Decision.ASK_USER and p.ask_user is None: raise ValueError( f"ASK_USER policy '{p.name or p.tool}' is missing an ask_user" " handler. Provide one via policy.ask_user(tool, handler=...)." ) return _PolicyDecideHook(flat_policies)