capability_guard
capability_guard
¶
What an operator may be asked to do — checked before it is scheduled.
OperatorManifest.required_capabilities has been loaded from TOML since the
field was written and read by nobody: two producers (operators/loader.py
and recipes/composer.py), zero consumers. A declaration nobody checks is
what spec §5 forbids — and here it is worse than idle, because an operator
installed by a third party is scheduled to run unattended, on a tick, with
whatever tools it names.
Why the check lives at activation, and not where it looks like it should.
- Not at load:
OperatorManager.discover()swallows exceptions, so a refused manifest would vanish fromoperators listwithout a word. You must be able to see an operator you are not allowed to run. - Not at tick: the manager is not on that path.
TaskScheduler._execute_taskre-reads the task from the store and never consults the manifest — and it recordssuccess = Trueeven when every tool call was denied, so the refusal would land in a log that says it worked. - At activation: it is the one bottleneck both doors share
(
cli/operators_cmd.pyandcli/compose_cmd.py), and it is the only moment where a refusal can honestly say nothing has been scheduled.
Why the policy is the last of three checks, not the only one.
The roadmap said "connect to the existing RBAC CapabilityPolicy". Taken
literally that refuses every operator on a default install: with no policy
file, CapabilityPolicy(default_deny=True).check("operative", ...) is
False, because the grants an operator really runs under are the ones
ToolExecutor gives itself at construction — which has not happened yet
at activation time. Measured, not assumed.
So the policy is consulted only when an administrator actually supplied one. The two checks that work on every install are the ones above it: the vocabulary, and consistency with the tools the manifest names.
Classes¶
CapabilityVerdict
dataclass
¶
CapabilityVerdict(
unknown_verbs: tuple[str, ...] = (),
undeclared: tuple[tuple[str, str], ...] = tuple(),
denied: tuple[str, ...] = (),
implied: tuple[str, ...] = (),
policy_consulted: bool = False,
silently_implied: tuple[tuple[str, str], ...] = tuple(),
)
Why an operator was refused — or that it was not.
Three separate lists rather than one message: the caller formats them for a human, and each kind of fault has a different remedy.
Attributes¶
unknown_verbs
class-attribute
instance-attribute
¶
Declared verbs that are not operator capabilities at all.
undeclared
class-attribute
instance-attribute
¶
(tool, capability) the manifest uses without declaring.
denied
class-attribute
instance-attribute
¶
Declared verbs an administrator's policy refuses to operative.
implied
class-attribute
instance-attribute
¶
What the named tools require, declared or not — always computed.
policy_consulted
class-attribute
instance-attribute
¶
False when no policy file exists, so the caller can say so.
silently_implied
class-attribute
instance-attribute
¶
(tool, capability) a silent manifest uses without declaring anything.
Not a refusal — an empty field is still "no requirement". But without this, declaring nothing was the way to be checked by nothing, and the guard rewarded exactly the silence it exists to end. Naming what a silent manifest will actually be able to do is the least it can do.
OperatorRefused
¶
OperatorRefused(
operator_id: str, verdict: CapabilityVerdict
)
Bases: RuntimeError
An operator was refused before anything was scheduled.
The full explanation is the exception's message, so that both activation
doors — operators activate and compose deploy, which each print
Error: {exc} — say the useful thing without either of them knowing
anything about capabilities.
Source code in src/diapason/operators/capability_guard.py
Functions:¶
known_capabilities
¶
The operator vocabulary: exactly the tool one, and no other.
capabilities_of_tool
¶
What one tool really requires — declared and implied.
Reads through ToolExecutor._required_capabilities rather than
spec.required_capabilities alone, because several built-ins declare
nothing in their own spec and get their capability from
DEFAULT_TOOL_CAPABILITIES: memory_store is the trap — read its
spec and you conclude it requires nothing at all.
A tool that cannot be instantiated (an optional dependency is missing on this machine) falls back to that same default table. Refusing to activate because a tool could not be imported would punish the wrong thing.
Source code in src/diapason/operators/capability_guard.py
implied_capabilities
¶
Map each named tool to the capabilities it really requires.
check_manifest
¶
check_manifest(
manifest: "OperatorManifest",
*,
policy: Optional[Any] = None,
) -> CapabilityVerdict
Decide whether manifest may be scheduled.
An empty required_capabilities means "no requirement", not "refuse" —
the twelve bundled operators and recipes declare nothing, so fail-closed
would refuse all of them on day one. Making them declare is a decision
that belongs to whoever maintains them, not to this function.
The earlier version of this docstring justified that leniency by claiming
"the real authorisation is not lost — ToolExecutor still filters every
single tool call and fails closed without a policy". That is false, and
it was measured on 26 August 2026. Without a policy file,
ToolExecutor._grant_selected_tools_when_unmanaged grants each selected
tool exactly the capabilities it declares, scoped to its own name — so a
tool is authorised by having been selected. And a tool that declares no
capability is filtered by nothing at all, anywhere. Invoking a protection
that does not exist to excuse the absence of another is precisely the
defect this guard was written to close.
So the leniency stands, but it is now named for what it is: a silent
manifest is not verified, and silently_implied says so out loud rather
than letting the caller believe a check happened.
Fail-closed does apply to the vocabulary. mesh/capabilities.py drops
an unknown verb silently, and that tolerance is right there — a client can
be newer than the server. It is wrong here: the manifest and the policy
ship in the same package, so an unknown verb is a typo, and ignoring it
would rebuild the very defect this guard exists to close.
Source code in src/diapason/operators/capability_guard.py
explain
¶
explain(
operator_id: str, verdict: CapabilityVerdict
) -> str
Say what is wrong, what to change, and that nothing was scheduled.
"Nothing has been scheduled" is not politeness. It is the whole difference between refusing at activation and refusing at tick: without it, the user has no way to tell whether a half-installed operator is about to wake up in five minutes.
Source code in src/diapason/operators/capability_guard.py
avertissement
¶
avertissement(
operator_id: str, verdict: CapabilityVerdict
) -> Optional[str]
Ce qu'un manifeste silencieux pourra faire — ou None s'il n'y a rien.
Un verdict qui passe n'est pas un verdict qui a vérifié. Un manifeste qui
ne déclare rien traverse check_manifest sans qu'aucune cohérence soit
contrôlée, et déclarer un champ vide était donc le moyen de n'être
contrôlé par rien.
Refuser serait l'autre réponse, et elle reste ouverte : elle suppose de renseigner les douze manifestes livrés, ce qui appartient à qui les maintient. En attendant, nommer vaut mieux que taire — et surtout mieux que d'écrire dans un champ que personne ne lit, ce que ce garde a précisément été écrit pour corriger.