Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
"""Plugin capability declarations + consent state (#64228).
|
||||
|
||||
Unifies the scattered per-plugin trust gates (``plugins.entries.<id>.allow_*``)
|
||||
into one declared, diffable **capability model** with install/update-time
|
||||
consent.
|
||||
|
||||
**This is NOT a sandbox.** In-process Python plugins remain trusted code — a
|
||||
malicious plugin can import anything, monkey-patch core, and ignore all of
|
||||
this. Capabilities govern the *host API surfaces* Hermes hands out (which
|
||||
registrations succeed, which ``ctx`` methods are live) and give the user an
|
||||
honest consent + audit trail. Actual isolation is a separate research track.
|
||||
|
||||
Canonical registry
|
||||
------------------
|
||||
Every capability id maps 1:1 to a trust gate that **already exists** on the
|
||||
enforcing surface. We deliberately do not mint capability ids without an
|
||||
enforcing gate:
|
||||
|
||||
=========================== ==================================================
|
||||
Capability id Legacy config gate (``plugins.entries.<id>.…``)
|
||||
=========================== ==================================================
|
||||
``tools.override`` ``allow_tool_override``
|
||||
``llm.provider_override`` ``llm.allow_provider_override``
|
||||
``llm.model_override`` ``llm.allow_model_override``
|
||||
``llm.agent_id_override`` ``llm.allow_agent_id_override``
|
||||
``llm.profile_override`` ``llm.allow_profile_override``
|
||||
``llm.task_override`` ``llm.allow_task_override``
|
||||
``gateway.platform_actions`` ``allow_platform_actions``
|
||||
=========================== ==================================================
|
||||
|
||||
The legacy ``allow_*`` keys keep working verbatim (deprecated but honored):
|
||||
a gate is open when the legacy key is true **or** the capability is granted.
|
||||
|
||||
Consent state
|
||||
-------------
|
||||
Stored under the plugin's config entry::
|
||||
|
||||
plugins:
|
||||
entries:
|
||||
<plugin_id>:
|
||||
granted_capabilities: [tools.override]
|
||||
capabilities_consent:
|
||||
hash: "<sha256 of the declared capability set at consent time>"
|
||||
granted_at: "2026-08-12T00:00:00+00:00"
|
||||
|
||||
The hash records *what the user saw* when they consented. When an update
|
||||
declares capabilities whose set hash differs, the additions stay ungranted
|
||||
until the user re-consents (``hermes plugins update`` surfaces the diff).
|
||||
|
||||
Ground rule: everything defaults OFF. Any failure to read consent state
|
||||
(missing config, corrupt YAML, wrong types) means **not granted**.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilitySpec:
|
||||
"""One declarable capability and the legacy gate it maps to."""
|
||||
|
||||
id: str
|
||||
# Path of the deprecated boolean under ``plugins.entries.<plugin_id>``,
|
||||
# e.g. ("allow_tool_override",) or ("llm", "allow_model_override").
|
||||
legacy_path: Tuple[str, ...]
|
||||
# One-line risk description shown on the consent screen.
|
||||
description: str
|
||||
|
||||
|
||||
# Canonical registry — ONLY capabilities with an existing enforcing surface.
|
||||
CAPABILITY_REGISTRY: Dict[str, CapabilitySpec] = {
|
||||
spec.id: spec
|
||||
for spec in (
|
||||
CapabilitySpec(
|
||||
id="tools.override",
|
||||
legacy_path=("allow_tool_override",),
|
||||
description=(
|
||||
"Replace built-in tools (e.g. shell_exec, write_file) — an "
|
||||
"override can intercept everything routed through that tool"
|
||||
),
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="llm.provider_override",
|
||||
legacy_path=("llm", "allow_provider_override"),
|
||||
description=(
|
||||
"Run host-owned LLM calls against a provider other than your "
|
||||
"active one (uses your credentials)"
|
||||
),
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="llm.model_override",
|
||||
legacy_path=("llm", "allow_model_override"),
|
||||
description=(
|
||||
"Choose which model host-owned LLM calls use (spend follows "
|
||||
"the chosen model)"
|
||||
),
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="llm.agent_id_override",
|
||||
legacy_path=("llm", "allow_agent_id_override"),
|
||||
description="Attribute its LLM calls to a different agent id",
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="llm.profile_override",
|
||||
legacy_path=("llm", "allow_profile_override"),
|
||||
description="Run LLM calls under a different auth profile",
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="llm.task_override",
|
||||
legacy_path=("llm", "allow_task_override"),
|
||||
description=(
|
||||
"Route its LLM calls through the host's built-in auxiliary "
|
||||
"task lanes"
|
||||
),
|
||||
),
|
||||
CapabilitySpec(
|
||||
id="gateway.platform_actions",
|
||||
legacy_path=("allow_platform_actions",),
|
||||
description=(
|
||||
"Act on connected chat platforms as the gateway bot "
|
||||
"(add reactions, rename threads) via ctx.platform_actions"
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
VALID_CAPABILITY_IDS = frozenset(CAPABILITY_REGISTRY)
|
||||
|
||||
# Config keys under ``plugins.entries.<plugin_id>``.
|
||||
GRANTED_KEY = "granted_capabilities"
|
||||
CONSENT_KEY = "capabilities_consent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declaration parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_declared_capabilities(raw: Any, plugin_name: str = "?") -> List[str]:
|
||||
"""Normalize a manifest ``capabilities:`` value into known capability ids.
|
||||
|
||||
Unknown ids are dropped with a warning (forward compat: a plugin built
|
||||
for a newer Hermes may declare ids this build doesn't know; they can
|
||||
never be granted here, so hiding them from the consent screen is the
|
||||
fail-closed choice — the plugin must degrade gracefully).
|
||||
"""
|
||||
if not raw:
|
||||
return []
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
logger.warning(
|
||||
"Plugin %s: manifest 'capabilities' must be a list, got %s — ignoring",
|
||||
plugin_name, type(raw).__name__,
|
||||
)
|
||||
return []
|
||||
out: List[str] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, str):
|
||||
logger.warning(
|
||||
"Plugin %s: ignoring non-string capability entry %r",
|
||||
plugin_name, item,
|
||||
)
|
||||
continue
|
||||
cap = item.strip()
|
||||
if cap in VALID_CAPABILITY_IDS:
|
||||
if cap not in out:
|
||||
out.append(cap)
|
||||
else:
|
||||
logger.warning(
|
||||
"Plugin %s: unknown capability %r (known: %s) — ignoring",
|
||||
plugin_name, cap, ", ".join(sorted(VALID_CAPABILITY_IDS)),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def capability_set_hash(capabilities: Iterable[str]) -> str:
|
||||
"""Deterministic sha256 over a capability set (order-insensitive)."""
|
||||
canon = "\n".join(sorted(set(capabilities)))
|
||||
return hashlib.sha256(canon.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consent state (read side — fail closed on ANY error)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _plugin_entry(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> dict:
|
||||
"""Return ``plugins.entries.<plugin_id>`` or ``{}`` — never raises."""
|
||||
try:
|
||||
cfg: Any = config
|
||||
if cfg is None:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config() or {}
|
||||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||||
entry = entries.get(plugin_id) or {}
|
||||
return entry if isinstance(entry, dict) else {}
|
||||
except Exception:
|
||||
# Ground rule: failure to read consent state = not granted.
|
||||
return {}
|
||||
|
||||
|
||||
def granted_capabilities(
|
||||
plugin_id: str, config: Optional[Mapping[str, Any]] = None
|
||||
) -> frozenset:
|
||||
"""Return the set of capabilities the user has granted this plugin.
|
||||
|
||||
Fail-closed: missing/corrupt state yields the empty set.
|
||||
"""
|
||||
entry = _plugin_entry(plugin_id, config)
|
||||
raw = entry.get(GRANTED_KEY)
|
||||
if not isinstance(raw, list):
|
||||
return frozenset()
|
||||
return frozenset(
|
||||
c.strip() for c in raw
|
||||
if isinstance(c, str) and c.strip() in VALID_CAPABILITY_IDS
|
||||
)
|
||||
|
||||
|
||||
def _legacy_gate_set(entry: Mapping[str, Any], spec: CapabilitySpec) -> bool:
|
||||
"""True when the deprecated ``allow_*`` key for *spec* is truthy."""
|
||||
node: Any = entry
|
||||
for part in spec.legacy_path:
|
||||
if not isinstance(node, Mapping):
|
||||
return False
|
||||
node = node.get(part)
|
||||
return bool(node) and node is not None
|
||||
|
||||
|
||||
def plugin_capability_granted(
|
||||
plugin_id: str,
|
||||
capability: str,
|
||||
config: Optional[Mapping[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Canonical check: is *capability* live for *plugin_id*?
|
||||
|
||||
True when EITHER:
|
||||
|
||||
* the capability appears in ``granted_capabilities`` (consent flow), OR
|
||||
* the legacy ``allow_*`` config key is set (deprecated, still honored so
|
||||
existing configs keep working).
|
||||
|
||||
Unknown capability ids and any failure to read state return ``False``
|
||||
(ground rule 4: fail closed).
|
||||
"""
|
||||
spec = CAPABILITY_REGISTRY.get(capability)
|
||||
if spec is None:
|
||||
logger.debug(
|
||||
"capability check for unknown id %r (plugin %s) — denied",
|
||||
capability, plugin_id,
|
||||
)
|
||||
return False
|
||||
entry = _plugin_entry(plugin_id, config)
|
||||
if capability in granted_capabilities(plugin_id, config={"plugins": {"entries": {plugin_id: entry}}}):
|
||||
_log_capability_decision(plugin_id, capability, True, "granted_capabilities")
|
||||
return True
|
||||
if _legacy_gate_set(entry, spec):
|
||||
_log_capability_decision(
|
||||
plugin_id, capability, True,
|
||||
f"legacy key plugins.entries.{plugin_id}.{'.'.join(spec.legacy_path)} (deprecated)",
|
||||
)
|
||||
return True
|
||||
_log_capability_decision(plugin_id, capability, False, "not granted")
|
||||
return False
|
||||
|
||||
|
||||
def _log_capability_decision(
|
||||
plugin_id: str, capability: str, allowed: bool, evidence: str
|
||||
) -> None:
|
||||
"""Audit line for capability gate decisions (the ``checked_by`` trail)."""
|
||||
logger.info(
|
||||
"capability_check plugin=%s capability=%s decision=%s checked_by=plugin_capability_granted evidence=%s",
|
||||
plugin_id, capability, "allow" if allowed else "deny", evidence,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consent state (write side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def record_consent(
|
||||
plugin_id: str,
|
||||
granted: Iterable[str],
|
||||
declared: Iterable[str],
|
||||
) -> None:
|
||||
"""Persist a consent decision for *plugin_id*.
|
||||
|
||||
Writes ``granted_capabilities`` (union with any previously granted set),
|
||||
the consent record (hash of the *declared* set the user saw + UTC
|
||||
timestamp), and — so every existing enforcement site keeps working
|
||||
without changes — the corresponding legacy ``allow_*`` keys for each
|
||||
newly granted capability.
|
||||
"""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
granted_list = [c for c in dict.fromkeys(granted) if c in VALID_CAPABILITY_IDS]
|
||||
declared_list = [c for c in dict.fromkeys(declared) if c in VALID_CAPABILITY_IDS]
|
||||
|
||||
config = load_config()
|
||||
plugins_cfg = config.setdefault("plugins", {})
|
||||
if not isinstance(plugins_cfg, dict):
|
||||
plugins_cfg = {}
|
||||
config["plugins"] = plugins_cfg
|
||||
entries = plugins_cfg.setdefault("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
entries = {}
|
||||
plugins_cfg["entries"] = entries
|
||||
entry = entries.setdefault(plugin_id, {})
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
entries[plugin_id] = entry
|
||||
|
||||
previous = entry.get(GRANTED_KEY)
|
||||
merged = list(previous) if isinstance(previous, list) else []
|
||||
for cap in granted_list:
|
||||
if cap not in merged:
|
||||
merged.append(cap)
|
||||
entry[GRANTED_KEY] = sorted(
|
||||
c for c in dict.fromkeys(merged)
|
||||
if isinstance(c, str) and c in VALID_CAPABILITY_IDS
|
||||
)
|
||||
entry[CONSENT_KEY] = {
|
||||
"hash": capability_set_hash(declared_list),
|
||||
"granted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
# Bridge: mirror each granted capability into its legacy gate so the
|
||||
# existing enforcement sites (which still read allow_*) honor the grant.
|
||||
for cap in entry[GRANTED_KEY]:
|
||||
spec = CAPABILITY_REGISTRY[cap]
|
||||
node = entry
|
||||
for part in spec.legacy_path[:-1]:
|
||||
child = node.setdefault(part, {})
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
node[part] = child
|
||||
node = child
|
||||
node[spec.legacy_path[-1]] = True
|
||||
|
||||
save_config(config)
|
||||
logger.info(
|
||||
"capability_consent plugin=%s granted=%s declared_hash=%s",
|
||||
plugin_id, ",".join(entry[GRANTED_KEY]) or "(none)",
|
||||
entry[CONSENT_KEY]["hash"][:12],
|
||||
)
|
||||
|
||||
|
||||
def consent_hash(plugin_id: str, config: Optional[Mapping[str, Any]] = None) -> Optional[str]:
|
||||
"""Return the stored consent hash, or None when absent/corrupt."""
|
||||
entry = _plugin_entry(plugin_id, config)
|
||||
consent = entry.get(CONSENT_KEY)
|
||||
if not isinstance(consent, dict):
|
||||
return None
|
||||
h = consent.get("hash")
|
||||
return h if isinstance(h, str) and h else None
|
||||
|
||||
|
||||
def pending_capabilities(
|
||||
plugin_id: str,
|
||||
declared: Iterable[str],
|
||||
config: Optional[Mapping[str, Any]] = None,
|
||||
) -> List[str]:
|
||||
"""Capabilities declared by the plugin but not yet granted.
|
||||
|
||||
Used both at first consent (everything is pending) and on update
|
||||
re-consent: when a new version declares capabilities the granted set
|
||||
lacks, those additions are returned and must be re-consented before
|
||||
they go live. The stored consent hash tells whether the *declared* set
|
||||
changed since the user last saw it.
|
||||
"""
|
||||
declared_list = [c for c in dict.fromkeys(declared) if c in VALID_CAPABILITY_IDS]
|
||||
granted = granted_capabilities(plugin_id, config)
|
||||
return [c for c in declared_list if c not in granted]
|
||||
|
||||
|
||||
def declared_set_changed(
|
||||
plugin_id: str,
|
||||
declared: Iterable[str],
|
||||
config: Optional[Mapping[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""True when the declared set differs from what the user consented to.
|
||||
|
||||
No stored consent at all counts as changed (never consented).
|
||||
"""
|
||||
stored = consent_hash(plugin_id, config)
|
||||
if stored is None:
|
||||
return True
|
||||
return stored != capability_set_hash(
|
||||
c for c in declared if c in VALID_CAPABILITY_IDS
|
||||
)
|
||||
Reference in New Issue
Block a user