"""Explicit, policy-bounded tools for local inference agents. **Nothing is approved until an operator approves it, so the default registry is still empty.** That has not changed, and it is what keeps the runtime's no-outbound-request claim literally true out of the box. What has changed is that there is now something to approve. This package is the tool *framework* and a small, evidence-chosen tool set: * versioned specs with JSON schemas, exact ``id@version`` references, per-job policy (allowlist, call quotas, timeouts, byte caps, host permissions) and a per-job :class:`ToolBroker` that snapshots the registry so a later registration cannot widen a running job; * :mod:`distinct_tools.local`, six local tools that open no socket, read no file and spawn no process: ``calculate``, ``calculate_date``, ``convert_units``, ``extract_from_text``, ``search_document`` and ``verify_quote``. Every one answers in a single call, which is the criterion that decides what is viable below 7B; * :mod:`distinct_tools.approval`, the operator's own allowlist, which is checked before the server's and cannot be widened remotely; * :mod:`distinct_tools.mcp`, which maps Model Context Protocol servers onto the same specs, brokers and policies rather than beside them; * :mod:`distinct_tools.trace`, a bounded record of every call attempted, including every refusal. Two more sources of library members exist outside this package and reach the registry through :func:`default_registry`: :mod:`distinct_skills`, a repository of skills held on disk and digest-pinned, and :mod:`distinct_mcp`, four in-process MCP servers. Both produce ordinary specs in the ordinary registry. **The three sources are not approved the same way, and the difference is the whole safety argument.** ``DISTINCT_APPROVED_TOOLS=local`` is a bundle name, and :func:`~distinct_tools.approval.load_policy` expands it to "every spec in the list it was given that declares no host". So the list handed to ``load_policy`` decides what one word approves, and it is deliberately :data:`INSTALLABLE_SPECS` and nothing else. A repository skill or an MCP tool is reachable only by naming its exact ``id@version``: they are filtered with ``policy.permitted_specs(...)``, which works on refs already approved and never expands a bundle. Widening the list passed to ``load_policy`` would silently put ``files.write_file@1``, which reads and writes a directory on a volunteer's machine, inside a single flag. There is a test that fails if anybody does. **The catalogue is small on purpose.** :data:`~distinct_tools.approval.MAX_APPROVED_TOOLS` caps one operator at 64 approved refs, because a large menu costs selection accuracy on a small model. Everything this repository can offer today, the ten library members, the four repository skills and the eight MCP tools, comes to twenty-two, so the cap is not currently reachable by approving everything. At the boundary the failure is closed and loud rather than partial: ``OperatorPolicy`` raises, ``load_policy`` catches it, and what comes back approves nothing at all with the reason in ``source``. A truncated approval list would be worse, because the operator would believe they had approved something they had not. Three gates stand between a tool existing and a tool running, and all three must open: 1. it is **installed** (registered, and for MCP its definition digest still matches what was approved); 2. the **operator** approved it, and separately approved network access if it declares any host; 3. the **run** selected it, through ``JobSpec.allowed_tools``. Any tool failing any gate fails closed and the attempt is recorded on :attr:`ToolBroker.trace`. With nothing approved, gate 2 refuses everything, no reference resolves, and the agent makes no outbound request. :mod:`distinct_tools.testing` holds inert fixtures used to keep the framework's own tests honest. Nothing there is ever auto-registered. """ from collections.abc import Mapping from .approval import ( APPROVED_EGRESS_ENV_VAR, APPROVED_TOOLS_ENV_VAR, DENY_ALL, LOCAL_BUNDLE, POLICY_FILE_ENV_VAR, OperatorPolicy, ToolPolicyError, load_policy, ) from .core import ( JobPolicy, OperatorRefusal, Registry, ToolBroker, ToolCallError, ToolContext, ToolInputError, ToolRef, ToolResult, ToolSpec, normalize_host, ) from .local import LOCAL_SPECS, register_local_tools from .skills import ( REPOSITORY_SKILLS, REPOSITORY_SPECS, REPOSITORY_STATUS, SKILL_SPECS, describe_skill_repository, install_skill_repository, register_skills, ) from .workspace import WORKSPACE_SPECS, register_workspace_tools from .trace import ToolCallRecord, ToolTrace, describe_for_agent #: The library members written in this package: the local tools and the local #: skills. Tools and skills are one library behind one policy; a skill differs #: only in returning an artifact for the user rather than an in-band answer. #: #: This tuple is also the list the ``local`` bundle expands over, which is why #: the repository skills and the MCP tools are not in it. Both of those are #: installed by :func:`default_registry` when an operator names them in full, #: and neither can be reached by the bundle name. INSTALLABLE_SPECS: tuple[ToolSpec, ...] = LOCAL_SPECS + SKILL_SPECS + WORKSPACE_SPECS def operator_policy(*, environ: Mapping[str, str] | None = None) -> OperatorPolicy: """Read the operator's approval policy for the installable tool set.""" return load_policy(INSTALLABLE_SPECS, environ=environ) def _install_mcp_catalogue( registry: Registry, policy: OperatorPolicy, environ: Mapping[str, str] | None, ) -> tuple[frozenset[ToolRef], tuple[str, ...]]: """Install the MCP tools the operator named, and report what was not. Returns the refs the catalogue recognised, installed or not, so the caller can tell an approved ref that this package could not offer apart from one that nothing anywhere has heard of. """ # Nothing was approved that the two local sources do not already cover, so # there is no MCP work to do. Returning before the import keeps an # operator who never asked for MCP from being told about it, and it is the # difference between "you approved an MCP tool and could not have it" and # a message about a package they were not using. known = frozenset(spec.ref for spec in INSTALLABLE_SPECS) known |= frozenset(spec.ref for spec in REPOSITORY_SPECS) unknown = policy.approved - known if not unknown: return frozenset(), () # Imported here rather than at the top of the module because # ``distinct_mcp`` imports ``distinct_tools``, and the cycle is the only # reason for the deferral. It is worth being exact about how it fails, # because half the time it does not: with the import at module level, # ``import distinct_tools`` still works, and ``import distinct_mcp`` first # raises ImportError for a partially initialised module, because this # module then runs part-way through that one. Anybody who tries the move # and checks only the first order will conclude it is fine. try: from distinct_mcp import install_catalogue except ImportError as exc: # Every unresolved ref is counted as accounted for here, even though # one of them may be a plain typo. Without the package there is no way # to tell the two apart, and the message below is the true one; adding # "check the spelling" on top of it would send the operator looking for # a mistake they did not make. return unknown, ( f"MCP tools were approved but distinct_mcp could not be imported ({exc}), " "so none of them is installed and every call to them fails closed", ) result = install_catalogue( registry, only=policy.approved, egress=policy.egress_approved, environ=environ ) return frozenset(result.refs) | frozenset(result.skipped), result.notes def _build_registry( environ: Mapping[str, str] | None = None, ) -> tuple[Registry, tuple[str, ...]]: """Build the registry and collect anything the operator should be told. Split from :func:`default_registry` so that the notes have somewhere to go. A capability an operator approved and did not get is exactly the fact they need, and returning only a registry leaves nowhere to put it. """ registry = Registry() notes: list[str] = [] # LOAD-BEARING: the policy is read against INSTALLABLE_SPECS and nothing # else, because the ``local`` bundle expands over whatever list it is # given. Adding the repository or the MCP specs here would put a skill # read off the disk, and a tool that writes files, inside one flag. policy = load_policy(INSTALLABLE_SPECS, environ=environ) if not policy.approved: return registry, () approved = frozenset(spec.ref for spec in policy.permitted_specs(INSTALLABLE_SPECS)) if approved: register_local_tools(registry, only=approved) register_skills(registry, only=approved) register_workspace_tools(registry, only=approved) # permitted_specs filters refs the operator already approved and expands # nothing, so this cannot widen the bundle however the repository grows. repository = frozenset(spec.ref for spec in policy.permitted_specs(REPOSITORY_SPECS)) if repository: try: install_skill_repository(registry, only=repository) except (ValueError, RuntimeError) as exc: # A duplicate identifier or an unresolvable handler: both are bugs # in this repository rather than operator error. The worker still # runs everything else, and says which one it dropped, because a # worker that refuses every tool over one broken skill is worse. notes.append(f"the skill repository could not be installed ({exc})") accounted, mcp_notes = _install_mcp_catalogue(registry, policy, environ) notes.extend(mcp_notes) installed = frozenset(spec.ref for spec in registry.specs()) unmatched = sorted( policy.approved - installed - accounted, key=lambda ref: (ref.tool_id, ref.version) ) for ref in unmatched: # Almost always a typo. Saying so beats leaving the operator to work # out why a tool they approved never appears in what the worker offers. notes.append( f"{ref.tool_id}@{ref.version} is approved but nothing installs it; check the " "spelling against the list of approvable refs" ) return registry, tuple(notes) def default_registry(*, environ: Mapping[str, str] | None = None) -> Registry: """Return the agent's tool registry: only what the operator approved. There is still no entry-point discovery, import scanning or dynamic module loading, and no credential-gated registration. A tool appears here only because a human named it in :data:`~distinct_tools.approval.APPROVED_TOOLS_ENV_VAR` or a policy file. With neither set, this returns an empty registry and every tool call fails closed. Three sources feed it and all three obey the same gate. The library members in :data:`INSTALLABLE_SPECS` are registered directly; the skills in :mod:`distinct_skills` are installed from their pinned directories; the MCP servers in :mod:`distinct_mcp` are built and installed. A server that is approved but cannot be built is skipped rather than fatal, and the reason is available from :func:`registry_notes`. Re-read on each call rather than cached, so an operator who changes their approvals does not have to restart the worker. A run already in flight is unaffected: :class:`ToolBroker` snapshots the registry at construction. Building an MCP server here is still discovery at install time in the sense :mod:`distinct_tools.mcp` means it: it happens before the job's broker exists, the broker then snapshots, and no run ever negotiates with a server. The servers are in-process code from this repository, so the listing costs no I/O beyond the file server resolving its directory. """ return _build_registry(environ)[0] def registry_notes(*, environ: Mapping[str, str] | None = None) -> tuple[str, ...]: """Say what the operator approved and did not get, and why. Empty when everything approved was installed. Anything here is a capability the worker is not offering despite being told to, which is a fact that has to reach a person: the alternatives are failing open, or a silence that reads as success. """ return _build_registry(environ)[1] def approvable_refs() -> tuple[str, ...]: """Every ``id@version`` an operator could name today, from all three sources. Not filtered by what is approved: this is the menu, not the order. The MCP half is derived from the catalogue's manifests rather than by building the servers, so the file server appears here whether or not a directory has been nominated for it yet. """ refs = {f"{spec.tool_id}@{spec.version}" for spec in INSTALLABLE_SPECS} refs |= {f"{spec.tool_id}@{spec.version}" for spec in REPOSITORY_SPECS} try: from distinct_mcp import catalogue_refs # deferred: see _install_mcp_catalogue except ImportError: # pragma: no cover - distinct_mcp is part of this repository return tuple(sorted(refs)) return tuple(sorted(refs | set(catalogue_refs()))) def available_tool_refs(*, environ: Mapping[str, str] | None = None) -> tuple[str, ...]: """Detect approved, installed capabilities as sorted ``id@version`` refs. This is what the worker advertises. A tool the operator has not approved is never advertised, so a server cannot select it in the first place; the broker refuses it again if one tries anyway. """ return tuple(sorted(f"{spec.tool_id}@{spec.version}" for spec in default_registry(environ=environ).specs())) __all__ = [ "APPROVED_EGRESS_ENV_VAR", "APPROVED_TOOLS_ENV_VAR", "DENY_ALL", "INSTALLABLE_SPECS", "REPOSITORY_SKILLS", "REPOSITORY_SPECS", "REPOSITORY_STATUS", "describe_skill_repository", "install_skill_repository", "LOCAL_BUNDLE", "LOCAL_SPECS", "POLICY_FILE_ENV_VAR", "JobPolicy", "OperatorPolicy", "OperatorRefusal", "Registry", "ToolBroker", "ToolCallError", "ToolCallRecord", "ToolContext", "ToolInputError", "ToolPolicyError", "ToolRef", "ToolResult", "ToolSpec", "ToolTrace", "approvable_refs", "available_tool_refs", "default_registry", "describe_for_agent", "SKILL_SPECS", "load_policy", "normalize_host", "operator_policy", "register_local_tools", "register_skills", "register_workspace_tools", "registry_notes", "WORKSPACE_SPECS", ]