Skip to content
Merged
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 @@ -1094,14 +1094,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
4 changes: 4 additions & 0 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ def _strip_yaml_frontmatter(content: str) -> str:
"- **Deliverables.** Files a human wants go in "
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
"`metadata` are NOT uploaded). Files must exist at completion.\n"
"- **Attachments.** Attach real downloadable artifacts instead of pasting "
"links in comments: `kanban_attach` (base64) or `kanban_attach_url` "
"(server-side public http(s) fetch); 25 MB cap, `kanban_attachments` "
"lists them. Workers may only attach to their own task.\n"
"- **Created cards.** List ids in `kanban_complete(created_cards=[...])` "
"ONLY when captured from a successful `kanban_create` return — never invent "
"or paste ids; the kernel rejects the completion on any phantom id.\n"
Expand Down
100 changes: 100 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,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 @@ -951,6 +969,9 @@ def kanban_command(args: argparse.Namespace) -> int:
"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 @@ -1851,6 +1872,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 @@ -2753,6 +2852,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
111 changes: 111 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -2963,6 +2963,117 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
# Attachments
# ---------------------------------------------------------------------------

# The attachment size cap is the module-level ``KANBAN_ATTACHMENT_MAX_BYTES``
# (defined near the top of this file) — one constant shared by the dashboard
# HTTP endpoint, the agent toolset, and the CLI so the limit cannot drift
# between surfaces.


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 = KANBAN_ATTACHMENT_MAX_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
45 changes: 14 additions & 31 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
KANBAN_ATTACHMENT_MAX_BYTES,
_collision_free_path,
_safe_attachment_name,
)


@router.get("/tasks/{task_id}/attachments")
Expand Down Expand Up @@ -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:
Expand All @@ -743,13 +726,13 @@ async def upload_task_attachment(
if not chunk:
break
total += len(chunk)
if total > _MAX_ATTACHMENT_BYTES:
if total > KANBAN_ATTACHMENT_MAX_BYTES:
out.close()
dest_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=(
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
f"attachment exceeds {KANBAN_ATTACHMENT_MAX_BYTES // (1024 * 1024)} MB limit"
),
)
out.write(chunk)
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"jtstothard@gmail.com": "jtstothard", # PR #63256 salvage (gateway: multiplex secondary adapter config validation)
"doogie@spark.local": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope)
"emrekoca2003@gmail.com": "kocaemre", # PR #36051 salvage (docs: audit round 3 code/doc reconciliation)
"13574+otsune@users.noreply.github.com": "otsune", # PR #36019 salvage (kanban: attachment toolset + CLI)
"205466933+wesleion@users.noreply.github.com": "wesleion", # PR #36049 salvage (telegram: per-topic free-response allowlist)
"evefromwayback@gmail.com": "evefromwayback", # PR #64611 salvage (agent: never load install-tree AGENTS.md as project context)
"Regina@Andreys-Mini.true.true": "Rival", # PR #64935/#64936 salvage (state: restore-boundary alternation repair; agent: turn-overlap tripwire)
Expand Down
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
Loading