Files
OpenMesh/coworker/overrides.py
zhaolei 6f402ffcee
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
2026-09-13 23:41:04 +08:00

161 lines
6.8 KiB
Python

"""User-local risk overrides — relax (or tighten) a tool's risk class — and, since
OPE-136, per-tool TRUST rules.
``rules`` relax or tighten a third-party (plugin) tool's risk class by glob; the most
specific rule wins. MCP tools cannot be reclassified (the floor in ``risk.classify``);
their sanctioned lever is a ``trust`` rule instead: *waive the approval card for this
tool* — nothing else. A trusted tool stays EXTERNAL: read-only modes still deny it, the
Auto-approve reviewer still judges it, and the audit trail still records it. One store,
two rule types, one loader — deliberately NOT a second file (the architecture review
rejected a parallel trust store as yet another labeling system).
**Inviolable rule: this store is user-local and is NEVER written by a persona/package.** A
persona can declare what tools it wants, but only the user decides how much to trust them — so
the persona-loading path never touches this file (see ``PERMISSIONS-AND-INBOX.md``).
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Callable, Optional
from .risk import RiskClass
@dataclass
class _Rule:
pattern: str
risk: RiskClass
def _specificity(pattern: str) -> int:
"""More literal (non-wildcard) characters = more specific; an exact pattern beats any glob."""
literal = sum(1 for c in pattern if c not in "*?[]")
exact = 0 if any(c in pattern for c in "*?[") else 1000
return literal + exact
class RiskOverrideStore:
def __init__(self, path: Optional[str | Path] = None) -> None:
self.path = Path(path) if path else None
# Rules refused at load with the reason why — surfaced to the user instead of
# silently shaping permissions differently than their file says.
self.rejected: list[tuple[str, str]] = [] # (pattern, reason)
# OPE-136 trust rules: exact tool names (the card writes exact names — a button
# grants precisely what its card showed; globs stay a hand-editing power path).
self._trust: list[str] = []
self._rules: list[_Rule] = self._load()
def _load(self) -> list[_Rule]:
if not (self.path and self.path.is_file()):
return []
data = json.loads(self.path.read_text(encoding="utf-8"))
# Trust entries: {"pattern": "..."} dicts (the written form) or bare strings.
seen: set[str] = set()
for entry in data.get("trust", []) or []:
pattern = (
str(entry.get("pattern", "")) if isinstance(entry, dict) else str(entry)
)
if pattern and pattern not in seen:
seen.add(pattern)
self._trust.append(pattern)
rules = []
for r in data.get("rules", []):
try:
rule = _Rule(str(r["pattern"]), RiskClass(str(r["risk"])))
except (KeyError, ValueError):
continue # skip malformed rules rather than failing the whole store
# OPE-136: an explicitly MCP-targeting rule may not sink a tool below
# EXTERNAL — the floor in risk.classify would silently ignore it anyway,
# and a rule that reads one way in the file but acts another is worse than
# a refused rule. (Generic globs that merely HAPPEN to match mcp__ names
# load normally; the classify floor neutralizes the loosening for those.)
if rule.pattern.startswith("mcp__") and rule.risk in (
RiskClass.READ,
RiskClass.EGRESS,
):
self.rejected.append(
(
rule.pattern,
"MCP tools cannot be reclassified below external "
"(OPE-136) — use a trust rule to stop the asking",
)
)
continue
rules.append(rule)
return rules
def save(self) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(
{
"rules": [
{"pattern": r.pattern, "risk": r.risk.value}
for r in self._rules
],
"trust": [{"pattern": p} for p in self._trust],
},
indent=2,
),
encoding="utf-8",
)
def set_rule(self, pattern: str, risk: RiskClass | str) -> None:
"""Add/replace a user override (the everyday path writes this from the approval UI).
Refuses what `_load` refuses (OPE-136): an explicitly MCP-targeting rule below
EXTERNAL would be written now and silently dropped on the next load — a rule
that works for one session and then vanishes is a trap, so it never lands."""
risk = RiskClass(risk) if not isinstance(risk, RiskClass) else risk
if pattern.startswith("mcp__") and risk in (RiskClass.READ, RiskClass.EGRESS):
raise ValueError(
"MCP tools cannot be reclassified below external (OPE-136) — "
"use a trust rule to stop the asking"
)
self._rules = [r for r in self._rules if r.pattern != pattern]
self._rules.append(_Rule(pattern, risk))
self.save()
def resolve(self, tool_name: str) -> Optional[RiskClass]:
best: Optional[RiskClass] = None
best_score = -1
for r in self._rules:
if fnmatchcase(tool_name, r.pattern):
score = _specificity(r.pattern)
if score > best_score:
best, best_score = r.risk, score
return best
def resolver(self) -> Callable[[str], Optional[RiskClass]]:
"""A callable for ``PermissionEngine.risk_overrides`` / ``risk.classify``."""
return self.resolve
# -- OPE-136 trust rules (waive the card; never reclassify) ---------------------
def trusted(self, tool_name: str) -> bool:
"""Whether a standing trust rule covers this tool (glob-matched, like risk rules)."""
return any(fnmatchcase(tool_name, p) for p in self._trust)
def set_trust(self, pattern: str) -> None:
"""Mint a trust rule (the approval card's "Always allow this tool" writes an
EXACT name — a button grants precisely what its card showed, nothing wider)."""
if not pattern:
return
if pattern not in self._trust:
self._trust.append(pattern)
self.save()
def revoke_trust(self, pattern: str) -> None:
before = len(self._trust)
self._trust = [p for p in self._trust if p != pattern]
if len(self._trust) != before:
self.save()
def trust_patterns(self) -> list[str]:
return list(self._trust)