Движок тика claude_code: тики исполняет Claude Code (claude -p) с моделью на уровень (min Haiku 5.5, hour Sonnet 5.5, day Opus 5.5; SIMBIOT_LLM_BACKEND[_<LEVEL>], SIMBIOT_CC_MODEL_<LEVEL>, SIMBIOT_CC_API_KEY); круги доверия — хук cc_guard → trust.check_tool, MCP портала с лимитом, трасса и BudgetSpent как у Strands; настройки уровней читаются из .env инстанса
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
70fdda356a
commit
c396756eb6
5 changed files with 335 additions and 8 deletions
|
|
@ -513,12 +513,6 @@ def resolve_tool_budget(level: str) -> int:
|
||||||
def main():
|
def main():
|
||||||
payload = json.loads(sys.stdin.read())
|
payload = json.loads(sys.stdin.read())
|
||||||
|
|
||||||
from harness import es
|
|
||||||
from harness.runtime import setup_provider, _wire_trace_hooks
|
|
||||||
|
|
||||||
setup_provider()
|
|
||||||
from strands_harness import create_harness
|
|
||||||
|
|
||||||
trace_id = payload.get("trace_prefix") or "tick"
|
trace_id = payload.get("trace_prefix") or "tick"
|
||||||
level = payload.get("level") or "?"
|
level = payload.get("level") or "?"
|
||||||
# круг доверия тика (harness/trust.py): в env — его наследуют shell и
|
# круг доверия тика (harness/trust.py): в env — его наследуют shell и
|
||||||
|
|
@ -526,6 +520,22 @@ def main():
|
||||||
from harness.trust import norm as _norm_circle
|
from harness.trust import norm as _norm_circle
|
||||||
circle = _norm_circle(payload.get("circle"))
|
circle = _norm_circle(payload.get("circle"))
|
||||||
os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle)
|
os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle)
|
||||||
|
# движок тика (harness/cc_backend.py): claude_code — тик исполняет Claude Code
|
||||||
|
# со своей моделью на уровень (роутер не нужен); иначе — Strands + 9router
|
||||||
|
from harness import cc_backend
|
||||||
|
if cc_backend.backend_for(level) == "claude_code":
|
||||||
|
try:
|
||||||
|
_emit(payload, cc_backend.run(payload, _emit_budget_incremental))
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
_emit(payload, {"ok": False, "error": f"claude_code: {e}\n{traceback.format_exc()[-800:]}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
from harness import es
|
||||||
|
from harness.runtime import setup_provider, _wire_trace_hooks
|
||||||
|
|
||||||
|
setup_provider()
|
||||||
|
from strands_harness import create_harness
|
||||||
try:
|
try:
|
||||||
# RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup —
|
# RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup —
|
||||||
# поиск по базе кооператива; лимиты (1 запрос/tick, k<=5, таймаут)
|
# поиск по базе кооператива; лимиты (1 запрос/tick, k<=5, таймаут)
|
||||||
|
|
|
||||||
130
harness/cc_backend.py
Normal file
130
harness/cc_backend.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Simbiot Harness — движок LLM-тика «claude_code»: тик исполняет Claude Code (`claude -p`).
|
||||||
|
|
||||||
|
Альтернатива Strands + 9router (_llm_worker). Выбор движка и модели — на уровень:
|
||||||
|
SIMBIOT_LLM_BACKEND_<LEVEL> > SIMBIOT_LLM_BACKEND > strands (strands | claude_code)
|
||||||
|
SIMBIOT_CC_MODEL_<LEVEL> > дефолт: min — Haiku 5.5, hour — Sonnet 5.5, day — Opus 5.5
|
||||||
|
SIMBIOT_CC_API_KEY — ключ Anthropic API для тиков (без него — вход Claude Code на сервере)
|
||||||
|
Запускается внутри того же изолированного воркера (isolated_runner): таймаут уровня
|
||||||
|
убивает группу процессов вместе с claude.
|
||||||
|
|
||||||
|
Что сохраняется от Strands-тика:
|
||||||
|
- мандат уровня → --append-system-prompt; промпт тика → stdin;
|
||||||
|
- круг доверия: хук cc_guard.py на каждый инструмент (trust.check_tool), MCP портала —
|
||||||
|
только наш сервер (--strict-mcp-config) с токеном харнеса и лимитом вызовов за тик;
|
||||||
|
- бюджет шагов — --max-turns (resolve_tool_budget уровня);
|
||||||
|
- трасса: InvocationStarted здесь, ToolCalled/ToolResult — хук;
|
||||||
|
- BudgetSpent инкрементально — по каждому ответу модели из stream-json (kill по
|
||||||
|
таймауту теряет только хвост, не весь тик).
|
||||||
|
Сессии Claude Code не сохраняются (--no-session-persistence): тики не засоряют панель.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .levels import _env_cfg
|
||||||
|
|
||||||
|
HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DEFAULT_MODELS = {"min": "claude-haiku-5-5", "hour": "claude-sonnet-5-5", "day": "claude-opus-5-5"}
|
||||||
|
RAG_HINT = ("\n\nДвижок тика — Claude Code. Поиск по базе знаний кооператива: "
|
||||||
|
"./venv/bin/python cli.py rag-query \"вопрос целиком\" (1 запрос за тик). "
|
||||||
|
"Портал кооператива — MCP-инструменты mcp__portal__* (до 4 вызовов за тик). "
|
||||||
|
"Команды харнеса (tg-reply, task, escalate, …) — ./venv/bin/python cli.py … из корня репо.")
|
||||||
|
|
||||||
|
|
||||||
|
def backend_for(level: str) -> str:
|
||||||
|
v = (_env_cfg(f"SIMBIOT_LLM_BACKEND_{level.upper()}") or _env_cfg("SIMBIOT_LLM_BACKEND") or "strands").strip().lower()
|
||||||
|
return "claude_code" if v in ("claude_code", "claude-code", "claude", "cc") else "strands"
|
||||||
|
|
||||||
|
|
||||||
|
def model_for(level: str) -> str:
|
||||||
|
return _env_cfg(f"SIMBIOT_CC_MODEL_{level.upper()}") or DEFAULT_MODELS.get(level, DEFAULT_MODELS["min"])
|
||||||
|
|
||||||
|
|
||||||
|
def claude_bin() -> str:
|
||||||
|
return _env_cfg("SIMBIOT_CLAUDE_BIN") or shutil.which("claude") or "/usr/bin/claude"
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_config(tmpdir: str):
|
||||||
|
"""Только MCP портала с токеном харнеса; без токена — без MCP."""
|
||||||
|
from . import portal_mcp
|
||||||
|
tok = portal_mcp._load_token()
|
||||||
|
if not tok:
|
||||||
|
return None
|
||||||
|
p = os.path.join(tmpdir, "mcp.json")
|
||||||
|
with open(p, "w") as f:
|
||||||
|
json.dump({"mcpServers": {"portal": {"type": "http", "url": portal_mcp._url(),
|
||||||
|
"headers": {"Authorization": f"Bearer {tok}"}}}}, f)
|
||||||
|
os.chmod(p, 0o600)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _usage_delta(u: dict) -> dict:
|
||||||
|
p = sum(int(u.get(k) or 0) for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
|
||||||
|
return {"prompt_tokens": p, "completion_tokens": int(u.get("output_tokens") or 0), "model_calls": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def run(payload: dict, emit_budget) -> dict:
|
||||||
|
"""Тик на Claude Code. Возвращает {"ok", "text", "usage", "trace_id"} как Strands-воркер."""
|
||||||
|
from . import es
|
||||||
|
from ._llm_worker import resolve_tool_budget
|
||||||
|
level = payload.get("level") or "min"
|
||||||
|
trace_id = payload.get("trace_prefix") or "tick"
|
||||||
|
model = model_for(level)
|
||||||
|
es.append_event("trace", f"{trace_id}:cc0", "InvocationStarted",
|
||||||
|
{"trace_id": trace_id, "level": level, "model": model, "engine": "claude_code"})
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="simbiot_cc_")
|
||||||
|
try:
|
||||||
|
state = os.path.join(tmpdir, "state.json")
|
||||||
|
guard = f"{os.path.join(HARNESS_DIR, 'venv', 'bin', 'python')} {os.path.join(HARNESS_DIR, 'harness', 'cc_guard.py')}"
|
||||||
|
settings = {"hooks": {ev: [{"matcher": "*", "hooks": [{"type": "command", "command": guard, "timeout": 30}]}]
|
||||||
|
for ev in ("PreToolUse", "PostToolUse")}}
|
||||||
|
cmd = [claude_bin(), "-p", "--model", model, "--output-format", "stream-json", "--verbose",
|
||||||
|
"--no-session-persistence", "--max-turns", str(resolve_tool_budget(level)),
|
||||||
|
"--append-system-prompt", (payload.get("instructions") or "") + RAG_HINT,
|
||||||
|
"--settings", json.dumps(settings),
|
||||||
|
# права решает страж (cc_guard → trust.check_tool), не интерактивные промпты
|
||||||
|
"--dangerously-skip-permissions"]
|
||||||
|
if not payload.get("can_shell"):
|
||||||
|
cmd += ["--disallowedTools", "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit"]
|
||||||
|
mcp = _mcp_config(tmpdir)
|
||||||
|
cmd += (["--mcp-config", mcp] if mcp else []) + ["--strict-mcp-config"]
|
||||||
|
env = dict(os.environ, SIMBIOT_CC_STATE=state, SIMBIOT_TRACE_ID=trace_id, SIMBIOT_TICK_LEVEL=level)
|
||||||
|
# отдельный ключ Anthropic API для харнеса (иначе — вход Claude Code на сервере)
|
||||||
|
api_key = _env_cfg("SIMBIOT_CC_API_KEY")
|
||||||
|
if api_key:
|
||||||
|
env["ANTHROPIC_API_KEY"] = api_key
|
||||||
|
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
cwd=HARNESS_DIR, env=env, text=True)
|
||||||
|
proc.stdin.write(payload.get("prompt") or "")
|
||||||
|
proc.stdin.close()
|
||||||
|
seen, usage, final = set(), {"prompt_tokens": 0, "completion_tokens": 0, "model_calls": 0}, None
|
||||||
|
for line in proc.stdout:
|
||||||
|
try:
|
||||||
|
ev = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ev.get("type") == "assistant":
|
||||||
|
msg = ev.get("message") or {}
|
||||||
|
mid = msg.get("id")
|
||||||
|
if mid and mid not in seen and msg.get("usage"):
|
||||||
|
seen.add(mid) # один ответ модели = несколько событий по блокам — считаем раз
|
||||||
|
d = _usage_delta(msg["usage"])
|
||||||
|
for k in usage:
|
||||||
|
usage[k] += d[k]
|
||||||
|
emit_budget(level, trace_id, {**d, "engine": "claude_code"}, model)
|
||||||
|
elif ev.get("type") == "result":
|
||||||
|
final = ev
|
||||||
|
err = proc.stderr.read()[-500:]
|
||||||
|
proc.wait()
|
||||||
|
if not final:
|
||||||
|
return {"ok": False, "error": f"claude_code: нет результата (rc={proc.returncode}) {err}"}
|
||||||
|
if final.get("is_error"):
|
||||||
|
return {"ok": False, "error": f"claude_code: {str(final.get('result') or final.get('subtype'))[:400]}"}
|
||||||
|
usage["cost_usd"] = final.get("total_cost_usd")
|
||||||
|
usage["engine"] = "claude_code"
|
||||||
|
usage["model"] = model
|
||||||
|
return {"ok": True, "text": final.get("result") or "", "usage": usage, "trace_id": trace_id}
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
113
harness/cc_guard.py
Normal file
113
harness/cc_guard.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Simbiot Harness — хук Claude Code для LLM-тика на движке claude_code (cc_backend.py).
|
||||||
|
|
||||||
|
Claude Code зовёт его на каждом PreToolUse/PostToolUse (stdin — JSON события).
|
||||||
|
PreToolUse: инструмент Claude → инструмент харнеса → trust.check_tool по кругу тика
|
||||||
|
(SIMBIOT_TICK_CIRCLE); запрет — exit 2 + причина в stderr (Claude видит её
|
||||||
|
как отказ инструмента). MCP портала — не больше MAX_CALLS_PER_TICK за тик.
|
||||||
|
PostToolUse: ToolResult в трассу.
|
||||||
|
Трасса — те же события, что у Strands (_wire_trace_hooks): ToolCalled/ToolResult в
|
||||||
|
агрегате trace "<trace_id>:<n>", n — общий счётчик тика (файл SIMBIOT_CC_STATE + flock).
|
||||||
|
Хук не должен валить тик: любая собственная ошибка → разрешить (кроме явного запрета).
|
||||||
|
"""
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, os.path.dirname(_HERE))
|
||||||
|
|
||||||
|
# инструмент Claude Code → (имя для trust.check_tool, ключ пути/команды)
|
||||||
|
_READ = {"Read", "Glob", "Grep", "LS", "NotebookRead"}
|
||||||
|
_WRITE = {"Write", "Edit", "MultiEdit", "NotebookEdit"}
|
||||||
|
_FREE = {"TodoWrite", "TodoRead", "ExitPlanMode", "ToolSearch"} # без внешних эффектов (ToolSearch — подгрузка схем MCP)
|
||||||
|
|
||||||
|
|
||||||
|
def _map(tool: str, inp: dict):
|
||||||
|
if tool == "Bash":
|
||||||
|
return "shell", {"command": inp.get("command") or ""}
|
||||||
|
if tool in _READ:
|
||||||
|
return "read", {"path": inp.get("file_path") or inp.get("path") or inp.get("notebook_path") or ""}
|
||||||
|
if tool in _WRITE:
|
||||||
|
return "write", {"path": inp.get("file_path") or inp.get("notebook_path") or ""}
|
||||||
|
if tool.startswith("mcp__portal__"):
|
||||||
|
return "portal_mcp", {"tool": tool[len("mcp__portal__"):]}
|
||||||
|
if tool in _FREE:
|
||||||
|
return "todo_write", {}
|
||||||
|
return tool.lower(), inp # WebFetch/WebSearch/Task… — только круг с полным shell
|
||||||
|
|
||||||
|
|
||||||
|
def _state(update):
|
||||||
|
"""Атомарно прочитать/изменить состояние тика (счётчики)."""
|
||||||
|
path = os.environ.get("SIMBIOT_CC_STATE", "")
|
||||||
|
if not path:
|
||||||
|
return update({})
|
||||||
|
with open(path, "a+") as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_EX)
|
||||||
|
f.seek(0)
|
||||||
|
try:
|
||||||
|
st = json.loads(f.read() or "{}")
|
||||||
|
except ValueError:
|
||||||
|
st = {}
|
||||||
|
out = update(st)
|
||||||
|
f.seek(0)
|
||||||
|
f.truncate()
|
||||||
|
f.write(json.dumps(st))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _trace(kind: str, payload: dict):
|
||||||
|
trace_id = os.environ.get("SIMBIOT_TRACE_ID", "")
|
||||||
|
if not trace_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from harness import es
|
||||||
|
|
||||||
|
def bump(st):
|
||||||
|
st["n"] = st.get("n", 0) + 1
|
||||||
|
return st["n"]
|
||||||
|
n = _state(bump)
|
||||||
|
es.append_event("trace", f"{trace_id}:cc{n}", kind, {
|
||||||
|
"trace_id": trace_id, "level": os.environ.get("SIMBIOT_TICK_LEVEL", ""),
|
||||||
|
"engine": "claude_code", **payload})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
ev = json.loads(sys.stdin.read() or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
tool = ev.get("tool_name") or "?"
|
||||||
|
inp = ev.get("tool_input") or {}
|
||||||
|
if ev.get("hook_event_name") == "PostToolUse":
|
||||||
|
res = ev.get("tool_response")
|
||||||
|
_trace("ToolResult", {"tool": tool, "ok": True,
|
||||||
|
"result": (res if isinstance(res, str) else json.dumps(res, ensure_ascii=False, default=str))[:4000]})
|
||||||
|
return 0
|
||||||
|
name, hinp = _map(tool, inp)
|
||||||
|
ok, why = True, ""
|
||||||
|
try:
|
||||||
|
from harness import trust
|
||||||
|
ok, why = trust.check_tool(name, hinp, os.environ.get("SIMBIOT_TICK_CIRCLE"))
|
||||||
|
if ok and name == "portal_mcp":
|
||||||
|
from harness.portal_mcp import MAX_CALLS_PER_TICK
|
||||||
|
|
||||||
|
def take(st):
|
||||||
|
st["mcp"] = st.get("mcp", 0) + 1
|
||||||
|
return st["mcp"]
|
||||||
|
if _state(take) > MAX_CALLS_PER_TICK:
|
||||||
|
ok, why = False, f"лимит: {MAX_CALLS_PER_TICK} вызовов портала за тик"
|
||||||
|
except Exception:
|
||||||
|
ok, why = True, ""
|
||||||
|
_trace("ToolCalled", {"tool": tool, "input": json.dumps(inp, ensure_ascii=False, default=str)[:4000],
|
||||||
|
**({} if ok else {"blocked": why})})
|
||||||
|
if not ok:
|
||||||
|
print(f"Запрещено стражем харнеса: {why}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -16,12 +16,14 @@ YEAR = 366 * DAY # округляем год как 366 дней для про
|
||||||
|
|
||||||
|
|
||||||
def _env_cfg(key: str, default: str = "") -> str:
|
def _env_cfg(key: str, default: str = "") -> str:
|
||||||
"""Значение настройки: env сильнее .env харнеса (мини-ридер без circular import)."""
|
"""Значение настройки: env сильнее .env инстанса (paths.env_path: SIMBIOT_HOME/env/.env
|
||||||
|
или .env репо) — как runtime._env_or_file."""
|
||||||
val = os.environ.get(key, "").strip()
|
val = os.environ.get(key, "").strip()
|
||||||
if val:
|
if val:
|
||||||
return val
|
return val
|
||||||
try:
|
try:
|
||||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
|
from . import paths as _paths
|
||||||
|
env_path = _paths.env_path()
|
||||||
with open(env_path, encoding="utf-8") as f:
|
with open(env_path, encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
if line.startswith(key + "="):
|
if line.startswith(key + "="):
|
||||||
|
|
|
||||||
72
tests/test_cc_backend.py
Normal file
72
tests/test_cc_backend.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Движок тика claude_code: выбор движка/модели по уровню, страж cc_guard по кругам. Без LLM."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
ok = bad = 0
|
||||||
|
|
||||||
|
|
||||||
|
def eq(c, msg):
|
||||||
|
global ok, bad
|
||||||
|
if c:
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
bad += 1
|
||||||
|
print("FAIL", msg)
|
||||||
|
|
||||||
|
|
||||||
|
def with_env(env, fn):
|
||||||
|
keep = {k: os.environ.get(k) for k in env}
|
||||||
|
os.environ.update(env)
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
finally:
|
||||||
|
for k, v in keep.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
from harness import cc_backend as cb # noqa: E402
|
||||||
|
|
||||||
|
for k in [k for k in os.environ if k.startswith(("SIMBIOT_LLM_BACKEND", "SIMBIOT_CC_MODEL"))]:
|
||||||
|
os.environ.pop(k)
|
||||||
|
os.environ["SIMBIOT_HOME"] = tempfile.mkdtemp() # пустой .env инстанса
|
||||||
|
eq(cb.backend_for("min") == "strands", "default strands")
|
||||||
|
eq(with_env({"SIMBIOT_LLM_BACKEND": "claude_code"}, lambda: cb.backend_for("hour")) == "claude_code", "global switch")
|
||||||
|
eq(with_env({"SIMBIOT_LLM_BACKEND": "claude_code", "SIMBIOT_LLM_BACKEND_DAY": "strands"},
|
||||||
|
lambda: cb.backend_for("day")) == "strands", "per-level override wins")
|
||||||
|
eq(cb.model_for("min") == "claude-haiku-5-5" and cb.model_for("hour") == "claude-sonnet-5-5"
|
||||||
|
and cb.model_for("day") == "claude-opus-5-5", "default models per level")
|
||||||
|
eq(with_env({"SIMBIOT_CC_MODEL_MIN": "claude-sonnet-5-5"}, lambda: cb.model_for("min")) == "claude-sonnet-5-5", "model override")
|
||||||
|
d = cb._usage_delta({"input_tokens": 2, "cache_creation_input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 7})
|
||||||
|
eq(d == {"prompt_tokens": 17, "completion_tokens": 7, "model_calls": 1}, "usage delta")
|
||||||
|
|
||||||
|
|
||||||
|
def guard(circle, tool, inp, state):
|
||||||
|
ev = json.dumps({"hook_event_name": "PreToolUse", "tool_name": tool, "tool_input": inp})
|
||||||
|
r = subprocess.run([sys.executable, os.path.join(ROOT, "harness", "cc_guard.py")], input=ev, text=True,
|
||||||
|
capture_output=True, env=dict(os.environ, SIMBIOT_TICK_CIRCLE=str(circle), SIMBIOT_CC_STATE=state))
|
||||||
|
return r.returncode
|
||||||
|
|
||||||
|
|
||||||
|
st = tempfile.mktemp()
|
||||||
|
eq(guard(1, "Bash", {"command": "ls /"}, st) == 0, "owner: full shell")
|
||||||
|
eq(guard(4, "Bash", {"command": "rm -rf /tmp/x"}, st) == 2, "others: arbitrary shell blocked")
|
||||||
|
eq(guard(4, "Bash", {"command": "./venv/bin/python cli.py tg-reply m-1 -c hi"}, st) == 0, "others: tg-reply allowed")
|
||||||
|
eq(guard(3, "Write", {"file_path": "/root/x"}, st) == 2, "own: write blocked")
|
||||||
|
eq(guard(2, "Read", {"file_path": os.path.join(ROOT, ".env")}, st) == 2, "board: secrets blocked")
|
||||||
|
eq(guard(2, "Read", {"file_path": "docs/ARCHITECTURE.md"}, st) == 0, "board: docs readable")
|
||||||
|
eq(guard(4, "mcp__portal__list_members", {}, st) == 2, "others: portal registry blocked")
|
||||||
|
eq(guard(3, "ToolSearch", {"query": "x"}, st) == 0, "ToolSearch is internal")
|
||||||
|
eq(guard(3, "WebFetch", {"url": "x"}, st) == 2, "own: web blocked")
|
||||||
|
st2 = tempfile.mktemp()
|
||||||
|
codes = [guard(1, "mcp__portal__whoami", {}, st2) for _ in range(5)]
|
||||||
|
eq(codes == [0, 0, 0, 0, 2], f"portal MCP limit 4/tick: {codes}")
|
||||||
|
print(f"{ok} ok, {bad} fail")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
Loading…
Reference in a new issue