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
This commit is contained in:
160
coworker/overrides.py
Normal file
160
coworker/overrides.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user