Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,14 +827,16 @@ kanban task.

- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
`init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
`unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
`tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
`assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
`unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
`block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
`stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
`dispatch`, `daemon`, `gc`.
- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
`kanban_comment`, `kanban_create`, `kanban_link`; profiles that
explicitly enable the `kanban` toolset outside a dispatcher-spawned
task also get `kanban_list` and `kanban_unblock` for board routing.
`kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
`kanban_attach_url`, `kanban_attachments`; profiles that explicitly
enable the `kanban` toolset outside a dispatcher-spawned task also get
`kanban_list` and `kanban_unblock` for board routing.
- **Dispatcher:** long-lived loop that (default every 60s) reclaims
stale claims, promotes ready tasks, atomically claims, and spawns
assigned profiles. Runs **inside the gateway** by default via
Expand Down
100 changes: 100 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,24 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_comment.add_argument("--max-len", type=int, default=None,
help="Trim the stored comment body to this many characters")

# --- attach / attachments / attach-rm ---
p_attach = sub.add_parser("attach", help="Attach a local file to a task")
p_attach.add_argument("task_id")
p_attach.add_argument("path", help="Path to the local file to attach")
p_attach.add_argument("--content-type", default=None,
help="MIME type (default: guessed from the file extension)")
p_attach.add_argument("--name", default=None,
help="Stored filename (default: the source file's basename)")
p_attach.add_argument("--author", default=None,
help="uploaded_by label (default: $HERMES_PROFILE or 'user')")

p_attachments = sub.add_parser("attachments", help="List a task's attachments")
p_attachments.add_argument("task_id")
p_attachments.add_argument("--json", action="store_true")

p_attach_rm = sub.add_parser("attach-rm", help="Delete an attachment by id")
p_attach_rm.add_argument("attachment_id", type=int)

p_complete = sub.add_parser("complete", help="Mark one or more tasks done")
p_complete.add_argument("task_ids", nargs="+",
help="One or more task ids (only --result applies to all of them)")
Expand Down Expand Up @@ -945,6 +963,9 @@ def _restore_board_env() -> None:
"unlink": _cmd_unlink,
"claim": _cmd_claim,
"comment": _cmd_comment,
"attach": _cmd_attach,
"attachments": _cmd_attachments,
"attach-rm": _cmd_attach_rm,
"complete": _cmd_complete,
"edit": _cmd_edit,
"block": _cmd_block,
Expand Down Expand Up @@ -1864,6 +1885,84 @@ def _cmd_comment(args: argparse.Namespace) -> int:
return 0


def _cmd_attach(args: argparse.Namespace) -> int:
"""Attach a local file to a task.

Reads the file off disk, writes it under the task's attachments dir,
and records the metadata row via the shared ``store_attachment_bytes``
path (same code the dashboard upload and the agent tool use), so the
25 MB cap and name-sanitisation behave identically everywhere.
"""
import mimetypes

src = Path(args.path).expanduser()
if not src.is_file():
print(f"kanban: no such file: {src}", file=sys.stderr)
return 1
data = src.read_bytes()
name = args.name or src.name
content_type = args.content_type or mimetypes.guess_type(name)[0]
uploaded_by = args.author or _profile_author()
try:
with kb.connect_closing() as conn:
att_id = kb.store_attachment_bytes(
conn,
args.task_id,
name,
data,
content_type=content_type,
uploaded_by=uploaded_by,
)
except kb.AttachmentTooLarge as exc:
print(f"kanban: {exc}", file=sys.stderr)
return 1
print(f"Attached {name} to {args.task_id} (attachment {att_id}, {len(data)} bytes)")
return 0


