#!/bin/bash # kanban-gateway: SSH forced-command wrapper for n8n -> Hermes kanban. # # Install on the Mac as the user that owns ~/.hermes, e.g. ~/bin/kanban-gateway (chmod 755). # Wire it up in ~/.ssh/authorized_keys with a forced command (see the line in the chat). # # Verbs n8n may call (sent as the SSH command string): # create # list [status] status in: triage todo ready running blocked review done archived # show id like t_ab12cd34 # # Nothing from the SSH command is ever passed through a shell or eval; the path # travels base64-encoded so spaces/quotes/$()/newlines in filenames are inert. set -euo pipefail # ---- EDIT THESE ------------------------------------------------------------ HERMES_BIN="${HERMES_BIN:-$HOME/.local/bin/hermes}" # absolute path; non-interactive SSH has a minimal PATH VAULT_ROOT="${VAULT_ROOT:-$HOME/Documents/wiki-llm}" # absolute path to the vault root RAW_DIR="raw" # inbox folder inside the vault LIBRARIAN_PROFILE="${LIBRARIAN_PROFILE:-librarian}" # assignee profile name SKILL_NAME="${SKILL_NAME:-llm-wiki}" # skill installed on that profile ('' to skip pinning) # ---------------------------------------------------------------------------- export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin" fail() { echo "kanban-gateway: $1" >&2; exit "${2:-2}"; } cmd_line="${SSH_ORIGINAL_COMMAND:-}" [[ "$cmd_line" != *$'\n'* ]] || fail "newline in command" # n8n's SSH node prefixes the command with "cd ; " (or "&&") when its Working Directory # field is set. The directory is never used or executed here; only that exact prefix shape is # stripped, and whatever remains must still be one of the allowed verbs below. cd_re='^cd[[:space:]]+("[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|`$()<>\\]+)[[:space:]]*(;|&&)[[:space:]]*' if [[ "$cmd_line" =~ $cd_re ]]; then cmd_line="${cmd_line#"${BASH_REMATCH[0]}"}" fi [[ -n "$cmd_line" ]] || fail "no command supplied" # Split only on spaces; the verb and a single argument. verb="${cmd_line%% *}" arg="" if [[ "$cmd_line" == *" "* ]]; then arg="${cmd_line#* }"; fi decode_b64() { # macOS older = -D, newer/GNU = -d printf '%s' "$1" | { base64 -d 2>/dev/null || base64 -D; } } case "$verb" in create) [[ "$arg" =~ ^[A-Za-z0-9+/=]+$ ]] || fail "create expects base64 argument" rel="$(decode_b64 "$arg")" || fail "bad base64" [[ -n "$rel" ]] || fail "empty path" # Reject control characters (incl. newlines), absolute paths, and traversal. if printf '%s' "$rel" | LC_ALL=C grep -q '[[:cntrl:]]'; then fail "control characters in path"; fi [[ "$rel" != /* ]] || fail "absolute path rejected" case "/$rel/" in */../*) fail "path traversal rejected" ;; esac [[ "$rel" == "$RAW_DIR"/* ]] || fail "path must be under $RAW_DIR/" vault_real="$(cd "$VAULT_ROOT" && pwd -P)" || fail "VAULT_ROOT not found" abs="$vault_real/$rel" [[ -f "$abs" ]] || fail "file not found: $rel" 3 # Resolve symlinks in the directory part and confirm it is still inside raw/. dir_real="$(cd "$(dirname "$abs")" && pwd -P)" || fail "cannot resolve directory" case "$dir_real/" in "$vault_real/$RAW_DIR/"*) ;; *) fail "resolved path escapes $RAW_DIR/" ;; esac # Folder-scoped rulesets live IN the vault as AGENTS.md files. Collect every AGENTS.md from the # vault root down to the file's folder (shallow -> deep) and name them on the card, so the # agent reads them deterministically instead of relying on lazy discovery. rules=() if [[ -f "$vault_real/AGENTS.md" && ! -L "$vault_real/AGENTS.md" ]]; then rules+=("AGENTS.md"); fi cur="" IFS='/' read -r -a parts <<< "$(dirname "$rel")" for part in "${parts[@]}"; do cur="${cur:+$cur/}$part" if [[ -f "$vault_real/$cur/AGENTS.md" && ! -L "$vault_real/$cur/AGENTS.md" ]]; then rules+=("$cur/AGENTS.md") fi done if (( ${#rules[@]} > 0 )); then rules_text="Rulesets for this file's folder. Read ALL of them before doing anything else, in this order (later ones are more specific and take precedence):" for r in "${rules[@]}"; do rules_text+=$'\n'"- $r"; done else rules_text="No AGENTS.md rulesets found for this folder; follow the llm-wiki skill defaults." fi title="Catalog: $(basename "$rel")" body="Catalog this new raw file per the llm-wiki skill. Relative path: $rel Absolute path: $abs $rules_text" args=( kanban create "$title" --body "$body" --assignee "$LIBRARIAN_PROFILE" --workspace "dir:$vault_real" --idempotency-key "$rel" --json ) if [[ -n "$SKILL_NAME" ]]; then args+=( --skill "$SKILL_NAME" ); fi exec "$HERMES_BIN" "${args[@]}" ;; list) if [[ -z "$arg" ]]; then exec "$HERMES_BIN" kanban list --json fi case "$arg" in triage|todo|ready|running|blocked|review|done|archived) exec "$HERMES_BIN" kanban list --status "$arg" --json ;; *) fail "invalid status" ;; esac ;; show) [[ "$arg" =~ ^t_[A-Za-z0-9]+$ ]] || fail "invalid task id" exec "$HERMES_BIN" kanban show "$arg" --json ;; *) fail "verb not allowed: $verb" ;; esac