From ce529d60728eff8519422ce025aa9d9673d9108c Mon Sep 17 00:00:00 2001 From: leeseoki0 Date: Sun, 24 May 2026 15:48:13 -0700 Subject: [PATCH] fix(kanban): scratch tasks must not inherit board.default_workdir (#28818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board defaults represent persistent project checkouts. Scratch workspaces are auto-deleted on completion and must stay under the per-board scratch root that resolve_workspace() creates. Inheriting default_workdir for a scratch task pointed the cleanup path at the user's source tree — the data-loss vector documented in #28818. The containment guard in _cleanup_workspace (just added) is the safety rail. This commit prevents the bad state from being created in the first place: only persistent kinds (dir/worktree) inherit board defaults. Tests updated to cover the new semantics: scratch with default_workdir set keeps workspace_path=None; dir/worktree still inherits the board default. Salvaged from PR #31315 by @leeseoki0 — prevention layer on top of the #28819 containment fix by @briandevans. Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- hermes_cli/kanban_db.py | 11 +++++++++-- tests/hermes_cli/test_kanban_db.py | 25 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 2918fd2fae3..c89e697c98d 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1651,8 +1651,15 @@ def create_task( now = int(time.time()) # Resolve workspace_path from board-level default_workdir when the - # caller did not specify one explicitly. - if workspace_path is None: + # caller did not specify one explicitly. Board defaults represent + # persistent project checkouts, so only persistent workspace kinds may + # inherit them. Scratch workspaces are auto-deleted on completion and + # must stay under the per-board scratch root created by + # ``resolve_workspace``; inheriting ``default_workdir`` for a scratch + # task would point cleanup at the user's source tree (#28818). The + # containment guard in ``_cleanup_workspace`` is the safety rail, but + # we also stop the bad state from being created in the first place. + if workspace_path is None and workspace_kind in {"dir", "worktree"}: board_slug = board if board else get_current_board() board_meta = read_board_metadata(board_slug) board_default = board_meta.get("default_workdir") diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index f39c1894e6e..883cf8f4d5d 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -2596,13 +2596,32 @@ def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch): # --------------------------------------------------------------------------- -def test_create_task_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch): - """Board with default_workdir → create_task without workspace_path → inherits default.""" +def test_create_task_scratch_without_workspace_ignores_board_default_workdir(kanban_home, monkeypatch): + """Scratch tasks must NOT inherit board.default_workdir — would point auto-cleanup + at the user's source tree on completion (#28818).""" default_wd = "/home/user/project" kb.create_board("work-proj", default_workdir=default_wd) with kb.connect(board="work-proj") as conn: - tid = kb.create_task(conn, title="inherited", board="work-proj") + tid = kb.create_task(conn, title="scratch-task", board="work-proj") + t = kb.get_task(conn, tid) + assert t is not None + assert t.workspace_kind == "scratch" + assert t.workspace_path is None + + +def test_create_task_dir_without_workspace_inherits_board_default_workdir(kanban_home, monkeypatch): + """Board default_workdir is for persistent dir/worktree workspaces, not scratch.""" + default_wd = "/home/user/project" + kb.create_board("work-proj-dir", default_workdir=default_wd) + + with kb.connect(board="work-proj-dir") as conn: + tid = kb.create_task( + conn, + title="inherited", + workspace_kind="dir", + board="work-proj-dir", + ) t = kb.get_task(conn, tid) assert t is not None assert t.workspace_path == default_wd