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",
]
|