Skip to content
Merged
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: 2 additions & 3 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,12 @@
]
},
"enabledPlugins": {
"frontend-design@claude-code-plugins": true,
"rust-analyzer-lsp@claude-plugins-official": true,
"cc-suite@xiaolai": true,
"tdd-guardian@xiaolai": true,
"claude-english-buddy@xiaolai": true,
"docs-guardian@xiaolai": true,
"typescript-lsp@claude-plugins-official": true,
"vmark-lsp@vmark-local": true
"vmark-lsp@vmark-local": true,
"frontend-design@claude-plugins-official": true
}
}
25 changes: 23 additions & 2 deletions .github/workflows/release-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ jobs:

- name: The bundled MCP sidecar reports its version
shell: bash
# The sidecar is a Tauri externalBin (Contents/MacOS/vmark-mcp-server*),
# a CLI — so unlike the GUI app it can prove itself in headless CI. Its
# The sidecar (Contents/MacOS/vmark-mcp-server; an externalBin, except in
# the macOS release, which places it via bundle.macOS.files) is a CLI —
# so unlike the GUI app it can prove itself in headless CI. Its
# --version must match the tag, and --health-check must pass: each has
# failed silently in a shipped binary before elsewhere, and each looks
# identical to success until someone runs it.
Expand All @@ -200,6 +201,26 @@ jobs:
}
"$SIDECAR" --health-check

- name: The published bundle carries the intended entitlements
shell: bash
# Same check release.yml runs before publishing, now on the notarized,
# downloaded artifact: the app declares microphone, camera and Apple
# Events for its terminal (#1483); the sidecar carries none of them.
# Enforced from v0.9.89, the first release built this way; a manual run
# against an older tag would otherwise fail on what that tag never had.
timeout-minutes: 3
env:
APP: ${{ steps.mount.outputs.app }}
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
FIRST=0.9.89
if [ "$(printf '%s\n%s\n' "$FIRST" "${TAG#v}" | sort -V | head -n 1)" != "$FIRST" ]; then
echo "skip: $TAG predates the entitlement layout (first: v$FIRST)"
exit 0
fi
scripts/verify-macos-bundle-entitlements.sh "$APP"

- name: Unmount
if: always() && steps.mount.outputs.mount != ''
shell: bash
Expand Down
58 changes: 55 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,31 @@ jobs:
# same commit the trigger already carried.
ref: refs/tags/${{ needs.create-release.outputs.version }}

# Decided from the CHECKED-OUT tree, not from this workflow file: a manual
# dispatch runs today's workflow against an older tag. Trees that carry
# the overlay ship the pre-signed sidecar through bundle.macOS.files
# instead of externalBin, which the bundler would re-sign with the app's
# privacy entitlements (#1483), and are verified after the build. Trees
# from before it build the way they always did. Half a layout fails.
- name: Resolve macOS bundle layout
id: layout
if: matrix.platform == 'macos-latest'
run: |
set -euo pipefail
OVERLAY=src-tauri/tauri.macos-release.conf.json
VERIFIER=scripts/verify-macos-bundle-entitlements.sh
if [ -f "$OVERLAY" ] && [ -f "$VERIFIER" ]; then
echo "config=--config $OVERLAY" >> "$GITHUB_OUTPUT"
echo "verify=true" >> "$GITHUB_OUTPUT"
elif [ ! -e "$OVERLAY" ] && [ ! -e "$VERIFIER" ]; then
echo "::notice::This tag predates the sidecar entitlement layout (#1483): building with externalBin, no bundle verification."
echo "config=" >> "$GITHUB_OUTPUT"
echo "verify=false" >> "$GITHUB_OUTPUT"
else
echo "::error::Only one of $OVERLAY and $VERIFIER exists; the layout is half-applied."
exit 1
fi

- name: Setup Node
uses: actions/setup-node@v7
with:
Expand Down Expand Up @@ -274,8 +299,13 @@ jobs:
echo "Skipping full health check (cross-architecture build)"
fi

# Pre-sign sidecar with JIT entitlements (required for pkg/Node.js binaries)
# This must happen BEFORE Tauri signs the app
# Sign the sidecar with ITS OWN entitlements (JIT exceptions for the
# pkg/Node.js binary, nothing else) and stage it where
# src-tauri/tauri.macos-release.conf.json places it in the bundle. The
# bundler copies files listed there without re-signing them, so this is
# the signature that ships; the app's microphone/camera/Apple Events
# entitlements stay on the app. Verified after the build by
# scripts/verify-macos-bundle-entitlements.sh.
- name: Sign sidecar with JIT entitlements (macOS)
if: matrix.platform == 'macos-latest'
env:
Expand Down Expand Up @@ -306,6 +336,12 @@ jobs:
echo "Checking entitlements..."
codesign -d --entitlements - "$SIDECAR_PATH"

# cp keeps the embedded signature; the overlay maps this fixed path
# to Contents/MacOS/vmark-mcp-server for either architecture.
mkdir -p src-tauri/binaries/macos-release
cp "$SIDECAR_PATH" src-tauri/binaries/macos-release/vmark-mcp-server
codesign --verify --strict src-tauri/binaries/macos-release/vmark-mcp-server

- name: Build MCP server sidecar (Windows)
if: matrix.platform == 'windows-latest'
working-directory: server/mcp
Expand Down Expand Up @@ -370,12 +406,28 @@ jobs:
CI: true
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.args }}
# The layout config goes BEFORE the matrix's `--target`: the DMG step
# reads the target as ${MATRIX_ARGS##*--target }. The separating space
# lives here, not in the output value. Config is empty off macOS.
args: ${{ steps.layout.outputs.config }} ${{ matrix.args }}
# Disable per-job latest.json upload to avoid race condition
# We generate and upload it once in publish-release job
# (renamed from includeUpdaterJson in tauri-action v1.0.0)
uploadUpdaterJson: false

# The gate on what was signed, not on what the plists meant: the app must
# declare microphone, camera and Apple Events for its terminal (#1483) and
# the sidecar must carry none of them. Fails the job, so publish-release
# (which needs build-tauri) never promotes the draft.
- name: Verify bundle entitlements (macOS)
if: matrix.platform == 'macos-latest' && steps.layout.outputs.verify == 'true'
env:
MATRIX_ARGS: ${{ matrix.args }}
run: |
set -euo pipefail
TARGET="${MATRIX_ARGS##*--target }"
scripts/verify-macos-bundle-entitlements.sh "src-tauri/target/$TARGET/release/bundle/macos/VMark.app"

# tauri-action signs, notarizes and staples the .app, and signs the .dmg —
# but it never notarizes the disk image itself. A signed-but-unnotarized
# dmg is refused by `spctl -t open` with "Unnotarized Developer ID", so
Expand Down
191 changes: 191 additions & 0 deletions scripts/check-macos-tcc-entitlements.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* The macOS app bundle must declare the privacy-protected resources that
* programs run in its integrated terminal ask for, and the MCP sidecar must not.
*
* VMark ships with the Hardened Runtime, and macOS treats the app as the
* "responsible process" for everything its terminal spawns. A resource the app
* never declared is denied WITHOUT a prompt: FFmpeg recording from VMark's
* terminal wrote all-zero samples and TCC logged the denial against `app.vmark`
* (#1483). The same held for the camera and for Apple Events (`osascript`), so
* the app declares all three — the subset of what iTerm2 and Ghostty declare
* that terminal programs realistically use.
*
* Each entitlement needs its usage description in Info.plist as well: with the
* entitlement but no description, macOS terminates the process on first access
* instead of asking the user.
*
* The sidecar (`vmark-mcp-server`) opens no device and scripts no app. The
* Tauri bundler re-signs every `externalBin` with the app's entitlements
* (crates/tauri-bundler `macos/app.rs`, `sign.rs`), but copies
* `bundle.macOS.files` without signing and signs the app without `--deep`. So
* the macOS release passes src-tauri/tauri.macos-release.conf.json, which
* places the sidecar release.yml pre-signed with sidecar-entitlements.plist.
* This file pins that wiring; scripts/verify-macos-bundle-entitlements.sh checks
* the signed artifact itself, in release.yml before publishing and in
* release-smoke.yml after.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse as parseYaml } from "yaml";

const ROOT = join(import.meta.dirname, "..");
const read = (rel) => readFileSync(join(ROOT, rel), "utf8");

