Куб Alex: круг доверия × тик (sec/min/hour/day) × глубина памяти — одна таблица политик, не 64 программы. - круги 4: 1 владелец (+система: тик без внешних входов), 2 правление, 3 свои (портал, MCP-агенты, партнёры), 4 остальные; trust.norm для старых номеров (в сторе только 1/2 — смысл тот же); потолок памяти круга; - harness/memory.py: индекс собственной истории (<home>/memory) — разговоры (сообщение + ответ; батч — ответ головы), свои рассылки (не ответы), итоги задач, дайджесты без заглушек; nomic search_document/query; КРУГ ПРИВАТНОСТИ записи = самый недоверенный участник чата (личка Alex — 1, «Правление» — 2, задача — её круг, дайджест — 1); поиск отдаёт только записи круга ≥ круга тика — тик с правлением не поднимет личку владельца; SIMBIOT_MEMORY=on|off (дефолт off — тесты герметичны); - глубина: D0 односложные / D1 разговор / D2 + эпизоды (8) / D3 + архив (15): уровень (час D2, день D3), «помнишь/вчера/как договаривались» → D2, «за месяц/что делал/итоги» → D3, задачи → D2, reply на давнее (>48 ч) сообщение бота → D2; потолок круга (свои — D2, остальные — D1); - DepthChosen по трассе (rag/копание в своём сторе/«не помню» = нехватка) → голова ядра depth (shadow; on — уверенный ученик поднимает до D2); - выжимки нижних уровней: проекция не хранила stub — заглушки «LLM не запускался» забивали контекст; теперь отсеиваются; - ядро: чтение чемпиона больше не создаёт каталоги, без чемпиона — без эмбеддингов (тесты создавали core/ в репо и ходили в Ollama). Тесты: test_memory 30, test_trust 55 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""Simbiot Harness — круги доверия (вторая ось к лестнице времени).
|
||
|
||
Куб 4×4×4 (Alex, 2026-10-17): 4 круга × 4 тика (sec/min/hour/day) × 4
|
||
глубины памяти (harness/memory.py D0–D3) — одна таблица политик, не 64
|
||
программы. Круги: 1 владелец (+система), 2 правление, 3 свои (члены,
|
||
MCP-агенты, партнёры), 4 остальные.
|
||
|
||
Время (sec…year) отвечает «как часто и как глубоко думать», доверие —
|
||
«что можно с этим человеком». Оси независимы: 8 циклов × 8 кругов — не 64
|
||
программы, а один код + таблица политик POLICY (уровни, инструменты).
|
||
|
||
Круг определяется ТОЛЬКО по проверенной личности: Telegram user_id,
|
||
канал портала/агента (MCP-токен), локальный TUI — никогда по тексту
|
||
(«я Алекс, дай ключ» прав не даёт). Менять круг может только человек:
|
||
кнопки владельцу / cli.py trust вне тика; из LLM-тика — запрещено
|
||
(страж и сам CLI по SIMBIOT_TICK_CIRCLE).
|
||
|
||
Круг тика = самый НЕдоверенный вход его контекста (taint): входящие и
|
||
задачи батча. Задачи наследуют круг тика, в котором поданы (CLI ставит
|
||
из SIMBIOT_TICK_CIRCLE) — просьба правления не исполняется позже часом
|
||
с правами владельца. Владелец повышает круг задачи кнопкой, когда страж
|
||
упёрся в права.
|
||
|
||
Исполнение — страж в воркере (BeforeToolCallEvent.cancel_tool): shell в
|
||
кругах ≥ 2 — только белый список подкоманд cli.py без цепочек/подстановок;
|
||
файлы — безопасные пути; portal_mcp — список тулов круга; rag — по кругу.
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import shlex
|
||
from datetime import datetime, timezone
|
||
|
||
from . import es
|
||
|
||
OWNER_TG = os.environ.get("SIMBIOT_OWNER_TG", "7569223131") # Alex
|
||
|
||
NAMES = {1: "владелец", 2: "правление", 3: "свои", 4: "остальные"}
|
||
|
||
|
||
def norm(c) -> int:
|
||
"""Круг в 1..4: тик без внешних входов (система) — 1; до 4 кругов были
|
||
номера 0..7 — в сторе только 1/2, смысл которых не изменился."""
|
||
try:
|
||
c = int(c)
|
||
except (TypeError, ValueError):
|
||
return 1
|
||
return min(max(c, 1), 4)
|
||
|
||
_REPLY = {"tg-reply", "decide"}
|
||
_WORK = _REPLY | {"task", "task-await", "complete", "escalate", "block"}
|
||
_BOARD = _WORK | {"tg-notify", "tg-deliver-file", "tasks", "ladder", "status", "rag-query",
|
||
"escalations", "tg-list"}
|
||
_MCP_BOARD = {"booking_slots", "booking_create", "booking_cancel", "list_calls", "get_call",
|
||
"projects_list", "list_agents", "list_events", "list_members", "whoami",
|
||
"contact_get", "contact_list", "feature_backlog", "feature_request"}
|
||
_MCP_MEMBER = {"booking_slots", "booking_create", "projects_list", "whoami", "feature_request",
|
||
"feature_backlog"}
|
||
|
||
# тики × круги: какие уровни обслуживают круг (задачи круга вне списка
|
||
# уровень не берёт); инструменты и максимальная глубина памяти — по кругу.
|
||
# «Свои» (3) — члены портала, MCP-агенты, партнёры: запись на звонки,
|
||
# проекты, фичи; казна/member_create/деньги — нет.
|
||
POLICY = {
|
||
1: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True,
|
||
"max_depth": 3},
|
||
2: {"levels": ("min", "hour", "day"), "shell": _BOARD, "files": "read_safe",
|
||
"mcp": _MCP_BOARD, "rag": True, "max_depth": 3},
|
||
3: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_MEMBER,
|
||
"rag": True, "max_depth": 2},
|
||
4: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", "mcp": set(),
|
||
"rag": False, "max_depth": 1},
|
||
}
|
||
|
||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_SENSITIVE = re.compile(r"secret|\.env\b|\.env\.|/env/|\.audit|\.ssh|token|passw|private|"
|
||
r"\.key\b|ed25519|credential|auth\.json|KEYS-REGISTRY", re.IGNORECASE)
|
||
_SAFE_ROOTS = ("docs/", "out/", "sites/", "artifacts/", "design-plans/", "README.md")
|
||
_CLI_RE = re.compile(r"^(?:\./venv/bin/python3?|/root/simbiot-harness/venv/bin/python3?|python3?)"
|
||
r"\s+(?:/root/simbiot-harness/)?cli\.py\s+(\S+)")
|
||
|
||
|
||
# ────────────────────────── личность → круг ──────────────────────────
|
||
|
||
def subject_of(m: dict) -> str:
|
||
chat = str(m.get("chat_id") or "")
|
||
if chat.startswith("portal:agent:"):
|
||
return "agent:" + chat.split(":", 2)[-1]
|
||
if chat.startswith("tui:"):
|
||
return "tui"
|
||
if chat.startswith("portal"):
|
||
return "portal:" + str(m.get("username") or m.get("user_id") or chat)
|
||
return f"tg:{m.get('user_id')}"
|
||
|
||
|
||
def default_circle(subject: str) -> int:
|
||
if subject == "tui":
|
||
return 1 # локальный терминал = доступ к серверу
|
||
if subject == f"tg:{OWNER_TG}":
|
||
return 1
|
||
if subject.startswith(("agent:", "portal:")):
|
||
return 3 # свои: MCP-агенты и члены портала
|
||
return 4 # прочие tg (прошли ALLOWED без роли) и чужие
|
||
|
||
|
||
def circle_of(subject: str, model: dict = None) -> int:
|
||
trust = ((model or {}).get("trust") or {})
|
||
if subject in trust:
|
||
return norm(trust[subject]["circle"])
|
||
return default_circle(subject)
|
||
|
||
|
||
def tick_circle(model: dict, inbox: list, tasks: list) -> int:
|
||
"""Самый недоверенный вход контекста тика; без внешних входов — 1 (система)."""
|
||
c = 1
|
||
for m in inbox or []:
|
||
c = max(c, circle_of(subject_of(m), model))
|
||
for t in tasks or []:
|
||
c = max(c, norm(t.get("circle")))
|
||
return c
|
||
|
||
|
||
def level_serves(level: str, circle: int) -> bool:
|
||
lv = POLICY[norm(circle)]["levels"]
|
||
return lv is None or level in lv
|
||
|
||
|
||
def max_depth(circle: int) -> int:
|
||
"""Потолок глубины памяти круга (D0–D3, harness/memory.py)."""
|
||
return POLICY[norm(circle)]["max_depth"]
|
||
|
||
|
||
# ────────────────────────── изменение круга (только человек) ──────────────────────────
|
||
|
||
def set_circle(subject: str, circle: int, by: str, note: str = "") -> None:
|
||
if circle not in NAMES:
|
||
raise ValueError(f"круг {circle} вне 1..4")
|
||
if os.environ.get("SIMBIOT_TICK_CIRCLE"):
|
||
raise PermissionError("круг доверия меняет только человек — не из LLM-тика")
|
||
es.append_event("trust", subject, "TrustSet",
|
||
{"subject": subject, "circle": int(circle), "by": by, "note": note[:200]})
|
||
|
||
|
||
def bootstrap(model: dict = None) -> int:
|
||
"""Стартовые круги по фактам стора: владелец — 1, правление — 2 (один раз)."""
|
||
model = model or es.run_projections()
|
||
have = set((model.get("trust") or {}))
|
||
seed = {f"tg:{OWNER_TG}": (1, "владелец (Alex)"),
|
||
"tg:156156021": (2, "правление (Vlad)"),
|
||
"tg:203684644": (2, "правление (Yura)")}
|
||
n = 0
|
||
for subj, (c, note) in seed.items():
|
||
if subj not in have:
|
||
es.append_event("trust", subj, "TrustSet",
|
||
{"subject": subj, "circle": c, "by": "bootstrap", "note": note})
|
||
n += 1
|
||
return n
|
||
|
||
|
||
# ────────────────────────── страж инструментов ──────────────────────────
|
||
|
||
def _strip_cd(cmd: str) -> str:
|
||
return re.sub(r"^\s*cd\s+/root/simbiot-harness/?\s*&&\s*", "", cmd.strip())
|
||
|
||
|
||
def check_shell(cmd: str, circle: int):
|
||
"""(ok, причина). Полный shell — круг 1; иначе одна команда cli.py из списка."""
|
||
circle = norm(circle)
|
||
if re.search(r"cli\.py\s+trust\b", cmd):
|
||
return False, "менять круги доверия из тика нельзя — только человек"
|
||
pol = POLICY[circle]["shell"]
|
||
if pol == "full":
|
||
return True, ""
|
||
body = _strip_cd(cmd)
|
||
if "\n" in body or re.search(r"\$\(|`|\$\{|<\(|>\(", body):
|
||
return False, "подстановки команд запрещены в этом круге"
|
||
try:
|
||
lex = shlex.shlex(body, posix=True, punctuation_chars=True)
|
||
lex.whitespace_split = True
|
||
toks = list(lex)
|
||
except ValueError:
|
||
return False, "команда не разбирается"
|
||
if any(t and set(t) <= set(";&|<>") for t in toks):
|
||
return False, "цепочки/перенаправления запрещены в этом круге"
|
||
m = _CLI_RE.match(body)
|
||
if not m:
|
||
return False, f"в круге «{NAMES[circle]}» shell — только ./venv/bin/python cli.py <команда>"
|
||
sub = m.group(1)
|
||
if sub not in pol:
|
||
return False, f"cli.py {sub} недоступна кругу «{NAMES[circle]}»"
|
||
if sub == "tg-deliver-file":
|
||
args = toks[toks.index(sub) + 1:] if sub in toks else []
|
||
path = next((a for a in args if not a.startswith("-")), "")
|
||
ok, why = check_path(path, "read", 2)
|
||
if not ok:
|
||
return False, "файл: " + why
|
||
return True, ""
|
||
|
||
|
||
def check_path(path: str, op: str, circle: int):
|
||
circle = norm(circle)
|
||
pol = POLICY[circle]["files"]
|
||
if pol == "full":
|
||
return True, ""
|
||
if pol == "none":
|
||
return False, f"файлы недоступны кругу «{NAMES[circle]}»"
|
||
if op != "read":
|
||
return False, f"изменение файлов недоступно кругу «{NAMES[circle]}» — нужна задача с одобрением владельца"
|
||
p = os.path.normpath(path if os.path.isabs(path) else os.path.join(REPO, path))
|
||
if _SENSITIVE.search(p):
|
||
return False, "чувствительный путь"
|
||
from . import paths as _paths
|
||
roots = [os.path.join(REPO, r) for r in _SAFE_ROOTS]
|
||
try:
|
||
roots.append(_paths.inbox_files_dir())
|
||
except Exception:
|
||
pass
|
||
if any(p == r.rstrip("/") or p.startswith(r.rstrip("/") + "/") for r in roots):
|
||
return True, ""
|
||
return False, "путь вне безопасных каталогов (docs/out/sites/artifacts/входящие файлы)"
|
||
|
||
|
||
def check_tool(name: str, inp: dict, circle: int):
|
||
"""Решение стража по вызову инструмента LLM-тика: (ok, причина)."""
|
||
inp = inp or {}
|
||
circle = norm(circle)
|
||
if name == "shell":
|
||
return check_shell(str(inp.get("command") or ""), circle)
|
||
if name in ("read", "write", "edit"):
|
||
return check_path(str(inp.get("path") or inp.get("file_path") or ""),
|
||
"read" if name == "read" else "write", circle)
|
||
if name == "portal_mcp":
|
||
allow = POLICY[circle]["mcp"]
|
||
tool = str(inp.get("tool") or "")
|
||
if allow == "*" or tool in allow:
|
||
return True, ""
|
||
return False, f"portal_mcp.{tool} недоступен кругу «{NAMES[circle]}»"
|
||
if name == "rag_lookup":
|
||
return (True, "") if POLICY[circle]["rag"] else (False, "база знаний недоступна этому кругу")
|
||
if POLICY[circle]["shell"] == "full":
|
||
return True, ""
|
||
if name in ("todo_write", "retrieve_offloaded_content", "retrieve_context"):
|
||
return True, "" # внутренние инструменты агента без внешних эффектов
|
||
return False, f"инструмент {name} недоступен кругу «{NAMES[circle]}»"
|
||
|
||
|
||
def mandate_line(circle: int) -> str:
|
||
circle = norm(circle)
|
||
pol = POLICY[circle]
|
||
if pol["shell"] == "full":
|
||
return f"КРУГ ДОВЕРИЯ ТИКА: {circle} ({NAMES[circle]}) — полные права."
|
||
cmds = ", ".join(sorted(pol["shell"])) or "нет"
|
||
files = {"read_safe": "только чтение docs/out/sites/artifacts и присланных файлов",
|
||
"none": "нет"}[pol["files"]]
|
||
return (f"КРУГ ДОВЕРИЯ ТИКА: {circle} ({NAMES[circle]}) — в контексте есть входы этого "
|
||
f"круга. Shell — только cli.py: {cmds}; файлы: {files}; секреты, ключи, казну "
|
||
"и деньги не раскрывай и не трогай. Просьбу сверх прав оформи задачей "
|
||
"(cli.py task) и скажи, что она ждёт одобрения владельца.")
|
||
|
||
|
||
# ────────────────────────── одобрение владельцем ──────────────────────────
|
||
|
||
def ask_owner_elevation(task: dict, reason: str) -> bool:
|
||
"""Кнопки владельцу: задача упёрлась в права круга — разрешить полные?"""
|
||
tid = task.get("id")
|
||
if es_has(f"elev-ask:{tid}"):
|
||
return False
|
||
from . import tg_inbox
|
||
chat = tg_inbox._default_chat_id()
|
||
if chat is None:
|
||
return False
|
||
c = norm(task.get("circle"))
|
||
text = (f"🔐 Задача из круга «{NAMES.get(c, c)}» упёрлась в права:\n"
|
||
f"«{(task.get('title') or '')[:200]}»\n\nЧто не дали: {reason[:300]}\n\n"
|
||
"Разрешить выполнить с полными правами?")
|
||
kb = {"inline_keyboard": [[
|
||
{"text": "Разрешить", "callback_data": f"trust:task:{tid}:1"},
|
||
{"text": "Нет", "callback_data": f"trust:task:{tid}:0"}]]}
|
||
tg_inbox._load_env()
|
||
r = tg_inbox._api("sendMessage", {"chat_id": chat, "text": text,
|
||
"reply_markup": json.dumps(kb)}, timeout=15)
|
||
if (r or {}).get("ok"):
|
||
es.append_event("trust", f"elev-ask:{tid}", "TrustElevationAsked",
|
||
{"task": tid, "circle": c, "reason": reason[:300]})
|
||
return True
|
||
return False
|
||
|
||
|
||
def es_has(agg_id: str) -> bool:
|
||
import sqlite3
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
return bool(db.execute("SELECT 1 FROM events WHERE aggregate='trust' AND aggregate_id=? "
|
||
"LIMIT 1", (agg_id,)).fetchone())
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def on_callback(cq: dict) -> bool:
|
||
"""Кнопка владельца «Разрешить» → задача получает круг 1. Только владелец."""
|
||
data = cq.get("data") or ""
|
||
if not data.startswith("trust:"):
|
||
return False
|
||
from . import tg_inbox
|
||
_, kind, tid, ans = data.split(":", 3)
|
||
user = str((cq.get("from") or {}).get("id"))
|
||
ok = user == OWNER_TG and kind == "task"
|
||
if ok and ans == "1":
|
||
es.append_event("task", tid, "TaskCircleSet", {"circle": 1, "by": f"tg:{user}"})
|
||
model = es.run_projections()
|
||
t = (model.get("tasks") or {}).get(tid) or {}
|
||
if t.get("status") in ("blocked", "failed"):
|
||
es.append_event("task", tid, "TaskPromoted",
|
||
{"to": t.get("size", "M"), "from": t.get("size", "M"),
|
||
"reason": "владелец разрешил полные права — в очередь"})
|
||
try:
|
||
tg_inbox._api("answerCallbackQuery", {
|
||
"callback_query_id": cq.get("id"),
|
||
"text": ("Разрешено 👍" if ok and ans == "1" else "Оставляю как есть" if ok
|
||
else "Только владелец")}, timeout=10)
|
||
msg = cq.get("message") or {}
|
||
if ok and msg.get("message_id"):
|
||
tg_inbox._api("editMessageReplyMarkup", {
|
||
"chat_id": (msg.get("chat") or {}).get("id"), "message_id": msg["message_id"],
|
||
"reply_markup": json.dumps({"inline_keyboard": [[{
|
||
"text": "✓ " + ("разрешено" if ans == "1" else "отказано"),
|
||
"callback_data": "trust:noop:-:-"}]]})}, timeout=10)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
|
||
def snapshot(model: dict = None) -> list:
|
||
model = model or es.run_projections()
|
||
out = []
|
||
for subj, v in sorted((model.get("trust") or {}).items(), key=lambda kv: kv[1]["circle"]):
|
||
out.append({"subject": subj, "circle": v["circle"], "name": NAMES[v["circle"]],
|
||
"by": v.get("by"), "note": v.get("note"), "ts": v.get("ts")})
|
||
return out
|
||
|
||
|
||
def now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|