Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,44 @@ 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
KEYFILE="$RUNNER_TEMP/maestro-publisher.pem"
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
rm -f "$KEYFILE"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# 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.
ANCHOR=$(node -e "console.log(require('./dist/shared/plugins/publisher-keys.js').MAESTRO_PUBLISHER_KEYS.join(','))")
if [ -z "$ANCHOR" ]; then
echo "::error::MAESTRO_PUBLISHER_KEYS is empty in publisher-keys.ts; bake the publisher public key before shipping a signed plugin." >&2
exit 1
fi
Comment thread
chr1syy marked this conversation as resolved.
Outdated
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 skips it - the panel renders but `main.js` never runs. 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
42 changes: 42 additions & 0 deletions src/__tests__/shared/plugins/rpc-and-signing.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { createPublicKey } from 'crypto';
import {
HOST_METHOD_CAPABILITY,
extractTarget,
Expand All @@ -10,6 +11,7 @@ import {
normalizeRelPath,
isTrustedKey,
} from '../../../shared/plugins/signing';
import { MAESTRO_PUBLISHER_KEYS, resolveTrustedKeys } from '../../../shared/plugins/publisher-keys';

describe('rpc-protocol', () => {
it('maps every host method to a capability', () => {
Expand Down Expand Up @@ -108,3 +110,43 @@ describe('isTrustedKey', () => {
expect(isTrustedKey('xyz', ['abc'])).toBe(false);
});
});

describe('resolveTrustedKeys', () => {
it('merges the baked anchor with user keys, trimmed and de-duplicated', () => {
const resolved = resolveTrustedKeys([' user-a ', 'user-b', 'user-a', ' ']);
// Every non-blank user key survives exactly once, trimmed.
expect(resolved).toContain('user-a');
expect(resolved).toContain('user-b');
expect(resolved.filter((k) => k === 'user-a')).toHaveLength(1);
expect(resolved).not.toContain('');
expect(resolved).not.toContain(' ');
// The baked anchor is always included ahead of user keys.
for (const anchor of MAESTRO_PUBLISHER_KEYS) {
expect(resolved).toContain(anchor.trim());
}
});

it('returns the baked publisher anchor for an empty user list', () => {
const anchor = resolveTrustedKeys([]);
// resolveTrustedKeys([]) is exactly the baked anchor set (trimmed/de-duped).
expect(anchor).toEqual([...MAESTRO_PUBLISHER_KEYS].map((k) => k.trim()).filter(Boolean));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

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);
expect(() =>
createPublicKey({
key: Buffer.from(key, 'base64'),
format: 'der',
type: 'spki',
})
).not.toThrow();
}
});
});