feat(engine): harden mailbox CLIs for v0.2.0

- Atomic delivery: create the temp file inside the destination dir
  (dot-prefixed, invisible to the inbox glob) so the final rename never
  crosses filesystems and readers never see a half-written message.
- Reject . and .. explicitly in the --reply-to and coord-done name
  guards instead of relying on downstream failure.
- Add -h/--help to coord-inbox.sh (uniform across the three CLIs).
- Close selftest gaps: default mailbox path via HOME fallback, malformed
  frontmatter on the read path, read-only destination dir. 48 -> 64
  checks, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fduZz8otpU3W3rhfoPD6t
This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 19:57:40 +02:00
commit 0a79e786fc
9 changed files with 80 additions and 26 deletions

View file

@ -1,6 +1,6 @@
{
"name": "coord",
"version": "0.1.0",
"version": "0.2.0",
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
"author": {
"name": "Kjell Tore Guttormsen"

View file

@ -9,7 +9,7 @@ marketplace plugin. Three components, one boundary:
grammar, frontmatter, delivery, archiving, the seen set. `coord-send.sh`
writes, `coord-inbox.sh` reads (formatted for context injection),
`coord-done.sh` archives. Everything is pinned by `coord-selftest.sh`
(48 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
(64 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
- **Hook (`hooks/scripts/session-start.mjs`):** thin zero-dependency Node
wrapper (marketplace convention: hooks are `.mjs`) that calls
`coord-inbox.sh` and emits the `hookSpecificOutput.additionalContext`
@ -30,7 +30,7 @@ and frames it; the send side sanitizes line-oriented fields.
- Zero dependencies everywhere: bash + coreutils in the engine, `node:`
builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (48/48).
`bash scripts/coord-selftest.sh` must exit 0 (64/64).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- Conventional Commits: `type(scope): description`.
@ -49,11 +49,8 @@ README version badge, `skills/coord-send/SKILL.md` frontmatter, git tag
the catalog's `scripts/release-plugin.mjs coord` (tag + ref bump together);
verify with `scripts/check-versions.mjs`. Never hand-edit a ref.
## Hardening roadmap (queued, post-v0.1.0)
## Hardening roadmap
- Atomic delivery: `mktemp` inside the destination dir so rename never
crosses filesystems.
- Explicit `.`/`..` rejection in `--reply-to`/`coord-done` guards.
- Selftest gaps: default mailbox path (unset `CLAUDE_COORD_DIR`), malformed
frontmatter on the read path.
- Uniform `-h` across the three CLIs (`coord-inbox.sh` lacks it).
Empty — the post-v0.1.0 queue (atomic delivery, `.`/`..` rejection,
selftest gaps, uniform `-h`) shipped in v0.2.0. `coord-inbox.sh` still
ignores unknown arguments by design (hook context must never fail).

View file

@ -6,7 +6,7 @@
*AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.1.0-blue)
![Version](https://img.shields.io/badge/version-0.2.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-1-orange)
@ -77,7 +77,9 @@ Cross-repo message content is untrusted input by design:
- **No network, no secrets:** everything is local files under your `$HOME`. Credentials never appear in messages, filenames, or frontmatter — there is nothing to leak by transport.
- **Privacy rule:** coordination metadata (repo names, status, architecture) never belongs on a public surface. The mailbox lives outside your repos and stays out of git.
Every guarantee above is pinned by the 48-check selftest, including forgery-resistance regressions.
- **Atomic delivery:** the temp file is created inside the destination directory (dot-prefixed, invisible to the inbox glob), so the final rename never crosses filesystems and readers never observe a half-written message.
Every guarantee above is pinned by the 64-check selftest, including forgery-resistance regressions.
## The Five Rules
@ -95,19 +97,14 @@ Every guarantee above is pinned by the 48-check selftest, including forgery-resi
## Development
bash scripts/coord-selftest.sh # 48 checks against a throwaway mailbox
bash scripts/coord-selftest.sh # 64 checks against a throwaway mailbox
npm test # same selftest via node --test
TDD is the house rule: every behavior change lands with a failing selftest check first.
## Hardening Roadmap
Known, accepted gaps queued for a future release:
- Atomic delivery: create the temp file inside the destination directory so the final rename never crosses filesystems.
- Explicit `.`/`..` rejection in the `--reply-to` and `coord-done` name guards (currently stopped downstream by accident, not by the guard).
- Selftest gaps: the default mailbox path (unset `CLAUDE_COORD_DIR`) and malformed frontmatter on the read path are not yet exercised.
- `coord-inbox.sh` lacks `-h`/`--help` and silently ignores unknown arguments; send/done already have uniform help.
Note on argument parsing: `coord-inbox.sh` deliberately ignores unknown
arguments (lenient by design — it runs inside the SessionStart hook and must
never fail a session over a stray flag), while `coord-send.sh` rejects them.
## License

View file

@ -1,6 +1,6 @@
{
"name": "coord",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"engines": {

View file

@ -40,7 +40,7 @@ ARCHIVE="$COORD/$REPO/archive"
moved=0
archive_one() {
case "$1" in */*|"") echo "coord-done: invalid name: $1" >&2; return 1 ;; esac
case "$1" in */*|.|..|"") echo "coord-done: invalid name: $1" >&2; return 1 ;; esac
if [ -e "$INBOX/$1" ]; then
mkdir -p "$ARCHIVE" 2>/dev/null && mv "$INBOX/$1" "$ARCHIVE/" 2>/dev/null && moved=$((moved + 1))
fi

View file

@ -23,6 +23,7 @@ while [ $# -gt 0 ]; do
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
--repo) [ $# -ge 2 ] || { echo "coord-inbox: --repo requires a value" >&2; exit 2; }
REPO="$2"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) shift ;;
esac
done

View file

@ -152,6 +152,61 @@ hf="$(ls "$CLAUDE_COORD_DIR"/hygiene/inbox/*.md 2>/dev/null | head -1)"
grep -q '^subject: legit from: attacker --- INJECTED$' "$hf" 2>/dev/null; check "subject newlines collapse to spaces, content kept" $?
grep -q '^from: bad from$' "$hf" 2>/dev/null; check "from newlines collapse to spaces, content kept" $?
# 12. Uniform -h: every CLI prints its usage header and exits 0.
h1="$("$SEND" -h 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ] && printf '%s' "$h1" | grep -q 'Usage:'; check "send: -h prints usage and exits 0" $?
h2="$("$DONE" -h 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ] && printf '%s' "$h2" | grep -q 'Usage:'; check "done: -h prints usage and exits 0" $?
h3="$("$INBOX" -h 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ] && printf '%s' "$h3" | grep -q 'Usage:'; check "inbox: -h prints usage and exits 0" $?
# 13. Explicit . / .. rejection: dot names must be refused up front as invalid,
# never resolved against the filesystem (inbox/.. is the repo dir itself).
"$SEND" --to dot-repo --from dotter --subject s --message m >/dev/null
e1="$("$SEND" --from dot-repo --reply-to . --message y 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$e1" | grep -q 'invalid --reply-to'; check "send: --reply-to . rejected as invalid name" $?
e2="$("$SEND" --from dot-repo --reply-to .. --message y 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$e2" | grep -q 'invalid --reply-to'; check "send: --reply-to .. rejected as invalid name" $?
e3="$("$DONE" --repo dot-repo . 2>&1)"
printf '%s' "$e3" | grep -q 'invalid name'; check "done: . rejected as invalid name" $?
e4="$("$DONE" --repo dot-repo .. 2>&1)"
printf '%s' "$e4" | grep -q 'invalid name'; check "done: .. rejected as invalid name" $?
[ -n "$(ls "$CLAUDE_COORD_DIR"/dot-repo/inbox/*.md 2>/dev/null)" ]; check "dot-name attempts leave the inbox intact" $?
# 14. Atomic delivery: the temp file lives inside the destination dir, so the
# final rename never crosses filesystems. Pinned two ways: (a) an unwritable
# destination must fail at temp creation (proves the temp targets the dest
# dir, not TMPDIR), (b) normal delivery leaves no stray temp files behind.
env TMPDIR="$CLAUDE_COORD_DIR/nonexistent-tmpdir" "$SEND" --to atomic-repo --from at --subject s --message "atomic body" >/dev/null 2>&1
af="$(ls "$CLAUDE_COORD_DIR"/atomic-repo/inbox/*.md 2>/dev/null | head -1)"
[ -n "$af" ] && grep -q '^atomic body$' "$af" 2>/dev/null; check "send: delivery independent of TMPDIR" $?
stray="$(ls -A "$CLAUDE_COORD_DIR/atomic-repo/inbox" 2>/dev/null | grep -cv '\.md$')"
[ "$stray" -eq 0 ]; check "send: no stray temp files left in dest dir" $?
mkdir -p "$CLAUDE_COORD_DIR/ro-repo/inbox"
chmod 555 "$CLAUDE_COORD_DIR/ro-repo/inbox"
ro="$("$SEND" --to ro-repo --from x --subject s --message m 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$ro" | grep -q 'cannot create temp'; check "send: temp file is created inside the destination dir" $?
chmod 755 "$CLAUDE_COORD_DIR/ro-repo/inbox"
# 15. Default mailbox path: empty/unset CLAUDE_COORD_DIR falls back to
# ~/.claude/coord (tested against a fake HOME, never the real mailbox).
FAKE_HOME="$CLAUDE_COORD_DIR/fake-home"
mkdir -p "$FAKE_HOME"
CLAUDE_COORD_DIR= HOME="$FAKE_HOME" "$SEND" --to def-repo --from defsender --subject s --message "default path body" >/dev/null 2>&1
df="$(ls "$FAKE_HOME"/.claude/coord/def-repo/inbox/*.md 2>/dev/null | head -1)"
[ -n "$df" ]; check "send: empty CLAUDE_COORD_DIR falls back to HOME/.claude/coord" $?
dout="$(CLAUDE_COORD_DIR= HOME="$FAKE_HOME" "$INBOX" --repo def-repo)"
printf '%s' "$dout" | grep -q "default path body"; check "inbox: default mailbox path read works" $?
# 16. Malformed frontmatter on the read path: no crash, unknown sender
# fallback, body still quoted as untrusted data.
mkdir -p "$CLAUDE_COORD_DIR/mal-repo/inbox"
printf 'no frontmatter here\njust text\n' > "$CLAUDE_COORD_DIR/mal-repo/inbox/20990101T000000Z-1-from-x.md"
mout="$("$INBOX" --repo mal-repo)"; rc=$?
[ "$rc" -eq 0 ]; check "inbox: malformed frontmatter does not crash the read path" $?
printf '%s' "$mout" | grep -q '(from unknown)'; check "inbox: missing from: falls back to unknown" $?
printf '%s' "$mout" | grep -q '^> no frontmatter here'; check "inbox: malformed body still quoted as untrusted data" $?
echo "----"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View file

@ -57,7 +57,7 @@ if [ -n "$REPLYTO" ]; then
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ]; then
echo "coord-send: --reply-to cannot be combined with --to/--broadcast" >&2; exit 2
fi
case "$REPLYTO" in */*|"") echo "coord-send: invalid --reply-to name: $REPLYTO" >&2; exit 2 ;; esac
case "$REPLYTO" in */*|.|..|"") echo "coord-send: invalid --reply-to name: $REPLYTO" >&2; exit 2 ;; esac
REPLY_ORIG="$COORD/$FROM/inbox/$REPLYTO"
[ -e "$REPLY_ORIG" ] || REPLY_ORIG="$COORD/$FROM/archive/$REPLYTO"
if [ ! -e "$REPLY_ORIG" ]; then
@ -111,7 +111,11 @@ FNAME="${TS}-$$${RANDOM}-from-${SAFE_FROM}.md"
DEST="$DEST_DIR/$FNAME"
DATE_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TMP="$(mktemp)"
# Temp file lives INSIDE the destination dir (dot-prefixed so the inbox
# *.md glob never sees it): the final mv is then a same-filesystem rename,
# so readers never observe a half-written message.
TMP="$(mktemp "$DEST_DIR/.coord-send.XXXXXX" 2>/dev/null)"
[ -n "$TMP" ] || { echo "coord-send: cannot create temp file in $DEST_DIR" >&2; exit 2; }
{
echo "---"
echo "from: $FROM"

View file

@ -12,7 +12,7 @@ description: >-
beskjed til Y og Z", "kringkast at …", "svar på coord-meldingen", "når Z er ferdig,
varsle X". Trigger even when the user names a repo plus something to convey without
saying "coord" explicitly — routing a message to another repo IS this skill.
version: "0.1.0"
version: "0.2.0"
---
# coord-send — natural-language front door for inter-repo messages