Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
55 changes: 55 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,61 @@ jobs:
env:
VITE_APP_VERSION: ${{ steps.version.outputs.VERSION }}

# Sign every bundled plugin with the publisher key so the baked
# MAESTRO_PUBLISHER_KEYS anchor resolves them "trusted" at seed time and
# their main.js is allowed to run. Must land after "Build application"
# (which produces dist/cli/maestro-cli.js) and before any electron-builder
# packaging step, since extraResources copies examples/plugins/* - including
# the freshly written signature.json - into the packaged resources.
# Runs on every matrix leg; signing is deterministic given the same files
# and key, so per-leg signing is safe. Looping over examples/plugins/*/
# signs future bundled plugins for free (only agent-flow is shipped via
# extraResources, so signing the sibling example dirs is harmless).
- name: Sign bundled plugins
env:
MAESTRO_PLUGIN_SIGNING_KEY: ${{ secrets.MAESTRO_PLUGIN_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
# Create any temp files (the private key below) with restrictive
# permissions so the key is never group/world-readable.
umask 077

# Read the baked publisher anchor first: signing and the drift guard
# only make sense once a public key exists to validate against.
ANCHOR=$(node -e "console.log(require('./dist/shared/plugins/publisher-keys.js').MAESTRO_PUBLISHER_KEYS.join(','))")

# Pre-key-mint state: no anchor baked (or no signing secret configured).
# Skip signing and the drift check and ship the bundled plugins
# unsigned rather than breaking every release target. Unsigned bundled
# plugins simply are not seeded at runtime (seedBundledPlugins() only
# installs a "trusted" plugin), so this degrades gracefully and stays
# fail-closed at runtime. Once a key is baked this branch is skipped and
# the drift guard below enforces trust.
if [ -z "$ANCHOR" ] || [ -z "${MAESTRO_PLUGIN_SIGNING_KEY:-}" ]; then
echo "::warning::No publisher key baked yet (MAESTRO_PUBLISHER_KEYS empty) or signing secret absent; shipping unsigned bundled plugins. They will not be seeded at runtime until a key is baked."
exit 0
fi
Comment thread
chr1syy marked this conversation as resolved.
Outdated
Comment thread
chr1syy marked this conversation as resolved.
Outdated

KEYFILE="$RUNNER_TEMP/maestro-publisher.pem"
# Remove the private key on any exit, including a signing failure that
# trips set -e before we reach the end of the step.
trap 'rm -f "$KEYFILE"' EXIT
printf '%s' "$MAESTRO_PLUGIN_SIGNING_KEY" > "$KEYFILE"
Comment thread
chr1syy marked this conversation as resolved.
for dir in examples/plugins/*/; do
node dist/cli/maestro-cli.js plugin sign "$dir" --key "$KEYFILE" --json
done

# Anchor/secret drift guard: the freshly written signature must resolve
# "trusted" against the baked MAESTRO_PUBLISHER_KEYS anchor. If the CI
# secret was rotated without re-baking publisher-keys.ts (or vice
# versa), the produced signature would be seed-skipped at runtime and
# ship a permanently non-runnable plugin. Fail the release here instead.
if ! node dist/cli/maestro-cli.js plugin validate examples/plugins/agent-flow --trusted-key "$ANCHOR" --json | grep -q '"status":"trusted"'; then
echo "::error::agent-flow signature does not resolve 'trusted' against the baked MAESTRO_PUBLISHER_KEYS anchor. The CI signing key (MAESTRO_PLUGIN_SIGNING_KEY) and the baked public key have drifted." >&2
exit 1
fi

# List release directory before packaging for debugging
- name: Create release directory
run: mkdir -p release
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ yarn-error.log*
# Claude Code local settings
.claude/settings.local.json
.cue-migration-backup-*/

# Bundled-plugin signing artifact (release-time build output; dev signs locally)
examples/plugins/*/signature.json
12 changes: 12 additions & 0 deletions CLAUDE-PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ Channels (all gated on `encoreFeatures.plugins`):

Integrity ("files match what was signed") and trust ("key is recognized") are layered; a plugin can be integral-but-untrusted and still run once the user has enabled = consented.

### Bundled-plugin signing (release vs. dev)

Bundled plugins (e.g. `agent-flow`) ship trusted via a build-time signature, not a committed one. The private key is a CI secret (`MAESTRO_PLUGIN_SIGNING_KEY`); release CI writes it to a temp file, runs `maestro-cli plugin sign` over every dir in `examples/plugins/*/`, and packages the resulting `signature.json` into `<resources>/plugins/` via `extraResources`. The matching base64 SPKI public key is baked into `MAESTRO_PUBLISHER_KEYS` in `src/shared/plugins/publisher-keys.ts`, so those signatures resolve `trusted` and `seedBundledPlugins()` keeps the seeded copy. A drift guard in `.github/workflows/release.yml` re-validates the signed plugin against the baked anchor and fails the release if the CI secret and `publisher-keys.ts` ever diverge. The `signature.json` is a release artifact only - it is gitignored (`examples/plugins/*/signature.json`) and must never be committed.

**Testing Agent Flow (or any bundled code plugin) locally.** Dev builds have no CI secret and fall back to the unsigned repo `examples/plugins`, so `seedBundledPlugins()` finds the plugin `unsigned` and `continue`s before copying it into `pluginsDir()`. The plugin is never installed, so there is no panel and no `main.js` to run. (The distinct "panel renders but `main.js` never runs" symptom belongs to a plugin manually installed unsigned into `pluginsDir()` and then enabled - a different path from bundled seeding.) To exercise the real (signed, trusted) path in a dev build:

1. Generate a throwaway keypair and sign the dir: `maestro-cli plugin sign examples/plugins/agent-flow --gen-key --key-out ~/maestro-dev-publisher.pem`. This writes `signature.json` (gitignored) and prints the base64 SPKI public key.
2. Add that public key to `pluginTrustedKeys` in Settings. `resolveTrustedKeys()` merges your key with the baked anchor, so the local signature resolves `trusted` and the seeder keeps it.
3. Enable the plugin and grant consent; the panel now populates with live events.

**Trust model (v1): single key, no revocation.** `MAESTRO_PUBLISHER_KEYS` is an allow-list of accepted keys with no CRL. Rotation is additive-then-prune: add the new public key alongside the old, resign, ship a release, then drop the old entry in a later release once no shipped build still relies on it. A leaked private key can only be revoked by shipping a new release that rotates the anchor - there is no runtime revocation.

## Host-API semver contract

`HOST_API_VERSION` is a permanent public contract once plugins ship. PATCH = host bug fix; MINOR = additive (new contribution point / manifest field / capability, older plugins keep working); MAJOR = remove or change the meaning of an existing one. A plugin pins `maestro.minHostApi`; the host loads it only when same-major and `host >= min`.
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/shared/plugins/publisher-keys.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { describe, it, expect } from 'vitest';
import { createPublicKey } from 'crypto';
import { MAESTRO_PUBLISHER_KEYS, resolveTrustedKeys } from '../../../shared/plugins/publisher-keys';

describe('MAESTRO_PUBLISHER_KEYS', () => {
it('bakes only well-formed base64 SPKI public keys as the trust anchor', () => {
// Guarded so this asserts nothing until a real publisher key is baked
// (MAESTRO_PUBLISHER_KEYS is empty pre-key-mint). Once populated it proves
// every anchor entry is a non-empty, valid SPKI key crypto can load - never
// hard-coding the real key value here.
for (const key of MAESTRO_PUBLISHER_KEYS) {
expect(typeof key).toBe('string');
expect(key.trim().length).toBeGreaterThan(0);
// Node decodes base64 leniently (padding/whitespace/url-safe), so a
// baked string that is not canonical could still load. Round-trip the
// DER bytes back to base64 and require an exact match to reject any
// non-canonical anchor.
const der = Buffer.from(key, 'base64');
expect(der.toString('base64')).toBe(key);
const publicKey = createPublicKey({
key: der,
format: 'der',
type: 'spki',
});
// The plugin signing scheme is ed25519 (SIGNATURE_ALGORITHM), so the
// anchor must be an ed25519 public key, not merely any valid SPKI key.
expect(publicKey.asymmetricKeyType).toBe('ed25519');
}
});
});

describe('resolveTrustedKeys', () => {
it('unions the built-in publisher anchor with user keys, trimmed and de-duplicated', () => {
expect(resolveTrustedKeys(['userA', ' userB ', 'userA', ''])).toEqual([
Expand Down