diff --git a/Agent.md b/Agent.md index c497acfb..cbc59fce 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (958) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (981) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/protocol.py b/emrg/protocol.py index b3ad636d..239179c2 100644 --- a/emrg/protocol.py +++ b/emrg/protocol.py @@ -30,6 +30,11 @@ class TaskRequest: default_factory=lambda: datetime.now().isoformat() ) images: Optional[list[dict]] = None + # Sandbox tier for the task's bash tool (rant 2026-08-20T15:46:50): + # "read-only" | "workspace-write" | "danger-full-access" (default None = + # danger-full-access, current behavior). Set by task config, never by + # the agent itself. + sandbox: Optional[str] = None def to_dict(self) -> dict: d = { @@ -42,6 +47,8 @@ def to_dict(self) -> dict: } if self.images: d["images"] = self.images + if self.sandbox: + d["sandbox"] = self.sandbox return d diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 3cfab6d5..426581fe 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -613,6 +613,9 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) -> cwd=cwd, prompt=prompt, timestamp="", + # Upgrade writes install/ and source/ inside its own work dir — + # workspace-write tier (rant 2026-08-20T15:46:50). + sandbox="workspace-write", ) if self._session_busy.get(session_id): # Queue per existing semantics (host decision A: busy → pending, @@ -772,6 +775,7 @@ async def _handle_client(self, ws) -> None: prompt=data.get("prompt", ""), timestamp=data.get("timestamp", ""), images=data.get("images"), + sandbox=data.get("sandbox"), ) except Exception as e: await self._send(ws, {"error": f"invalid task: {e}"}) @@ -1467,6 +1471,7 @@ async def _process_message( enabled=msg.get("enabled", True), repo=msg.get("repo"), description=msg.get("description"), + sandbox=msg.get("sandbox"), ) if not ok: await self._send(ws, {"type": "task_result", "error": res}) @@ -1478,7 +1483,7 @@ async def _process_message( if not self._scheduler: await self._send(ws, {"type": "task_result", "error": "scheduler not running"}) return - fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg} + fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description", "sandbox") if k in msg} if "task_type" in fields: fields["type"] = fields.pop("task_type") ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields) @@ -2536,6 +2541,12 @@ async def _run_tool_loop( elif tc_name == "grep" and "path" not in args: args["path"] = str(session.cwd) + # Sandbox tier (rant 2026-08-20T15:46:50): the task's + # configured sandbox is injected into the bash tool — the + # agent cannot choose it per call. + if tc_name == "bash" and req.sandbox: + args["sandbox"] = req.sandbox + # Execute tool = self.tools.get(tc_name) if tool: diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 8c3b0c97..25301873 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -25,6 +25,7 @@ from emrg.config import config_dir from emrg.connect import connect_to_server +from emrg.tools.bash_tool import SANDBOX_MODES from websockets.exceptions import ConnectionClosed from emrg.protocol import EvolutionLog, InstanceIdentity from emrg.server.atomic import atomic_write_yaml @@ -130,6 +131,7 @@ def _task_cfg_signature(cfg: dict) -> tuple: json.dumps(conf, sort_keys=True), cfg.get("interval", DEFAULT_INTERVAL), bool(cfg.get("enabled", True)), + cfg.get("sandbox"), ) @@ -198,6 +200,7 @@ def __init__( interval: int, identity: InstanceIdentity, template_path: Path | None = None, + sandbox: str | None = None, ) -> None: self.name = name # Rant 2026-08-19T10:18:44: per-task logger — LoggerAdapter injects a @@ -281,6 +284,32 @@ def __init__( self._repo_configured = project_name == "emrg" self._session_id = f"emrg-evolution-{name}" self._source_dir = path or name + # Sandbox tier for this task's bash tool (rant 2026-08-20T15:46:50): + # explicit config wins; builtin tasks get suggested defaults; None = + # danger-full-access (current behavior). + self._sandbox = self._resolve_sandbox(name, config, sandbox) + if self._sandbox: + self._logger.info( + "TaskHandler[%s]: bash sandbox tier = %s", name, self._sandbox + ) + + @staticmethod + def _resolve_sandbox(name: str, config: dict, explicit: str | None) -> str | None: + """Effective bash sandbox tier for a task. + + Order: tasks.yml top-level ``sandbox:`` field → ``config.sandbox`` → + builtin defaults by task name → None (= danger-full-access, the + existing un-sandboxed behavior). Invalid values fall through to the + defaults rather than breaking the task. + """ + for cand in (explicit, config.get("sandbox")): + if cand in SANDBOX_MODES and cand != "danger-full-access": + return cand + if name == "emrg-task": + return "workspace-write" # evolution writes its own repo + if name.endswith("-opensource-task"): + return "read-only" # community work in host-owned repos + return None # ── Saturation state (restored from disk across daemon restarts) ── @@ -684,6 +713,7 @@ async def _run_evolution_cycle(self) -> None: "prompt": prompt, "stream": True, "timestamp": cycle_time.isoformat(), + "sandbox": self._sandbox, }, ensure_ascii=False, ) @@ -961,6 +991,7 @@ def _build_handler(self, cfg: dict) -> TaskHandler: interval=cfg.get("interval", DEFAULT_INTERVAL), identity=self.identity, template_path=template_path, + sandbox=cfg.get("sandbox"), ) def _start_handler_for(self, cfg: dict) -> TaskHandler: @@ -1267,12 +1298,15 @@ def task_create( self, name: str, task_type: str, project: str, interval: int | None = None, enabled: bool = True, repo: str | None = None, description: str | None = None, + sandbox: str | None = None, ) -> tuple[bool, str | dict]: """Create a task. Returns (ok, error) or (ok, task-dict).""" interval = DEFAULT_INTERVAL if interval is None else interval err = self._validate_task_fields(name, task_type, project, interval) if err: return False, err + if sandbox is not None and sandbox not in SANDBOX_MODES: + return False, f"invalid sandbox {sandbox!r} (expected one of {', '.join(SANDBOX_MODES)})" tasks = self._load_tasks() if any(t.get("name") == name for t in tasks): return False, f"task {name!r} already exists" @@ -1287,6 +1321,8 @@ def task_create( "enabled": bool(enabled), "last_run": None, } + if sandbox is not None: + task["sandbox"] = sandbox if description: task["description"] = description tasks.append(task) @@ -1327,6 +1363,10 @@ def task_update(self, name: str, **fields) -> tuple[bool, str | dict]: task["enabled"] = bool(fields["enabled"]) if "description" in fields: task["description"] = fields["description"] + if "sandbox" in fields: + if fields["sandbox"] is not None and fields["sandbox"] not in SANDBOX_MODES: + return False, f"invalid sandbox {fields['sandbox']!r} (expected one of {', '.join(SANDBOX_MODES)})" + task["sandbox"] = fields["sandbox"] self._save_tasks(tasks) logger.info("TaskScheduler: task %s updated", name) return True, task diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 6ae62cf7..60113def 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -94,6 +94,159 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: return rewritten.rstrip(), path +# ── Sandbox — file-level isolation for the bash tool (rant 2026-08-20T15:46:50) ── +# +# Three tiers (default danger-full-access = current, un-sandboxed behavior): +# danger-full-access — no checks at all (existing behavior) +# read-only — no writes allowed: destructive commands (rm -r / +# rmdir / mv / cp -r) and shell redirects (> / >>) +# to any non-/dev/null target are blocked +# workspace-write — writes inside the workspace root (and the OS temp +# area) are allowed; destructive writes to protected +# daemon state files and to absolute paths outside +# the workspace are blocked +# +# Enforcement is deliberately heuristic (host design-finalized): a static +# command scan, NOT an OS-level sandbox (no bwrap/Seatbelt/ACL). The checked +# modes report enforcement="partial" — honest reporting, never pretending +# full OS-level isolation. The core value is blocking a hallucinated LLM's +# obviously destructive commands (rm -rf with a wrong path, writing the +# daemon's own state files). + +SANDBOX_MODES = ("danger-full-access", "read-only", "workspace-write") + +# Daemon state files — writing to these from a sandboxed task is always +# blocked (they are the daemon's own data, not agent scratch space). +_PROTECTED_FILES = ( + "~/.emrg/config.toml", + "~/.emrg/emrgd.token", + "~/.emrg/tasks.yml", + "~/.emrg/projects.yml", + "~/.emrg/rants.jsonl", +) + + +def _extract_write_targets(cmd: str) -> list[str]: + """Heuristic extraction of write targets from a command line. + + Returns path tokens the command appears to write to: + - ``rm -r/-rf/-R `` and ``rmdir `` → the removed path + - ``mv `` / ``cp -r `` → the destination + - ``> / >> / 2> / &>`` redirects → the redirect target + + Deliberately non-exhaustive (the sandbox only catches obvious + destructive writes — the boundary is honest: enforcement=partial). + """ + targets: list[str] = [] + # rm -r / rm -rf / rm -R ... (recursive delete) + for m in re.finditer(r"\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+)", cmd): + targets.append(m.group(1)) + # rmdir + for m in re.finditer(r"\brmdir\s+([^\s|;&]+)", cmd): + targets.append(m.group(1)) + # mv — the destination is the last bare token + for m in re.finditer(r"\bmv\s+((?:-[a-zA-Z]*\s+)*[^\s|;&]+\s+[^\s|;&]+)", cmd): + toks = m.group(1).split() + if len(toks) >= 2: + targets.append(toks[-1]) + # cp -r — the destination is the last bare token + for m in re.finditer(r"\bcp\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*\s+)+([^\s|;&]+\s+[^\s|;&]+)", cmd): + toks = m.group(1).split() + if len(toks) >= 2: + targets.append(toks[-1]) + # shell redirects: > file / >> file / 2> file / &> file + for m in re.finditer(r"(?:\d*>>?|&>>?)\s*([^\s|;&]+)", cmd): + targets.append(m.group(1)) + return targets + + +def _protected_paths() -> list[str]: + """Canonicalized (realpath) protected daemon state files.""" + out: list[str] = [] + for p in _PROTECTED_FILES: + try: + out.append(os.path.realpath(os.path.expanduser(p))) + except OSError: + pass + return out + + +def _is_absolute_path(p: str) -> bool: + """True when ``p`` is absolute (or drive-less rooted, e.g. ``/etc/hosts`` + on Windows — ntpath.isabs returns False for those, but they still do not + resolve under the cwd, so the sandbox must treat them as absolute).""" + return os.path.isabs(p) or p.startswith("/") or p.startswith("\\") + + +def _is_within(path: str, root: str) -> bool: + """True when ``path`` (absolute) is inside ``root`` (absolute) or equals it.""" + try: + rp = os.path.realpath(path) + rr = os.path.realpath(root) + return rp == rr or rp.startswith(rr + os.sep) + except OSError: + return False + + +def _check_sandbox(cmd: str, mode: str, workdir: str | None = None) -> tuple[bool, str | None, str]: + """Static sandbox check for a bash command (rant 2026-08-20T15:46:50). + + Returns ``(allowed, blocked_reason, enforcement)``: + - danger-full-access → (True, None, "full") — no checks, current behavior. + - read-only → blocks every destructive write (rm -r / rmdir / mv / + cp -r and shell redirects to any non-/dev/null target). + - workspace-write → blocks destructive writes to protected daemon + files, to ``~/.emrg`` itself, and to absolute paths outside the + workspace root (the OS temp dir is allowed — mirrors dsh's + workspace + backend-promised temp area). + + Heuristic by design: static scan only, no OS-level boundary — checked + modes honestly report enforcement="partial". + """ + if mode not in SANDBOX_MODES: + return False, f"invalid sandbox mode {mode!r}", "partial" + if mode == "danger-full-access": + return True, None, "full" + + targets = _extract_write_targets(cmd) + if mode == "read-only": + for t in targets: + if t != "/dev/null": + return False, ( + f"read-only sandbox: blocked destructive write targeting {t!r}" + ), "partial" + return True, None, "partial" + + # workspace-write + if not targets: + return True, None, "partial" + protected = _protected_paths() + emrg_home = os.path.realpath(os.path.expanduser("~/.emrg")) + workdir_real = os.path.realpath(workdir) if workdir else None + for t in targets: + if t == "/dev/null": + continue + expanded = os.path.expanduser(t) + if not _is_absolute_path(expanded): + # Relative target: assumed in-workspace (cwd = the workspace root). + continue + real = os.path.realpath(expanded) + if real in protected: + return False, ( + f"workspace-write sandbox: blocked write to protected daemon file {t!r}" + ), "partial" + if real == emrg_home: + return False, ( + f"workspace-write sandbox: blocked destructive write to {t!r} " + "(would erase the daemon's data directory)" + ), "partial" + if workdir_real and not _is_within(real, workdir_real) and not _is_within(real, tempfile.gettempdir()): + return False, ( + f"workspace-write sandbox: blocked write outside workspace {t!r}" + ), "partial" + return True, None, "partial" + + def _decode_output(data: bytes, os_name: str | None = None) -> str: """Decode subprocess output bytes without corrupting non-UTF-8 text. @@ -166,10 +319,30 @@ async def execute(self, arguments: dict) -> ToolResult: cmd = arguments.get("command", "") timeout = arguments.get("timeout", 30) workdir = arguments.get("workdir", None) + # Sandbox tier — daemon-injected per task config (the agent cannot + # choose its own sandbox; rant 2026-08-20T15:46:50). + sandbox = arguments.get("sandbox") if not cmd: return ToolResult(name="bash", content="Error: no command provided", error=True) + # Static file-level isolation check (rant 2026-08-20T15:46:50). + sandbox_tag: str | None = None + if sandbox and sandbox != "danger-full-access": + allowed, reason, enforcement = _check_sandbox(cmd, sandbox, workdir) + if not allowed: + logger.info("bash: BLOCKED by %s sandbox: %s", sandbox, reason) + return ToolResult( + name="bash", + content=( + f"⛔ [sandbox:{sandbox} enforcement={enforcement}] " + f"{reason} — command not executed" + ), + error=True, + ) + sandbox_tag = f"[sandbox:{sandbox} enforcement={enforcement}]" + logger.debug("bash: sandbox %s check passed", sandbox) + logger.debug("bash: running %r (timeout=%ds)", cmd[:100], timeout) # Windows: cmd.exe cannot parse bash heredocs — translate the first @@ -271,6 +444,8 @@ async def execute(self, arguments: dict) -> ToolResult: if not parts: parts.append("(no output)") result = "\n".join(parts) + if sandbox_tag: + result = f"{sandbox_tag} ok\n{result}" return ToolResult(name="bash", content=result) except FileNotFoundError: return ToolResult( diff --git a/tests/test_bash_tool_sandbox.py b/tests/test_bash_tool_sandbox.py new file mode 100644 index 00000000..ab1149a8 --- /dev/null +++ b/tests/test_bash_tool_sandbox.py @@ -0,0 +1,207 @@ +"""Tests for the bash tool sandbox (rant 2026-08-20T15:46:50). + +Covers the static file-level isolation check: three tiers +(danger-full-access / read-only / workspace-write), protected daemon state +files, honest enforcement reporting (full/partial), and blocked-result +feedback through execute(). +""" + +import asyncio +import os +import tempfile + +import pytest + +from emrg.tools.bash_tool import ( + BashTool, + SANDBOX_MODES, + _check_sandbox, + _extract_write_targets, +) + + +def _run(coro): + return asyncio.run(coro) + + +# ── constant ────────────────────────────────────────────────────────────── + +def test_sandbox_modes_constant(): + assert SANDBOX_MODES == ("danger-full-access", "read-only", "workspace-write") + + +# ── _extract_write_targets ──────────────────────────────────────────────── + +def test_extract_rm_rf_target(): + assert _extract_write_targets("rm -rf /tmp/x") == ["/tmp/x"] + assert _extract_write_targets("rm -r ./build") == ["./build"] + assert _extract_write_targets("rm -rf /tmp/a; echo hi") == ["/tmp/a"] + + +def test_extract_rmdir_target(): + assert _extract_write_targets("rmdir /tmp/empty") == ["/tmp/empty"] + + +def test_extract_mv_destination(): + assert _extract_write_targets("mv /tmp/a /tmp/b") == ["/tmp/b"] + + +def test_extract_cp_r_destination(): + assert _extract_write_targets("cp -r src /tmp/dst") == ["/tmp/dst"] + assert _extract_write_targets("cp -R ./a ./b") == ["./b"] + + +def test_extract_redirect_targets(): + assert _extract_write_targets("echo x > /tmp/y") == ["/tmp/y"] + assert _extract_write_targets("echo x >> /tmp/y") == ["/tmp/y"] + assert _extract_write_targets("cmd 2> err.txt") == ["err.txt"] + assert _extract_write_targets("echo x > /dev/null") == ["/dev/null"] + # 2>&1 is not a file redirect — must not be captured + assert "&1" not in _extract_write_targets("echo x > /tmp/y 2>&1") + + +def test_extract_no_targets_for_plain_reads(): + assert _extract_write_targets("ls -la") == [] + assert _extract_write_targets("git status") == [] + assert _extract_write_targets("echo hello") == [] + + +# ── _check_sandbox — danger-full-access ─────────────────────────────────── + +def test_check_danger_full_access_always_allowed(): + allowed, reason, enforcement = _check_sandbox("rm -rf /", "danger-full-access") + assert allowed is True + assert reason is None + assert enforcement == "full" + + +def test_check_invalid_mode_blocked(): + allowed, reason, enforcement = _check_sandbox("echo hi", "sandboxed") + assert allowed is False + assert "invalid sandbox mode" in reason + assert enforcement == "partial" + + +# ── _check_sandbox — read-only ──────────────────────────────────────────── + +def test_check_read_only_blocks_destructive_commands(): + for cmd in ("rm -rf /tmp/x", "rmdir /tmp/empty", "mv /tmp/a /tmp/b", + "cp -r src /tmp/dst"): + allowed, reason, enforcement = _check_sandbox(cmd, "read-only") + assert allowed is False, cmd + assert "read-only sandbox" in reason + assert enforcement == "partial" + + +def test_check_read_only_blocks_redirects(): + allowed, _, _ = _check_sandbox("echo x > /tmp/y", "read-only") + assert allowed is False + allowed, _, _ = _check_sandbox("echo x >> /tmp/y", "read-only") + assert allowed is False + + +def test_check_read_only_allows_dev_null_redirect(): + allowed, _, _ = _check_sandbox("echo hi > /dev/null", "read-only") + assert allowed is True + + +def test_check_read_only_allows_read_commands(): + for cmd in ("ls -la", "git status", "cat file.txt", "pwd", "echo hi"): + allowed, _, _ = _check_sandbox(cmd, "read-only") + assert allowed is True, cmd + + +# ── _check_sandbox — workspace-write ────────────────────────────────────── + +def test_check_workspace_write_allows_relative_writes(): + # Relative targets are assumed in-workspace (cwd = workspace root). + allowed, _, _ = _check_sandbox("echo x > out.txt", "workspace-write") + assert allowed is True + allowed, _, _ = _check_sandbox("rm -rf ./build", "workspace-write") + assert allowed is True + + +def test_check_workspace_write_allows_temp_and_workspace_abs(): + allowed, _, _ = _check_sandbox(f"echo x > {tempfile.gettempdir()}/y", "workspace-write") + assert allowed is True + allowed, _, _ = _check_sandbox( + "echo x > /workspace/out.txt", "workspace-write", workdir="/workspace" + ) + assert allowed is True + + +def test_check_workspace_write_blocks_protected_daemon_file(): + allowed, reason, enforcement = _check_sandbox( + "echo x > ~/.emrg/config.toml", "workspace-write" + ) + assert allowed is False + assert "protected" in reason + assert enforcement == "partial" + + +def test_check_workspace_write_blocks_emrg_home_rm(): + allowed, reason, _ = _check_sandbox("rm -rf ~/.emrg", "workspace-write") + assert allowed is False + assert "daemon's data directory" in reason + + +def test_check_workspace_write_blocks_outside_workspace(): + allowed, _, _ = _check_sandbox( + "rm -rf /etc/hosts", "workspace-write", workdir="/workspace" + ) + assert allowed is False + allowed, _, _ = _check_sandbox( + "echo x > /etc/hosts", "workspace-write", workdir="/workspace" + ) + assert allowed is False + + +# ── execute() integration ───────────────────────────────────────────────── + +def test_execute_read_only_blocks_rm_rf(): + tool = BashTool() + result = _run(tool.execute({ + "command": "rm -rf /tmp/emrg-sandbox-test", + "sandbox": "read-only", + })) + assert result.error is True + assert "sandbox" in result.content + assert "not executed" in result.content + + +def test_execute_workspace_write_blocks_protected_file(): + tool = BashTool() + result = _run(tool.execute({ + "command": "echo x > ~/.emrg/config.toml", + "sandbox": "workspace-write", + })) + assert result.error is True + assert "sandbox" in result.content + + +def test_execute_sandboxed_success_tags_output(): + tool = BashTool() + result = _run(tool.execute({ + "command": "echo hi", + "sandbox": "workspace-write", + })) + assert not result.error + assert "[sandbox:workspace-write enforcement=partial] ok" in result.content + assert "hi" in result.content + + +def test_execute_danger_full_access_unchanged(): + tool = BashTool() + result = _run(tool.execute({"command": "echo hello", "sandbox": "danger-full-access"})) + assert not result.error + assert "hello" in result.content + assert "sandbox" not in result.content + + +def test_execute_no_sandbox_key_unchanged(): + """Default (no sandbox key) = danger-full-access = current behavior.""" + tool = BashTool() + result = _run(tool.execute({"command": "echo hello"})) + assert not result.error + assert "hello" in result.content + assert "sandbox" not in result.content diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 7ceef162..db5bbc52 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -1677,7 +1677,7 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None): calls.update(name=name, task_type=task_type, project=project) return True, {"name": name, "type": task_type} @@ -1707,7 +1707,7 @@ async def _test(): try: calls = {} - def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None, sandbox=None): calls.update(name=name, task_type=task_type, project=project) return True, {"name": name, "type": task_type}