diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 00b7dbe0..29c57193 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -20,14 +20,15 @@ }, { "name": "macdoc", - "version": "1.4.0", + "version": "1.4.1", "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理。v1.2.0: session-start hook 自動安裝 signed CLI binary(arm64)。", "author": { "name": "Che Cheng" }, "source": "./plugins/macdoc", "category": "productivity", - "binary_version": "0.7.0" + "binary_version": "0.7.0", + "binary_sha256": "9fe09f26b6c8f97f13520ec2f618b8918b475de9ebc8a9980fd64897b39ec298" }, { "name": "che-pdf-mcp", diff --git a/plugins/macdoc/.claude-plugin/plugin.json b/plugins/macdoc/.claude-plugin/plugin.json index 03e6e063..dbd3dec9 100644 --- a/plugins/macdoc/.claude-plugin/plugin.json +++ b/plugins/macdoc/.claude-plugin/plugin.json @@ -1,8 +1,9 @@ { "name": "macdoc", "description": "macOS 原生文件處理 CLI — 格式轉換、VLM OCR(含 host profile 設定)、SRT 處理。v1.2.0: session-start hook 自動安裝 signed CLI binary(arm64)。", - "version": "1.4.0", + "version": "1.4.1", "binary_version": "0.7.0", + "binary_sha256": "9fe09f26b6c8f97f13520ec2f618b8918b475de9ebc8a9980fd64897b39ec298", "author": { "name": "Che Cheng" } diff --git a/plugins/macdoc/CHANGELOG.md b/plugins/macdoc/CHANGELOG.md index 554a7e52..62713a9a 100644 --- a/plugins/macdoc/CHANGELOG.md +++ b/plugins/macdoc/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > `plugin.json` description field. Section categorization is best-effort — > review and refine `Added` / `Changed` / `Fixed` etc. as needed. +## [1.4.1] - 2026-08-24 + +### Fixed + +- SessionStart 不再執行常駐 `~/bin/macdoc --version`(PsychQuant/macdoc#161)。它先固定 system PATH,再以 `/usr/bin/codesign` 與 plugin-pinned `binary_sha256` 重驗 exact release bytes,只讀 installer sidecar 判斷版本;簽章/digest 不符、sidecar 缺失或版本不同時改嘗試一次 verified download。下載的 `.sha256` asset 也必須等於 plugin pin。這消除驗簽後從可替換路徑執行的 swap window,也避免 user-writable `~/bin` 劫持 trust-chain 工具;下載失敗仍維持 session fail-soft。 + +### Tests + +- 新增共用驗證 library 與 resident binary 對抗測試:簽章不符的 binary 不得被執行;合法且 digest/sidecar 相符的 binary 只驗簽、不執行、不連網;release-pin、下載 bytes 與 codesign 三個 candidate gate 各有獨立負向測試,並以實際 signed v0.7.0 fixture 驗證正向路徑。 + ## [1.4.0] - 2026-08-19 ### Changed diff --git a/plugins/macdoc/hooks/session-start-verify.sh b/plugins/macdoc/hooks/session-start-verify.sh new file mode 100644 index 00000000..9efaec55 --- /dev/null +++ b/plugins/macdoc/hooks/session-start-verify.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Shared verification primitives for the macdoc SessionStart hook. Production +# and tests call these exact functions; trust-chain executables are fixed. + +macdoc_verify_binary() { + /usr/bin/codesign --verify --strict -R "$2" "$1" 2>/dev/null +} + +macdoc_sha256_file() { + /usr/bin/shasum -a 256 "$1" 2>/dev/null | /usr/bin/awk '{print $1}' +} + +# Return codes identify the failed gate without parsing prose: +# 10 = release asset digest differs from plugin pin +# 11 = downloaded bytes differ from plugin pin +# 12 = candidate does not satisfy the Developer ID requirement +macdoc_verify_candidate() { + local candidate=$1 + local release_sha=$2 + local pinned_sha=$3 + local requirement=$4 + + [ "$release_sha" = "$pinned_sha" ] || return 10 + [ "$(macdoc_sha256_file "$candidate")" = "$pinned_sha" ] || return 11 + macdoc_verify_binary "$candidate" "$requirement" || return 12 +} diff --git a/plugins/macdoc/hooks/session-start.sh b/plugins/macdoc/hooks/session-start.sh index 3fa8d32e..ec8df516 100755 --- a/plugins/macdoc/hooks/session-start.sh +++ b/plugins/macdoc/hooks/session-start.sh @@ -14,6 +14,11 @@ set -u +# The install target commonly lives in ~/bin, which may also lead PATH. Never +# resolve trust-chain utilities through a user-writable directory (#161). +PATH="/usr/bin:/bin:/usr/sbin:/sbin" +export PATH + REPO="PsychQuant/macdoc" BINARY_NAME="macdoc" [ -n "${HOME:-}" ] || exit 0 # no HOME (exotic env) — nothing sane to do, never break session @@ -24,52 +29,64 @@ REQUIREMENT='=anchor apple generic and certificate 1[field.1.2.840.113635.100.6. note() { echo "macdoc plugin: $1" >&2; } soft_exit() { note "$1"; exit 0; } # fail-soft: never break session start -[ "$(uname -m)" = "arm64" ] || exit 0 # arm64-only release; Intel builds from source (silent — not an error) +[ "$(/usr/bin/uname -m)" = "arm64" ] || exit 0 # arm64-only release; Intel builds from source (silent — not an error) PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PLUGIN_JSON="$PLUGIN_ROOT/.claude-plugin/plugin.json" [ -f "$PLUGIN_JSON" ] || exit 0 +VERIFY_LIB="$PLUGIN_ROOT/hooks/session-start-verify.sh" +[ -f "$VERIFY_LIB" ] || soft_exit "verification library missing — refusing to trust or install a resident binary" +# Runtime path is derived from this plugin root. +# shellcheck disable=SC1090,SC1091 +. "$VERIFY_LIB" WANT=$(grep -oE '"binary_version"[[:space:]]*:[[:space:]]*"[^"]+"' "$PLUGIN_JSON" 2>/dev/null \ | head -1 | sed -E 's/.*"([^"]+)"$/\1/' || true) [ -n "$WANT" ] || exit 0 # no pinned CLI version — nothing to manage +WANT_SHA=$(grep -oE '"binary_sha256"[[:space:]]*:[[:space:]]*"[^"]+"' "$PLUGIN_JSON" 2>/dev/null \ + | head -1 | sed -E 's/.*"([^"]+)"$/\1/' || true) +[[ "$WANT_SHA" =~ ^[0-9a-fA-F]{64}$ ]] \ + || soft_exit "missing/malformed binary_sha256 in plugin.json — refusing to trust or install a resident binary" -# --version with a 5s alarm (a hung/planted binary must not stall every -# session start — fail-soft covers errors, not hangs; codex V114 HIGH-1). -# Probe writes to a FILE, not a pipe: a killed probe may leave grandchildren -# holding an inherited pipe fd, and command substitution would then wait on -# the pipe far past the alarm (empirically reproduced with a sleep-300 fake). -# Normalize to the semver token so banner-style output doesn't force a -# re-download loop (codex V114 M-2). -HAVE="" -PROBE=$(mktemp "${TMPDIR:-/tmp}/.macdoc.probe.XXXXXX" 2>/dev/null) || PROBE="" -if [ -n "$PROBE" ]; then - { perl -e 'alarm 5; exec @ARGV' -- "$BINARY" --version "$PROBE" 2>/dev/null; } 2>/dev/null || true - HAVE=$(head -1 "$PROBE" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) - rm -f "$PROBE" +# Exec-time re-verification happens before even asking the resident binary for +# its version. A binary that merely prints WANT must never reach a fast path +# unless its Developer ID chain + Team requirement is valid (#161). +RESIDENT_VERIFIED=false +if [ -x "$BINARY" ]; then + if macdoc_verify_binary "$BINARY" "$REQUIREMENT"; then + RESIDENT_SHA=$(macdoc_sha256_file "$BINARY") + [ "$RESIDENT_SHA" = "$WANT_SHA" ] && RESIDENT_VERIFIED=true + fi fi -[ "$HAVE" = "$WANT" ] && exit 0 # fast path: version matches, zero network -# Loop-guard sidecar: if a previous session already installed WANT but the -# binary self-reports an unparsable/odd version, do not re-download forever. +# SessionStart never executes the resident binary. Its version comes only from +# the installer-written sidecar, after the resident bytes pass codesign. This +# removes the verify-then-exec path-swap window; missing/stale sidecars cause a +# verified replacement download rather than probing untrusted executable code. GUARD="$INSTALL_DIR/.${BINARY_NAME}.installed_version" -[ -x "$BINARY" ] && [ "$(cat "$GUARD" 2>/dev/null)" = "$WANT" ] && exit 0 +HAVE=$(tr -d '[:space:]' < "$GUARD" 2>/dev/null || true) +$RESIDENT_VERIFIED && [ "$HAVE" = "$WANT" ] && exit 0 mkdir -p "$INSTALL_DIR" 2>/dev/null || soft_exit "cannot create $INSTALL_DIR — skipping auto-install" TMP=$(mktemp "$INSTALL_DIR/.${BINARY_NAME}.download.XXXXXX" 2>/dev/null) || soft_exit "mktemp failed — skipping auto-install" trap 'rm -f "$TMP"' EXIT URL="https://github.com/$REPO/releases/download/v$WANT/$BINARY_NAME" -curl -fsSL --proto '=https' --tlsv1.2 --max-time 300 "$URL" -o "$TMP" 2>/dev/null \ - || soft_exit "download failed for v$WANT (keeping existing ${HAVE:-none}); manual: https://github.com/$REPO/releases" +/usr/bin/curl -fsSL --proto '=https' --tlsv1.2 --max-time 300 "$URL" -o "$TMP" 2>/dev/null \ + || soft_exit "download failed for v$WANT; resident binary was not executed. Manual: https://github.com/$REPO/releases" -EXPECTED=$(curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "$URL.sha256" 2>/dev/null | head -1 | awk '{print $1}') +EXPECTED=$(/usr/bin/curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "$URL.sha256" 2>/dev/null | /usr/bin/head -1 | /usr/bin/awk '{print $1}') [[ "$EXPECTED" =~ ^[0-9a-fA-F]{64}$ ]] \ || soft_exit "missing/malformed .sha256 asset — refusing to install unverified binary" -[[ "$(shasum -a 256 "$TMP" | awk '{print $1}')" == "$EXPECTED" ]] \ - || soft_exit "sha256 mismatch — refusing to install" -codesign --verify --strict -R "$REQUIREMENT" "$TMP" 2>/dev/null \ - || soft_exit "code-signature verification failed (not Developer ID Team 6W377FS7BS) — refusing to install" +macdoc_verify_candidate "$TMP" "$EXPECTED" "$WANT_SHA" "$REQUIREMENT" +CANDIDATE_RC=$? +case "$CANDIDATE_RC" in + 0) ;; + 10) soft_exit "release sha256 asset does not match pinned binary_sha256 — refusing to install" ;; + 11) soft_exit "sha256 mismatch — refusing to install" ;; + 12) soft_exit "code-signature verification failed (not Developer ID Team 6W377FS7BS) — refusing to install" ;; + *) soft_exit "candidate verification failed unexpectedly — refusing to install" ;; +esac chmod +x "$TMP" || soft_exit "chmod failed" mv "$TMP" "$BINARY" || soft_exit "install mv failed" diff --git a/plugins/macdoc/tests/session-start-reverify.sh b/plugins/macdoc/tests/session-start-reverify.sh new file mode 100644 index 00000000..9e6c42a7 --- /dev/null +++ b/plugins/macdoc/tests/session-start-reverify.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +HOOK="$ROOT/plugins/macdoc/hooks/session-start.sh" +VERIFY_LIB="$ROOT/plugins/macdoc/hooks/session-start-verify.sh" +TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/macdoc-session-start-test.XXXXXX") +trap 'rm -rf "$TEST_ROOT"' EXIT + +FAKE_PATH="$TEST_ROOT/hostile-path" +INSTALL_DIR="$TEST_ROOT/install" +EVENT_LOG="$TEST_ROOT/events.log" +HOOK_STDERR="$TEST_ROOT/hook.stderr" +RESIDENT="$INSTALL_DIR/macdoc" +GUARD="$INSTALL_DIR/.macdoc.installed_version" +UNSIGNED_CANDIDATE="$TEST_ROOT/unsigned-candidate" +SIGNED_FIXTURE="${MACDOC_SIGNED_FIXTURE:-$HOME/bin/macdoc}" +REQUIREMENT='=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "6W377FS7BS"' +PINNED_SHA=9fe09f26b6c8f97f13520ec2f618b8918b475de9ebc8a9980fd64897b39ec298 +mkdir -p "$FAKE_PATH" "$INSTALL_DIR" + +for tool in uname awk codesign curl; do + cat > "$FAKE_PATH/$tool" <> "\$EVENT_LOG" +exit 22 +EOF + chmod +x "$FAKE_PATH/$tool" +done + +cat > "$RESIDENT" <<'EOF' +#!/bin/bash +echo resident-executed >> "$EVENT_LOG" +echo 'macdoc 0.7.0' +EOF + +cat > "$UNSIGNED_CANDIDATE" <<'EOF' +#!/bin/bash +echo candidate-executed >> "$EVENT_LOG" +echo 'macdoc 0.7.0' +EOF +chmod +x "$RESIDENT" "$UNSIGNED_CANDIDATE" + +run_hook() { + : > "$EVENT_LOG" + EVENT_LOG="$EVENT_LOG" \ + MACDOC_CODESIGN_BIN="$FAKE_PATH/codesign" \ + MACDOC_CURL_BIN="$FAKE_PATH/curl" \ + MACDOC_INSTALL_DIR="$INSTALL_DIR" \ + HTTPS_PROXY="http://127.0.0.1:9" \ + ALL_PROXY="http://127.0.0.1:9" \ + NO_PROXY="" \ + PATH="$FAKE_PATH:$PATH" \ + bash "$HOOK" >/dev/null 2>"$HOOK_STDERR" +} + +assert_no_hostile_tool_or_binary() { + if [ -s "$EVENT_LOG" ]; then + echo "FAIL: SessionStart executed a hostile PATH/env tool or binary: $(tr '\n' ' ' < "$EVENT_LOG")" >&2 + exit 1 + fi +} + +# A rejected resident must ignore hostile command overrides, never execute, +# and fail soft when the real download cannot connect. +echo 0.7.0 > "$GUARD" +run_hook +assert_no_hostile_tool_or_binary +grep -q 'resident binary was not executed' "$HOOK_STDERR" + +# Candidate integrity functions are the same fixed-tool functions sourced by +# production. Test each negative gate independently. +# Test derives the checked-out plugin root. +# shellcheck disable=SC1090,SC1091 +. "$VERIFY_LIB" + +set +e +macdoc_verify_candidate "$UNSIGNED_CANDIDATE" 0000000000000000000000000000000000000000000000000000000000000000 "$PINNED_SHA" "$REQUIREMENT" +rc_asset_pin=$? +macdoc_verify_candidate "$UNSIGNED_CANDIDATE" "$PINNED_SHA" "$PINNED_SHA" "$REQUIREMENT" +rc_bytes=$? +unsigned_sha=$(/usr/bin/shasum -a 256 "$UNSIGNED_CANDIDATE" | /usr/bin/awk '{print $1}') +macdoc_verify_candidate "$UNSIGNED_CANDIDATE" "$unsigned_sha" "$unsigned_sha" "$REQUIREMENT" +rc_signature=$? +set -e + +[[ "$rc_asset_pin" -eq 10 ]] +[[ "$rc_bytes" -eq 11 ]] +[[ "$rc_signature" -eq 12 ]] + +# Full positive coverage needs a real Team-signed fixture. Mandatory hostile +# cases above still run on CI machines without one. +if ! /usr/bin/codesign --verify --strict -R "$REQUIREMENT" "$SIGNED_FIXTURE" 2>/dev/null; then + echo "SKIP: positive signed-fixture cases (set MACDOC_SIGNED_FIXTURE)" + echo "PASS: hostile resident/candidate paths were rejected" + exit 0 +fi + +signed_sha=$(/usr/bin/shasum -a 256 "$SIGNED_FIXTURE" | /usr/bin/awk '{print $1}') +[[ "$signed_sha" = "$PINNED_SHA" ]] +macdoc_verify_candidate "$SIGNED_FIXTURE" "$PINNED_SHA" "$PINNED_SHA" "$REQUIREMENT" + +# Verified resident + matching sidecar is a zero-network, zero-execution path. +cp "$SIGNED_FIXTURE" "$RESIDENT" +chmod +x "$RESIDENT" +echo 0.7.0 > "$GUARD" +run_hook +assert_no_hostile_tool_or_binary + +echo "PASS: SessionStart uses fixed trust tools and verifies exact candidate bytes"