def _cmd_attachments(args: argparse.Namespace) -> int:
"""List a task's attachments."""
with kb.connect_closing() as conn:
if kb.get_task(conn, args.task_id) is None:
print(f"no such task: {args.task_id}", file=sys.stderr)
return 1
atts = kb.list_attachments(conn, args.task_id)
if getattr(args, "json", False):
print(json.dumps([
{
"id": a.id,
"filename": a.filename,
"content_type": a.content_type,
"size": a.size,
"uploaded_by": a.uploaded_by,
"stored_path": a.stored_path,
"created_at": a.created_at,
}
for a in atts
], indent=2))
return 0
if not atts:
print(f"No attachments on {args.task_id}")
return 0
print(f"Attachments on {args.task_id}:")
for a in atts:
ct = a.content_type or "-"
print(f" [{a.id}] {a.filename} ({a.size} bytes, {ct}, by {a.uploaded_by or '-'})")
print(f" {a.stored_path}")
return 0


def _cmd_attach_rm(args: argparse.Namespace) -> int:
"""Delete an attachment by id (removes the row and the on-disk blob)."""
with kb.connect_closing() as conn:
removed = kb.delete_attachment(conn, args.attachment_id)
if removed is None:
print(f"no such attachment: {args.attachment_id}", file=sys.stderr)
return 1
print(f"Deleted attachment {args.attachment_id} ({removed.filename}) from {removed.task_id}")
return 0


def _worker_run_id_for(task_id: str) -> Optional[int]:
if os.environ.get("HERMES_KANBAN_TASK") != task_id:
return None
Expand Down Expand Up @@ -2751,6 +2850,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
`stats` Per-status / per-assignee counts
`create <title>…` Create a task (auto-subscribes you to events)
`comment <id> <msg>` Append a comment
`attach <id> <path>` Attach a local file; `attachments <id>` to list
`complete <id>…` Mark task(s) done
`block <id> [reason]` Mark blocked; `schedule <id> [reason]` parks time-delay work; `unblock <id>` to revive
`assign <id> <profile>` Reassign
Expand Down
112 changes: 112 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2505,6 +2505,118 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
# Attachments
# ---------------------------------------------------------------------------

# Cap a single attachment so a runaway upload can't fill the disk. 25 MB
# comfortably covers PDFs, images, and source docs — the kanban use case.
# Shared by the dashboard HTTP endpoint, the agent toolset, and the CLI so
# the limit cannot drift between surfaces.
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024


class AttachmentTooLarge(ValueError):
"""Raised when an attachment exceeds the configured size cap.

Subclasses :class:`ValueError` so generic ``except ValueError`` handlers
(e.g. the dashboard's 400 fallback) still catch it, while callers that
want a distinct user-facing message (the tool/CLI 413-equivalent) can
catch it specifically.
"""


def _safe_attachment_name(raw: str) -> str:
"""Reduce a client-supplied filename to a safe basename.

Strips any directory components (both separators) so a malicious
``../../etc/passwd`` or ``C:\\x`` collapses to its leaf. Drops control
chars and leading dots so we never write a dotfile or a name with
embedded NULs/newlines. 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.

Raises :class:`ValueError` on an unusable name; HTTP callers map that
to a 400.
"""
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
name = "".join(ch for ch in name if ch.isprintable() and ch not in "\x00").strip()
name = name.lstrip(".").strip()
if not name:
raise ValueError("invalid attachment filename")
return name[:200]


def _collision_free_path(dest_dir: Path, safe_name: str) -> Path:
"""Return a path under ``dest_dir`` that doesn't clobber an existing file.

``foo.pdf`` → ``foo.pdf``, then ``foo (1).pdf``, ``foo (2).pdf``, …
``safe_name`` must already be sanitised via :func:`_safe_attachment_name`.
"""
stem, dot, ext = safe_name.partition(".")
candidate = safe_name
n = 1
while (dest_dir / candidate).exists():
candidate = f"{stem} ({n}){dot}{ext}"
n += 1
return dest_dir / candidate


def store_attachment_bytes(
conn: sqlite3.Connection,
task_id: str,
filename: str,
data: bytes,
*,
content_type: Optional[str] = None,
uploaded_by: Optional[str] = None,
board: Optional[str] = None,
max_bytes: Optional[int] = None,
) -> int:
"""Validate, size-check, persist a blob, and record its metadata row.

This is the single write path shared by the dashboard endpoint, the
agent toolset (``kanban_attach`` / ``kanban_attach_url``), and the CLI
(``hermes kanban attach``) so name-sanitisation, the size cap, and the
collision-resolution all behave identically everywhere.

Steps: enforce ``max_bytes``, sanitise ``filename`` to a safe basename,
write the bytes under :func:`task_attachments_dir` with a
collision-free name, then insert the ``task_attachments`` row via
:func:`add_attachment`. Returns the new attachment id.

Raises :class:`AttachmentTooLarge` when ``data`` exceeds ``max_bytes``,
or :class:`ValueError` for a bad filename / unknown task. On any failure
after the blob is written (e.g. the task disappeared) the orphaned blob
is removed before re-raising.
"""
if max_bytes is None:
max_bytes = _MAX_ATTACHMENT_BYTES
if len(data) > max_bytes:
raise AttachmentTooLarge(
f"attachment exceeds {max_bytes // (1024 * 1024)} MB limit"
)
safe_name = _safe_attachment_name(filename)
dest_dir = task_attachments_dir(task_id, board=board)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_path = _collision_free_path(dest_dir, safe_name)
dest_path.write_bytes(data)
try:
return add_attachment(
conn,
task_id,
filename=dest_path.name,
stored_path=str(dest_path.resolve()),
content_type=content_type,
size=len(data),
uploaded_by=uploaded_by,
)
except Exception:
# Don't leave an orphan blob if the metadata insert fails (most
# commonly: the task id doesn't exist).
try:
dest_path.unlink(missing_ok=True)
except OSError:
pass
raise


def add_attachment(
conn: sqlite3.Connection,
task_id: str,
Expand Down
41 changes: 12 additions & 29 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,28 +635,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 = 25 * 1024 * 1024


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")
Expand Down Expand Up @@ -702,13 +690,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:
Expand Down
4 changes: 4 additions & 0 deletions skills/devops/kanban-worker/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ Every tool has a CLI equivalent for human operators and scripts:
- `kanban_complete` ↔ `hermes kanban complete <id> --summary "..." --metadata '{...}'`
- `kanban_block` ↔ `hermes kanban block <id> "reason"`
- `kanban_create` ↔ `hermes kanban create "title" --assignee <profile> [--parent <id>]`
- `kanban_attach` ↔ `hermes kanban attach <id> <path> [--name ...] [--content-type ...]`
- `kanban_attachments` ↔ `hermes kanban attachments <id>`
- etc.

**Attaching files.** When your run produces a real artifact a human or the next worker should download (a generated PDF/image/export), attach it instead of pasting a link in a comment: `kanban_attach` for bytes you have in hand (base64), or `kanban_attach_url` to have Hermes fetch an http(s) URL server-side. Both store a first-class attachment (25 MB cap) visible in the dashboard drawer and surfaced to the next worker's context with an absolute path. `kanban_attachments` lists what's already on the task. Workers may only attach to their own task.

Use the tools from inside an agent; the CLI exists for the human at the terminal.
7 changes: 6 additions & 1 deletion tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ def test_cli_gc_reports_counts(kanban_home):
# run_slash parity — every verb returns a sensible, non-crashy string
# ---------------------------------------------------------------------------

def test_run_slash_every_verb_returns_sensible_output(kanban_home):
def test_run_slash_every_verb_returns_sensible_output(kanban_home, tmp_path):
"""Smoke-test every verb with minimal args. None may raise, none may
return the empty string (must either succeed or report a usage error)."""
# Set up a pair of tasks to reference.
Expand All @@ -901,6 +901,9 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
finally:
conn.close()

attach_src = tmp_path / "smoke.txt"
attach_src.write_text("smoke")

invocations = [
"", # no subcommand → help text
"--help",
Expand All @@ -914,6 +917,8 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
f"unlink {tid_a} {tid_b}",
f"claim {tid_a}",
f"comment {tid_a} hello",
f"attach {tid_a} {attach_src}",
f"attachments {tid_a}",
f"complete {tid_a}",
f"block {tid_b} need input",
f"unblock {tid_b}",
Expand Down
Loading