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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue