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
67 changes: 67 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,73 @@ 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(','))")

# No anchor baked: a fork, or this repo before the key was minted.
# There is nothing to validate a signature against, so skip signing and
# ship the bundled plugins unsigned rather than breaking every release
# target. Unsigned bundled plugins are simply not seeded at runtime
# (seedBundledPlugins() only installs a "trusted" plugin), so this
# degrades gracefully and stays fail-closed where it matters.
if [ -z "$ANCHOR" ]; then
echo "::warning::No publisher key baked (MAESTRO_PUBLISHER_KEYS empty); shipping unsigned bundled plugins. They will not be seeded at runtime until a key is baked."
exit 0
fi

# Anchor baked but no signing secret: that is a MISCONFIGURED release,
# not a degraded one, so fail instead of quietly shipping unsigned
# plugins. This branch is reachable only by removing or renaming
# MAESTRO_PLUGIN_SIGNING_KEY after a key was baked - at which point the
# anchor promises a trust guarantee the artifact cannot honour, and
# Agent Flow would silently stop being seeded in a shipped build.
# Grouped with the drift guard below: both exist so a key/secret
# mismatch fails at release time rather than in users' hands.
if [ -z "${MAESTRO_PLUGIN_SIGNING_KEY:-}" ]; then
echo "::error::MAESTRO_PUBLISHER_KEYS is baked but the MAESTRO_PLUGIN_SIGNING_KEY secret is absent, so bundled plugins cannot be signed and would ship unseedable. Restore the secret (it must be the private counterpart of the baked anchor) or empty the anchor deliberately." >&2
exit 1
fi

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
29 changes: 29 additions & 0 deletions src/__tests__/shared/plugins/publisher-keys.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
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', () => {
// The anchor must stay populated: an accidental future emptying would
// silently revert to pre-A1 behavior (no bundled plugin ever seeds).
expect(MAESTRO_PUBLISHER_KEYS.length).toBeGreaterThan(0);
// 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
16 changes: 13 additions & 3 deletions src/shared/plugins/publisher-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,24 @@
* SHIPPING CONTRACT:
* - The matching PRIVATE key is a maintainer/CI secret and is NEVER committed.
* Release tooling signs the bundled plugin(s) with it at build time.
* - Until a real publisher key is added here this list is EMPTY. The seeder is
* - This list was EMPTY until the publisher key below was minted. The seeder is
* trust-gated (it only installs a bundled plugin that verifies `trusted`), so
* an empty anchor means bundled plugins are simply not auto-installed - never
* an empty anchor meant bundled plugins were simply not auto-installed - never
* an orphaned, auto-installed-but-untrusted plugin the user did not choose.
* - Base64 SPKI DER, one entry per publisher key, matching the `publicKey`
* field a `signature.json` carries (see `signing.ts`).
*/
export const MAESTRO_PUBLISHER_KEYS: readonly string[] = [];
export const MAESTRO_PUBLISHER_KEYS: readonly string[] = [
// Maestro release-signing key (ed25519), minted 2026-07-23. The private half
// lives ONLY in the MAESTRO_PLUGIN_SIGNING_KEY Actions secret (added
// 2026-07-25); it is intentionally not held by any individual, so a bundled
// plugin can only be signed by a maintainer-tagged release run. This value
// must stay the public counterpart of that secret - the release drift guard
// fails the build if they diverge. Rotation: see CLAUDE-PLUGINS.md
// "Trust model (v1)", and note it is a TWO-part change (new secret AND a
// re-baked key here, in the same release).
'MCowBQYDK2VwAyEAgG9ilXDpkj83vdxhlOI64cehRMB2EpbW2CNQO3izPu0=',
];

/**
* Union of the built-in publisher anchor and the user's configured trusted keys,
Expand Down