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:
parent
0e6014fe98
commit
0a79e786fc
9 changed files with 80 additions and 26 deletions
|
|
@ -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"
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -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).
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "coord",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue