policy — capability registry and grantsAsk Metnos with a request like this example: “Explain which controls apply when I ask to read a file and which apply when I ask to modify it.” The answer must distinguish active runtime controls from the declarative matrix documented on this page.
runtime/policy.py provides a canonical registry, a
deterministic autonomy-level matrix, and persistent grants scoped to a target. These
APIs are available and inspectable, but they are not yet the general gate on the
ordinary execution path. Effective runtime authorization is documented by the
components that enforce it.
The policy module describes an authorization outcome for a tuple made of
an autonomy level, a capability, and a target. The result is allowed,
denied, or approval required. It is a deterministic model available
in the code; it must not be confused with a gate already applied to every executor.
The model separates three responsibilities assigned to different components:
This separation describes the subsystem design, not the order of the current
execution path. The current code uses the registry to validate capabilities declared
by manifests; it does not yet call effective_outcome as a general gate
before every execution.
Declarative contract. Active manifests declare capabilities from the
canonical registry. Effective controls remain distributed across manifest
validation, vaglio, approval dialogs, sandboxing, and dispatcher-specific rules;
this page does not attribute all of them to policy.py.
The registry is the closed dictionary of actions Metnos recognises. Canonical entries are defined in runtime/policy.py:CAPABILITY_REGISTRY. Each entry is a CapabilitySpec with four attributes:
name — the canonical name (format family:mode, e.g. fs:read);critical — True if the action is irreversible or high-stakes (write, send, exec);default_approval — class used to build the matrix: none, per_target, or always;target_kind — target type: path_glob, host, exact, none.The following table is a representative subset. The registry in the code and the
output of the registry command are the complete, current sources.
| name | critical | default_approval | target_kind | description |
|---|---|---|---|---|
compute:pure | no | none | none | deterministic in-memory computation without external I/O |
fs:read | no | per_target | path_glob | read files from the local filesystem within declared path_globs |
fs:write | yes | per_target | path_glob | write/modify files within path_globs (critical) |
code:exec | yes | always | exact | execute a shell command from a whitelist (e.g. package manager) |
network:http | no | per_target | host | HTTP/HTTPS GET/POST to authorised hosts |
llm:local | no | none | none | local LLM call (llama-server, llama.cpp), zero cost |
llm:online | no | per_target | none | online LLM call (Anthropic, OpenAI,...), cost > 0 |
mail:read | no | per_target | exact | read IMAP messages from an authorised mailbox |
mail:send | yes | always | exact | SMTP send to recipients (irreversible, high stakes) |
channel:in | no | none | exact | receive messages from a channel (Telegram, CLI, voice) |
channel:out | no | per_target | exact | send messages to a specific channel |
time:read | no | none | none | read current time and timezones |
parse:local | no | none | none | local parsing of known formats (PDF, HTML, JSON, CSV) |
calendar:read | no | per_target | exact | read events from an authorised calendar |
index:read | no | per_target | exact | read a local index managed by Metnos |
metnos:read | no | per_target | exact | read a local resource managed by Metnos |
metnos:cache | no | none | none | best-effort technical cache in a Metnos-managed subtree, with no effect on user data |
system:read | no | per_target | exact | read system diagnostics on an authorised host or device |
provider:access | no | per_target | exact | access the network and credentials of an authorised provider |
The registry is closed: record_grant rejects a capability outside it. Adding one means changing the registry in code and validating the manifests — there is no dynamic registration during execution. The action vocabulary is therefore a controlled contract.
provider:access uses the skill binding as its exact target. A manifest
when clause may narrow it to one backend of one invocation; policy never
derives this permission from the executor name or a free plan value.
The registry covers reads, writes, external-provider access, administrative operations, and human interaction. The critical flag and default_approval class contribute to the module's matrix. By themselves, they do not prove that the current execution path presents an approval prompt.
For example, compute:pure and time:read use none; fs:write uses per_target; and mail:send, mail:write, system:admin, and drive:permissions use always. Read the complete set from the registry rather than a hand-maintained list on this page.
The matrix is the product of the three autonomy levels (ReadOnly, Supervised, Full) and the registered capabilities. Each cell yields allowed, approval_required, or denied. _init_table generates it. The table below is an excerpt; the table command prints the complete matrix.
| capability | ReadOnly | Supervised | Full |
|---|---|---|---|
compute:pure | allowed | allowed | allowed |
fs:read | approval | approval | allowed |
fs:write | denied | approval | allowed |
code:exec | denied | approval | approval |
network:http | denied | approval | allowed |
llm:local | allowed | allowed | allowed |
llm:online | denied | approval | allowed |
mail:read | approval | approval | allowed |
mail:send | denied | approval | approval |
channel:in | allowed | allowed | allowed |
channel:out | denied | approval | allowed |
time:read | allowed | allowed | allowed |
parse:local | allowed | allowed | allowed |
calendar:read | approval | approval | allowed |
index:read | approval | approval | allowed |
metnos:read | approval | approval | allowed |
metnos:cache | allowed | allowed | allowed |
system:read | approval | approval | allowed |
provider:access | approval | approval | allowed |
The table is not arbitrary: it derives from three rules, one per level, that _init_table applies while iterating over the registry.
ReadOnly. Non-critical capabilities in class none are allowed. An explicit set of scoped reads and accesses — including fs:read, mail:read, calendar:read, network:sites, and provider:access — yields approval_required. Other critical or approval-class capabilities are denied.
Supervised. Capabilities in class none are allowed; all others yield approval_required. This is matrix semantics, not proof that the current dispatcher presents the corresponding dialog.
Full. Capabilities in class always still yield approval_required; all others are allowed. Current always entries include code:exec, mail:send, mail:write, system:admin, and drive:permissions.
An outcome is not the same as an enforced gate. The matrix retains
approval_required for class always even at Full. Until the
dispatcher uses this outcome as a general constraint, treat it as a module contract,
not a whole-system guarantee.
Reversibility is a contract separate from the autonomy matrix. A standard-compliant executor declaring revertible = true must name a reverse_pattern. The runtime keeps the execution data needed for reversal, and undo_last_turn attempts the inverse path while reporting failures.
The closed catalog in runtime/reverse_patterns.py includes, among others:
swap_src_dst — a move or rename is undone by swapping source and destination (filesystem paths and IMAP folders alike, the latter keyed by stable Message-ID).delete_created_dirs / delete_created_paths — a creation is undone by removing what was created, only while it is still empty/untouched.restore_blob_backup — before any destructive write the runtime stores a byte copy of the previous version at $METNOS_HISTORY_DIR/<turn>/blob/<sha256>.bin; the undo restores it. This is the "catch the change and keep a copy of the previous version" part, done once and deterministically.delete_*_by_id patterns — a creation on an external service is reversed using the identifier returned by the operation.For standard-compliant manifests, the validator checks consistency between the reversibility claim and the inverse pattern. Compatibility paths remain for some manually maintained executors, so it would be inaccurate to say that every mutating operation uses only the catalog.
An inverse pattern neither grants authority nor removes an approval requirement by itself. Reversibility, authorization, and isolation are separate controls.
The module also provides a SQLite store for scoped grants. Each record binds a channel, sender, capability, target, and optional expiry. The store exists and can be queried; the ordinary approval path does not yet write grants to it.
The schema is defined by runtime/policy.py:SCHEMA:
CREATE TABLE IF NOT EXISTS grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel TEXT NOT NULL, sender_id TEXT NOT NULL, capability TEXT NOT NULL, target TEXT NOT NULL, granted_at TEXT NOT NULL, expires_at TEXT, granted_by TEXT, revoked_at TEXT );
A grant is identified by the tuple (channel, sender_id, capability, target). Dates granted_at, expires_at, and revoked_at are UTC. Logical user separation depends on the caller supplying the correct channel and sender.
The default file is ~/.local/state/metnos/grants.db for the installation user; METNOS_GRANTS_DB can select another path. Multiple application users served by one process therefore share the physical container while rows remain separated by the identity supplied to the API.
| function | what it does |
|---|---|
record_grant(channel, sender_id, capability, target, expires_at=None, granted_by=None) |
Records a concession. Raises ValueError if capability is not in the registry. Returns the Grant object with assigned id. |
has_grant(channel, sender_id, capability, target) |
True if an active grant (not revoked, not expired) exists for the four-tuple. The query compares expires_at with the current time. |
list_grants(channel=None, sender_id=None, include_revoked=False) |
Lists grants, filterable by channel/sender, optionally including revoked. Sorted by granted_at descending. |
revoke_grant(grant_id) |
Sets revoked_at to the current time. Returns True if something was modified, False if the grant was already revoked or did not exist. |
The functions open and close one connection per call; they keep no global SQLite session.
The current runtime has no production callers of record_grant, and the
planner does not consult effective_outcome. The table can be populated
by calling the module API directly, but no active administration surface does so,
and it must not be described as the memory of user confirmations.
effective_outcome)
The effective_outcome function combines the matrix and grants into one
result. It is available to module callers and the CLI; it is not currently the
planner's entry point.
| table says | active grant for (channel, sender, target)? | outcome |
|---|---|---|
| allowed | indifferent, the DB is not queried | allowed |
| denied | indifferent, the DB is not queried | denied |
| approval_required | yes | allowed |
| approval_required | no (or scope parameters missing) | approval_required |
The logic is linear: if the table already decides cleanly (allowed or denied), the grant is not even consulted; if it decides approval_required, an active grant turns it into allowed, otherwise it stays approval_required.
Function invariant: a grant never turns denied into
allowed. It can turn approval_required into
allowed.
Known limitation. When it finds an active grant,
effective_outcome does not distinguish per_target from
always. A capability classified as always would therefore be
elevated to allowed if a caller recorded such a grant. Before connecting
the module to the dispatcher, this case must be prevented or given an explicit,
different meaning.
Module evaluation, not turn execution.
The command check Supervised fs:write, without identity and target,
returns approval_required. The command
check ReadOnly fs:write returns denied. These results show
the matrix; they neither save a file nor open a chat dialog.
The module participates in the runtime at these points:
CAPABILITY_REGISTRY to reject unknown names;effective_outcome is called by the CLI, but not by the ordinary planner or dispatcher path;approval_registry and its channel renderer have no production caller that creates pending requests;This grant store must therefore not be interpreted as the planner's active
authorization mechanism. Any caller must preserve exact actor and target
identity, enforce per-user isolation, handle always classes
correctly, and use a localized semantic renderer.
From the repository root, inspect the module with the Metnos environment. It exposes five subcommands.
| command | what it does |
|---|---|
PYTHONPATH=runtime ./.venv/bin/python -m policy registry | prints one JSON line per capability with all attributes. |
PYTHONPATH=runtime ./.venv/bin/python -m policy table | prints one JSON line per level with every capability outcome. |
PYTHONPATH=runtime ./.venv/bin/python -m policy check <level> <capability> [--channel C --sender S --target T] | prints effective_outcome. Without identity and target, it shows the matrix-only result. |
PYTHONPATH=runtime ./.venv/bin/python -m policy grants [--channel C] [--sender S] [--all] | lists active grants; --all includes revoked rows. |
PYTHONPATH=runtime ./.venv/bin/python -m policy revoke <grant_id> | revokes a grant by identifier. |
The registry, table, and grants commands emit JSON Lines and can be filtered with jq.
Module verification comprises:
registry, table, and the desired check cases;get_approval dialog separately, because it is the active mechanism and does not depend on this grant store.denied; handling of always classes still needs correction
before integration.The module's current value is narrow but concrete: a closed registry, deterministic matrix, and structured store. The public guarantee remains limited to what the runtime actually wires in.
Presenting this API as a general gate before it is connected to the dispatcher would claim more than the demonstrated behavior.