/** Keys whose value is `<true/>` in a flat plist dict. */
function trueKeys(xml) {
const plain = xml.replace(/<!--[\s\S]*?-->/g, "");
return [...plain.matchAll(/<key>([^<]+)<\/key>\s*<true\s*\/>/g)].map((m) => m[1]);
}

/** Keys whose value is a non-empty `<string>` in a flat plist dict. */
function stringKeys(xml) {
const plain = xml.replace(/<!--[\s\S]*?-->/g, "");
return [...plain.matchAll(/<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g)]
.filter((m) => m[2].trim() !== "")
.map((m) => m[1]);
}

/** Resource entitlement → the Info.plist usage description macOS requires with it. */
const TCC_RESOURCES = {
"com.apple.security.device.audio-input": "NSMicrophoneUsageDescription",
"com.apple.security.device.camera": "NSCameraUsageDescription",
"com.apple.security.automation.apple-events": "NSAppleEventsUsageDescription",
};

/** Code-signing exceptions V8 needs, on both the app and the sidecar. */
const RUNTIME_EXCEPTIONS = [
"com.apple.security.cs.allow-jit",
"com.apple.security.cs.allow-unsigned-executable-memory",
"com.apple.security.cs.disable-library-validation",
];

const PRIVACY_PREFIXES = [
"com.apple.security.device.",
"com.apple.security.automation.",
"com.apple.security.personal-information.",
];
const isPrivacy = (k) => PRIVACY_PREFIXES.some((p) => k.startsWith(p));

const OVERLAY = "src-tauri/tauri.macos-release.conf.json";
const STAGED = "binaries/macos-release/vmark-mcp-server";

const tauriConf = JSON.parse(read("src-tauri/tauri.conf.json"));
const appEntitlementsPath = tauriConf.bundle?.macOS?.entitlements;

/** The build-tauri job's steps, parsed — so a commented-out step does not count. */
const buildSteps = parseYaml(read(".github/workflows/release.yml")).jobs["build-tauri"].steps;
const stepIndex = (name) => {
const i = buildSteps.findIndex((s) => s.name === name);
expect(i, `release.yml build-tauri has no step named "${name}"`).toBeGreaterThanOrEqual(0);
return i;
};
const step = (name) => buildSteps[stepIndex(name)];

describe("macOS TCC declarations for the integrated terminal", () => {
it("signs the app bundle with its own entitlements file, not the sidecar's", () => {
expect(appEntitlementsPath).toBe("app-entitlements.plist");
});

it("declares every terminal-facing resource on the app, with its usage description", () => {
const app = trueKeys(read(join("src-tauri", appEntitlementsPath)));
const described = stringKeys(read("src-tauri/Info.plist"));
for (const [entitlement, description] of Object.entries(TCC_RESOURCES)) {
expect(app, `app entitlements lack ${entitlement}`).toContain(entitlement);
expect(described, `${entitlement} needs a non-empty ${description} in Info.plist`).toContain(description);
}
});

it("gives the app exactly these privacy entitlements, plus the runtime exceptions", () => {
const app = trueKeys(read(join("src-tauri", appEntitlementsPath)));
expect(app.filter(isPrivacy).sort()).toEqual(Object.keys(TCC_RESOURCES).sort());
for (const key of RUNTIME_EXCEPTIONS) expect(app).toContain(key);
});

it("keeps the sidecar's own entitlements to the runtime exceptions", () => {
const sidecar = trueKeys(read("src-tauri/sidecar-entitlements.plist"));
expect(sidecar.filter(isPrivacy)).toEqual([]);
for (const key of RUNTIME_EXCEPTIONS) expect(sidecar).toContain(key);
});
});

