feat(kanban): attachment toolset + CLI to match the dashboard surface

The kanban board has had full attachment storage and a dashboard HTTP
API (upload/list/download/delete) since #35338, but there was no agent
toolset tool and no `hermes kanban` CLI verb for attachments. Agents and
scripts that don't go through the dashboard server (or can't touch the DB
directly) had no way to create or read real attachments — only links in
comments.

Close that gap by mirroring the existing comment surface:

- `kanban_db.store_attachment_bytes()` — one shared write path (validate
  name, enforce the 25 MB cap, write the blob under the per-task dir with
  collision-free naming, insert the metadata row, clean up an orphan blob
  if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`,
  and a new `_collision_free_path` move here so the dashboard, the tool,
  and the CLI all share one implementation and can't drift.
- Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64),
  `kanban_attach_url` (server-side http/https fetch with the same cap),
  `kanban_attachments` (list). Write tools respect worker task-ownership;
  list is read-only. Registered in the `kanban` toolset.
- CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`,
  `attach-rm <attachment_id>`.
- Dashboard `upload_task_attachment` now imports the shared helpers and
  uses `_collision_free_path` — behavior identical (still streams to disk
  with the cap, still 413 on overflow).
- Docs (AGENTS.md, kanban-worker skill) and toolset membership updated.

Tests: tool round-trip + oversize + bad base64 + ownership; attach_url
against a local HTTP fixture incl. oversize-mid-stream and non-http
scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit
tests; dashboard parity preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
otsune 2026-06-01 11:59:30 +09:00 committed by Teknium
parent 14f023cd00
commit 3fccd698fd
9 changed files with 930 additions and 39 deletions

View file

@ -660,28 +660,16 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
# Attachments — upload / list / download / delete (#35338)
# ---------------------------------------------------------------------------
# Cap a single upload so a runaway request can't fill the disk. 25 MB
# comfortably covers PDFs, images, and source docs — the kanban use case.
_MAX_ATTACHMENT_BYTES = kanban_db.KANBAN_ATTACHMENT_MAX_BYTES
def _safe_attachment_name(raw: str) -> str:
"""Reduce a client-supplied filename to a safe basename.
Strips any directory components (``os.path.basename`` on both
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
to its leaf. Rejects empty / dotfile-only names. The result is only
ever joined under the per-task attachments dir, never used verbatim
as a path from the client.
"""
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
# Drop control chars and leading dots so we never write a dotfile or
# a name with embedded NULs/newlines.
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
name = name.lstrip(".").strip()
if not name:
raise HTTPException(status_code=400, detail="invalid attachment filename")
return name[:200]
# The size cap, filename sanitiser, and collision resolver now live in
# ``kanban_db`` so the dashboard, the agent toolset, and the CLI share one
# implementation and cannot drift. ``_safe_attachment_name`` raises a plain
# ``ValueError`` there; the upload handler's ``except ValueError`` below maps
# it to a 400, preserving the previous response.
from hermes_cli.kanban_db import ( # noqa: E402
_MAX_ATTACHMENT_BYTES,
_collision_free_path,
_safe_attachment_name,
)
@router.get("/tasks/{task_id}/attachments")
@ -727,13 +715,8 @@ async def upload_task_attachment(
dest_dir.mkdir(parents=True, exist_ok=True)
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
stem, dot, ext = safe_name.partition(".")
candidate = safe_name
n = 1
while (dest_dir / candidate).exists():
candidate = f"{stem} ({n}){dot}{ext}"
n += 1
dest_path = dest_dir / candidate
dest_path = _collision_free_path(dest_dir, safe_name)
candidate = dest_path.name
total = 0
try: