Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion plugins/macdoc/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
10 changes: 10 additions & 0 deletions plugins/macdoc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions plugins/macdoc/hooks/session-start-verify.sh
Original file line number Diff line number Diff line change
@@ -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
}
67 changes: 42 additions & 25 deletions plugins/macdoc/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 </dev/null >"$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"
Expand Down
111 changes: 111 additions & 0 deletions plugins/macdoc/tests/session-start-reverify.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
#!/bin/bash
echo fake-$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"
Loading