describe("the macOS release ships the sidecar with its own signature", () => {
it("keeps externalBin for dev, Windows and Linux", () => {
expect(tauriConf.bundle.externalBin).toContain("binaries/vmark-mcp-server");
});

it("the release overlay drops externalBin and places the pre-signed sidecar via files", () => {
const overlay = JSON.parse(read(OVERLAY));
expect(overlay.bundle.externalBin).toEqual([]);
expect(overlay.bundle.macOS.files).toEqual({ "MacOS/vmark-mcp-server": STAGED });
});

it("resolves the layout from the checked-out tree, on macOS, right after checkout", () => {
const layout = step("Resolve macOS bundle layout");
expect(layout.id).toBe("layout");
expect(layout.if).toBe("matrix.platform == 'macos-latest'");
expect(stepIndex("Resolve macOS bundle layout")).toBe(stepIndex("Checkout") + 1);
expect(layout.run).toContain(`OVERLAY=${OVERLAY}`);
expect(layout.run).toContain("VERIFIER=scripts/verify-macos-bundle-entitlements.sh");
expect(layout.run).toContain('echo "config=--config $OVERLAY" >> "$GITHUB_OUTPUT"');
});

it("passes the layout config to the build before the matrix's --target", () => {
expect(step("Build Tauri app").with.args).toBe("${{ steps.layout.outputs.config }} ${{ matrix.args }}");
});

it("signs the sidecar with sidecar-entitlements.plist and stages it where the overlay reads it", () => {
const sign = step("Sign sidecar with JIT entitlements (macOS)");
expect(sign.run).toContain("--entitlements src-tauri/sidecar-entitlements.plist");
expect(sign.run).toContain(`cp "$SIDECAR_PATH" src-tauri/${STAGED}`);
expect(stepIndex("Sign sidecar with JIT entitlements (macOS)")).toBeLessThan(stepIndex("Build Tauri app"));
});

it("verifies the signed bundle after the build and before the DMG is notarized", () => {
const verify = step("Verify bundle entitlements (macOS)");
expect(verify.if).toBe("matrix.platform == 'macos-latest' && steps.layout.outputs.verify == 'true'");
const commands = verify.run.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
expect(commands).toContain('scripts/verify-macos-bundle-entitlements.sh "src-tauri/target/$TARGET/release/bundle/macos/VMark.app"');
const at = stepIndex("Verify bundle entitlements (macOS)");
expect(at).toBeGreaterThan(stepIndex("Build Tauri app"));
expect(at).toBeLessThan(stepIndex("Notarize and staple DMG (macOS)"));
});
});

/** App locale (src/locales) → the macOS .lproj that localizes Info.plist for it. */
const LPROJ = { en: "en", de: "de", es: "es", fr: "fr", it: "it", ja: "ja", ko: "ko", "pt-BR": "pt-BR", "zh-CN": "zh-Hans", "zh-TW": "zh-Hant" };

/** `"key" = "value";` pairs of an .strings file. */
function stringsPairs(text) {
return Object.fromEntries([...text.matchAll(/^"([^"]+)"\s*=\s*"((?:[^"\\]|\\.)*)";\s*$/gm)].map((m) => [m[1], m[2]]));
}

describe("the permission prompts are localized like the app", () => {
const appLocales = readdirSync(join(ROOT, "src/locales"), { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith("__"))
.map((d) => d.name)
.sort();
const files = tauriConf.bundle.macOS.files;

it("maps every app locale to an lproj", () => {
expect(appLocales).toEqual(Object.keys(LPROJ).sort());
});

it.each(Object.entries(LPROJ))("%s: bundles InfoPlist.strings with all three descriptions", (_locale, lproj) => {
const source = files[`Resources/${lproj}.lproj/InfoPlist.strings`];
expect(source, `bundle.macOS.files lacks Resources/${lproj}.lproj/InfoPlist.strings`).toBeTruthy();
const pairs = stringsPairs(read(join("src-tauri", source)));
for (const description of Object.values(TCC_RESOURCES)) {
expect(pairs[description]?.trim(), `${lproj}: ${description}`).toBeTruthy();
}
});

it("the English strings match Info.plist word for word", () => {
const plist = read("src-tauri/Info.plist").replace(/<!--[\s\S]*?-->/g, "");
const en = stringsPairs(read(join("src-tauri", files["Resources/en.lproj/InfoPlist.strings"])));
for (const description of Object.values(TCC_RESOURCES)) {
const m = plist.match(new RegExp(`<key>${description}</key>\\s*<string>([^<]*)</string>`));
expect(en[description]).toBe(m[1].replace(/&apos;/g, "'"));
}
});
});
Loading
Loading