File size: 11,086 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
"""The operator's own tool allowlist, enforced independently of any server.

There are two parties who get a say in which tools a run may use, and they are
not the same party:

* the **server** proposes, per run, through ``JobSpec.allowed_tools``;
* the **operator** approves, once, through this module.

A tool runs only if both agree and it is actually installed.  The operator's
list is the one that cannot be widened remotely: a compromised or simply
mistaken server can name any tool it likes and will be refused, because the
worker checks its own list before it checks the job's.  This is the same
argument as PLAN.md §4.7 about per-server credential scoping, applied to
capability rather than credentials.

**The default is to approve nothing.** A freshly installed agent advertises no
tools and every call fails closed.  That is not a degraded state; it is what
keeps ``distinct_tools``' claim in ``__init__`` literally true until an
operator decides otherwise.  Approving the local bundle is one flag.  Approving
anything that reaches the network is a separate, second decision, because the
runtime's no-outbound-request property is exactly what it costs.
"""

from __future__ import annotations

import json
import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path

from .core import ToolRef, ToolSpec

#: Comma-separated ``id@version`` refs, or the bundle name ``local``.
APPROVED_TOOLS_ENV_VAR = "DISTINCT_APPROVED_TOOLS"

#: Comma-separated ``id@version`` refs additionally cleared to reach the
#: network.  Being in this list does nothing on its own; a tool must also be
#: in the approved list.
APPROVED_EGRESS_ENV_VAR = "DISTINCT_APPROVED_EGRESS_TOOLS"

#: Path to a JSON policy file, which takes precedence over the variables.
POLICY_FILE_ENV_VAR = "DISTINCT_TOOL_POLICY"

#: Name for "every local tool that declares no hosts".  Expanded against the
#: installed registry at load time, so it can never silently come to include a
#: networked tool added later: expansion filters on ``required_hosts``.
LOCAL_BUNDLE = "local"

MAX_APPROVED_TOOLS = 64


class ToolPolicyError(ValueError):
    """A policy could not be read. Callers must fail closed, not continue."""


def _parse_ref(value: object) -> ToolRef:
    if not isinstance(value, str) or value.count("@") != 1:
        raise ToolPolicyError(
            f"approved tool {value!r} must use the exact id@version form, for example calculate@1"
        )
    tool_id, version = value.split("@", 1)
    try:
        return ToolRef(tool_id.strip(), version.strip())
    except (TypeError, ValueError) as exc:
        raise ToolPolicyError(f"approved tool {value!r} is malformed: {exc}") from exc


def _split(value: object) -> tuple[str, ...]:
    if value is None:
        return ()
    if isinstance(value, str):
        return tuple(item.strip() for item in value.split(",") if item.strip())
    if isinstance(value, Sequence):
        return tuple(str(item).strip() for item in value if str(item).strip())
    raise ToolPolicyError("tool policy entries must be a list or a comma-separated string")


@dataclass(frozen=True)
class OperatorPolicy:
    """Which exact tool versions this worker's operator has approved.

    ``approved`` is exhaustive: a ref absent from it is refused, whatever the
    registry contains and whatever a server asks for.  ``egress_approved`` is a
    second gate that only matters for tools declaring ``required_hosts``.
    """

    approved: frozenset[ToolRef] = frozenset()
    egress_approved: frozenset[ToolRef] = frozenset()
    source: str = "default (nothing approved)"
    rejected: tuple[str, ...] = field(default_factory=tuple)

    def __post_init__(self) -> None:
        approved = frozenset(self.approved)
        if len(approved) > MAX_APPROVED_TOOLS:
            raise ToolPolicyError(
                f"at most {MAX_APPROVED_TOOLS} tools may be approved; "
                "a large local catalogue costs selection accuracy on small models"
            )
        object.__setattr__(self, "approved", approved)
        egress = frozenset(self.egress_approved)
        stray = egress.difference(approved)
        if stray:
            # Clearing a tool for the network without approving the tool is
            # almost certainly a mistake, and guessing which half was meant
            # would be guessing in the permissive direction.
            names = ", ".join(sorted(f"{ref.tool_id}@{ref.version}" for ref in stray))
            raise ToolPolicyError(
                f"egress approval names tools that are not approved at all: {names}"
            )
        object.__setattr__(self, "egress_approved", egress)
        object.__setattr__(self, "rejected", tuple(str(item)[:200] for item in self.rejected))

    def permits(self, ref: ToolRef) -> bool:
        return ref in self.approved

    def permits_egress(self, ref: ToolRef) -> bool:
        return ref in self.egress_approved

    def refusal_reason(self, spec: ToolSpec) -> str | None:
        """Return why ``spec`` may not run, or ``None`` if it may.

        Separate from :meth:`permits` because the reason is what the operator
        and the user need; a bare boolean turns two different problems into one
        indistinguishable failure.
        """

        ref = spec.ref
        if ref not in self.approved:
            return (
                f"tool {ref.tool_id}@{ref.version} is not on this worker's approved list; "
                "the operator must approve it before any run can use it"
            )
        if spec.required_hosts and ref not in self.egress_approved:
            hosts = ", ".join(sorted(spec.required_hosts))
            return (
                f"tool {ref.tool_id}@{ref.version} needs outbound access to {hosts} and this "
                "worker has not approved network access for it"
            )
        return None

    def permitted_specs(self, specs: Iterable[ToolSpec]) -> tuple[ToolSpec, ...]:
        """Filter installed specs down to what may actually be advertised."""

        return tuple(
            spec
            for spec in sorted(specs, key=lambda item: (item.tool_id, item.version))
            if self.refusal_reason(spec) is None
        )

    def advertised_refs(self, specs: Iterable[ToolSpec]) -> tuple[str, ...]:
        return tuple(f"{spec.tool_id}@{spec.version}" for spec in self.permitted_specs(specs))

    def describe(self, specs: Iterable[ToolSpec] = ()) -> str:
        """One block of text for the agent console at start-up.

        Says what is on, what is off and what was thrown out, because an
        operator who cannot see the effective policy cannot check it.
        """

        # Materialised once: ``specs`` may be a generator, and walking it twice
        # would silently report an empty second half.
        installed = tuple(specs)
        permitted = self.permitted_specs(installed)
        lines = [f"Tool approval policy: {self.source}"]
        if not permitted:
            lines.append("  No tools are approved. Every tool call will fail closed.")
        for spec in permitted:
            marker = "network" if spec.required_hosts else "local, no egress"
            lines.append(f"  {spec.tool_id}@{spec.version} ({marker})")
        for spec in sorted(installed, key=lambda item: (item.tool_id, item.version)):
            reason = self.refusal_reason(spec)
            if reason is not None:
                lines.append(f"  withheld: {spec.tool_id}@{spec.version}: {reason}")
        for item in self.rejected:
            lines.append(f"  ignored malformed policy entry: {item}")
        return "\n".join(lines)


#: Approves nothing. The value a caller should fall back to on any error.
DENY_ALL = OperatorPolicy()


def _expand(
    names: Sequence[str],
    installed: Sequence[ToolSpec],
    *,
    rejected: list[str],
) -> set[ToolRef]:
    local_refs = {spec.ref for spec in installed if not spec.required_hosts}
    refs: set[ToolRef] = set()
    for name in names:
        if name == LOCAL_BUNDLE:
            # Expanded against what is installed *and* declares no hosts, so
            # the bundle cannot quietly grow a networked member.
            refs.update(local_refs)
            continue
        try:
            refs.add(_parse_ref(name))
        except ToolPolicyError as exc:
            rejected.append(str(exc))
    return refs


def load_policy(
    installed: Iterable[ToolSpec] = (),
    *,
    environ: Mapping[str, str] | None = None,
) -> OperatorPolicy:
    """Read the operator's policy, or return :data:`DENY_ALL`.

    Precedence: the policy file named by :data:`POLICY_FILE_ENV_VAR`, else the
    two environment variables, else nothing approved.  Every failure path ends
    at ``DENY_ALL`` with a reason in ``source``: a policy that could not be
    read must never be treated as a permissive one.
    """

    values = os.environ if environ is None else environ
    specs = tuple(installed)
    rejected: list[str] = []

    path_value = values.get(POLICY_FILE_ENV_VAR, "").strip()
    if path_value:
        try:
            raw = Path(path_value).expanduser().read_text(encoding="utf-8")
            document = json.loads(raw)
        except (OSError, ValueError) as exc:
            return OperatorPolicy(
                source=f"{path_value} could not be read ({type(exc).__name__}); nothing approved"
            )
        if not isinstance(document, Mapping):
            return OperatorPolicy(source=f"{path_value} is not a JSON object; nothing approved")
        try:
            approved = _expand(_split(document.get("approved")), specs, rejected=rejected)
            egress = _expand(_split(document.get("egress")), specs, rejected=rejected)
            return OperatorPolicy(
                approved=frozenset(approved),
                egress_approved=frozenset(egress.intersection(approved)),
                source=f"{path_value}",
                rejected=tuple(rejected),
            )
        except ToolPolicyError as exc:
            return OperatorPolicy(source=f"{path_value} was rejected ({exc}); nothing approved")

    approved_names = _split(values.get(APPROVED_TOOLS_ENV_VAR))
    if not approved_names:
        return OperatorPolicy()
    egress_names = _split(values.get(APPROVED_EGRESS_ENV_VAR))
    try:
        approved = _expand(approved_names, specs, rejected=rejected)
        egress = _expand(egress_names, specs, rejected=rejected)
        return OperatorPolicy(
            approved=frozenset(approved),
            egress_approved=frozenset(egress.intersection(approved)),
            source=f"{APPROVED_TOOLS_ENV_VAR}",
            rejected=tuple(rejected),
        )
    except ToolPolicyError as exc:
        return OperatorPolicy(source=f"{APPROVED_TOOLS_ENV_VAR} was rejected ({exc}); nothing approved")


__all__ = [
    "APPROVED_EGRESS_ENV_VAR",
    "APPROVED_TOOLS_ENV_VAR",
    "DENY_ALL",
    "LOCAL_BUNDLE",
    "MAX_APPROVED_TOOLS",
    "POLICY_FILE_ENV_VAR",
    "OperatorPolicy",
    "ToolPolicyError",
    "load_policy",
]