diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 96a32c6..9de8292 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,13 +1,48 @@ { "name": "hermes-helmet", "displayName": "Hermes Helmet", - "version": "0.7.0", - "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", + "version": "0.8.0", + "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination, plus the Captain's Bridge mod, which opens a pane bound to the current conversation with /captains-bridge.", "author": { "name": "Machine Wisdom", "url": "https://machine-wisdom.ai" }, "homepage": "https://github.com/MachineWisdomAI/hermes-helmet", "repository": "https://github.com/MachineWisdomAI/hermes-helmet", - "license": "Apache-2.0" + "license": "Apache-2.0", + "types": "./types/index.d.ts", + "userConfig": { + "helmetCommand": { + "type": "string", + "title": "helmet command", + "description": "Executable that reads session records. An absolute path is accepted, for sessions that do not inherit the shell PATH.", + "default": "helmet" + }, + "explanationModel": { + "type": "string", + "title": "Explanation model", + "description": "Model alias or ID used when the explanation is updated.", + "default": "sonnet" + }, + "explanationTimeoutSeconds": { + "type": "number", + "title": "Explanation time limit (seconds)", + "description": "Time limit for one explanation update.", + "default": 180, + "min": 10, + "max": 900 + }, + "showMeCommand": { + "type": "string", + "title": "Show Me command", + "description": "Name of the slash command for Show Me.", + "default": "show-me" + }, + "retroCommand": { + "type": "string", + "title": "Retro command", + "description": "Name of the slash command for Retro.", + "default": "retro" + } + } } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ca71563..41703d4 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hermes-helmet", - "version": "0.7.0", + "version": "0.8.0", "description": "First-officer skills for Hermes Helmet: setup, single-issue delivery, and dependent-issue coordination.", "author": { "name": "Machine Wisdom", diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ccd9297..ac79578 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -32,6 +32,12 @@ jobs: echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" - name: Verify Jujutsu version run: PYTHONPATH=src python -m hermes_helmet.jj_toolchain --check-binary "$(command -v jj)" + - name: Install checked Claude Code CLI + run: | + PYTHONPATH=src python -m hermes_helmet.claude_mod_toolchain --bin-dir "$RUNNER_TEMP/claude-bin" + echo "$RUNNER_TEMP/claude-bin" >> "$GITHUB_PATH" + - name: Verify Claude Code CLI version + run: PYTHONPATH=src python -m hermes_helmet.claude_mod_toolchain --check-binary "$(command -v claude)" - name: Run product verification run: scripts/verify.sh - name: Run minimum quickstart and setup profiles diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0084c..57a953a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,25 @@ All notable changes to Hermes Helmet are recorded here. The project follows ## Unreleased -### 0.6.0 - -- Captain’s Bridge can prepare an updated walkthrough in the background. The - panel captures the originating chat and a verifiable snapshot (delivery is - rejected if captured records changed or digests are inconsistent), asks the first - officer to dispatch one bounded read-only agent without waiting, and keeps the - current walkthrough visible. A timeout asks the first officer to stop the agent - and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate - submission and late completion preserve the last useful view. Verified with - synthetic protocol and panel tests only; acceptance in an installed Codex host - is recorded separately in the pull request. Measured Codex limit: after the - parent turn finishes, the originating panel does not render a delivery, so a - request on the installed host now ends with an unsupported-boundary message - (no capture, dispatch or timer) unless the host advertises background delivery. +### 0.8.0 + +- Add the Captain’s Bridge mod skeleton to the Claude plugin + ([#68](https://github.com/MachineWisdomAI/hermes-helmet/issues/68), part of + [#55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55)). A hooks + module registers `/captains-bridge` (immediate) and opens a pane bound to the + exact `$.session.id()` without starting a turn or messaging the first + officer; non-drawing surfaces get a text status. `clear`, `resume`, `fork` + and session end end the binding and show “This conversation changed. Run + /captains-bridge to open the Bridge for it.” Claude Code older than 2.1.293 + is told which version is needed. The manifest names a `PluginState`/ + `BridgeState` type contract and `userConfig` (`helmetCommand`, + `explanationModel`, `explanationTimeoutSeconds`, `showMeCommand`, + `retroCommand`). CI installs an exact, checksum-verified Claude Code 2.1.296, + runs `claude plugin validate` and `claude plugin test` without credentials, + and fails on any call outside the read-only allowlist. MCP packaging and the skills + are unchanged. The version moves to 0.8.0 in `pyproject.toml`, the Python + package and both plugin manifests (the Codex manifest changes only its + version field), synchronized by `scripts/mw-version.py`. ### 0.7.0 @@ -43,6 +48,21 @@ Claude Code record reader. into several output records, the first keeps the UUID and later blocks get a deterministic 32-hex identity from the UUID and block index. +### 0.6.0 + +- Captain’s Bridge can prepare an updated walkthrough in the background. The + panel captures the originating chat and a verifiable snapshot (delivery is + rejected if captured records changed or digests are inconsistent), asks the first + officer to dispatch one bounded read-only agent without waiting, and keeps the + current walkthrough visible. A timeout asks the first officer to stop the agent + and reports the stop as requested, not confirmed. Cancel, supersession, timeout, failure, duplicate + submission and late completion preserve the last useful view. Verified with + synthetic protocol and panel tests only; acceptance in an installed Codex host + is recorded separately in the pull request. Measured Codex limit: after the + parent turn finishes, the originating panel does not render a delivery, so a + request on the installed host now ends with an unsupported-boundary message + (no capture, dispatch or timer) unless the host advertises background delivery. + ### 0.5.0 - Captain’s Bridge Refresh records now rereads the exact bound chat directly diff --git a/docs/first-officer-plugins.md b/docs/first-officer-plugins.md index 1433c95..4580005 100644 --- a/docs/first-officer-plugins.md +++ b/docs/first-officer-plugins.md @@ -34,8 +34,9 @@ first officer to start one read-only background preparation, cancellable from th panel; see the lifecycle in the Bridge guide. See the [source and test guide](../mcp/captains-bridge/README.md). The Claude Code port is tracked in -[issue #55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55); the -Claude plugin does not load the Bridge. +[issue #55](https://github.com/MachineWisdomAI/hermes-helmet/issues/55); see +[Captain's Bridge in Claude Code](#captains-bridge-in-claude-code) for what the +Claude plugin ships so far. ### Claude Code session records @@ -66,6 +67,59 @@ reload plugins in an existing session, invoke them as `/hermes-helmet:setup-helmet`, `/hermes-helmet:helmet-issue`, and `/hermes-helmet:helmet-epic`. +### Captain's Bridge in Claude Code + +The Claude plugin now includes the Captain's Bridge mod. A mod runs code inside +your Claude Code sessions, unlike the Codex plugin, which still copies +instructions (and its own MCP server) only. The skills above are unchanged and +keep working if your organization policy disables mods; only the Bridge is +skipped. + +This release is the mod skeleton. Run `/captains-bridge` in any session to open +the Captain's Bridge pane beside the conversation. It is bound to that exact +session ID, starts no turn and messages no one, so it can be opened while the +first officer is working and at narrow terminal widths. It never opens by +itself and runs no timers. In VS Code and headless or SDK sessions, where no +pane draws, the command returns a short text status instead. The walkthrough, +refresh, background explanation, Show Me and Retro arrive in later releases +under the same state contract. + +If the conversation changes (`/clear`, `/resume`, a fork, or the session +ends), the Bridge ends its binding and shows exactly: "This conversation +changed. Run /captains-bridge to open the Bridge for it." It never rebinds the +open pane on its own; run `/captains-bridge` again. + +Requirements: + +- Claude Code 2.1.293 or newer. An older host names the required version in + the command and the pane. Mods are early access and their API can change + between Claude Code releases; this release is verified on Claude Code + 2.1.296 by an exact, checksum-pinned CLI in CI (`claude plugin validate` and + `claude plugin test`, with no account credentials). Re-verify when the + minimum changes. CI is not an installed-host check: the Terminal and Desktop + Code tab are confirmed by hand before a release. +- An organization policy that allows mods. +- The `helmet` CLI reachable from Claude Code. Sessions started by Claude + Desktop may not inherit your shell `PATH`; set `helmetCommand` to an absolute + path then. + +Settings (plugin `userConfig`, defaults in parentheses): `helmetCommand` +(`helmet`), `explanationModel` (`sonnet`), `explanationTimeoutSeconds` (`180`), +`showMeCommand` (`show-me`), `retroCommand` (`retro`). The last four take +effect with later releases. + +Update with `claude plugin update hermes-helmet@hermes-helmet`, then +`/reload-plugins` or start a new session. Third-party marketplaces do not +auto-update, so each release carries a version bump. + +The mod's calls are limited in CI to a read-only allowlist: `$.session.id`, +`$.session.version`, `$.process.run`, `$.model.complete`, `$.command.register`, +`$.command.list`, `$.command.run`, `$.ui.open`, `$.ui.resolve`, `$.ui.status`, +`$.ui.toast`, `$.state.get`, `$.state.set` and `$.clock.after`. It cannot write +files, spawn agents, submit prompts, send messages or reach the network. This +slice uses only `$.command.register`, `$.session.id`, `$.session.version`, +`$.state.get`, `$.state.set`, `$.ui.open` and `$.ui.resolve`. + ### Codex ```sh diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..4f4495c --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,3 @@ +{ + "modules": ["./register.tsx"] +} diff --git a/hooks/register.test.tsx b/hooks/register.test.tsx new file mode 100644 index 0000000..54a0acc --- /dev/null +++ b/hooks/register.test.tsx @@ -0,0 +1,339 @@ +// Tests for the Captain's Bridge mod skeleton. They drive only the public +// seams of claude-code/testing: classic events, the command, a drawn pane on +// terminal and desktop, and the non-drawing text fallback. Every fixture is +// synthetic. +import { describe, expect, test } from 'claude-code/testing' +import { overviewText } from './register' + +const CHANGED = + 'This conversation changed. Run /captains-bridge to open the Bridge for it.' +const SURFACES = ['terminal', 'desktop'] as const + +// A stand-in plugin that reads the mod's state back as a person-visible +// string, so tests assert on observable state without the mod's internals. +const PROBE = { + name: 'probe', + register: (on: any) => { + on('command.run', { command: 'probe' }, async ($: any) => { + const binding = await $.state.get({ plugin: 'hermes-helmet', key: 'binding' }) + const request = await $.state.get({ plugin: 'hermes-helmet', key: 'request' }) + return { text: JSON.stringify({ binding: binding.value ?? null, request: request.value ?? null }) } + }) + }, +} +const WITH_PROBE = { plugins: [PROBE] } + +const PANE = { + plugin: 'hermes-helmet', + component: 'Pane', + requestId: 'captains-bridge', + props: { title: "Captain's Bridge", isFocused: true, bodyColumns: 60, placement: 'dock' }, +} as const + +// The bottom of the chain stands for the engine; these answers are synthetic. +function host( + on: any, + opts: { version?: string; id?: string; isPlaced?: boolean } = {}, +) { + const seen = { opened: [] as any[], registered: [] as any[], turns: 0, timers: 0, reached: [] as any[] } + on('session.version', () => ({ value: { version: opts.version ?? '2.1.296' } })) + on('session.id', () => ({ value: opts.id ?? 'sess-a' })) + on('session.start', () => ({ cwd: '/work' })) + on('session.end', (_$: any, e: any) => ({ sessionId: e.sessionId })) + on('classic.SessionStart', (_$: any, e: any) => { + seen.reached.push(e) + return {} + }) + on('command.register', (_$: any, e: any) => { + seen.registered.push(e) + return { value: { command: e.name } } + }) + on('ui.open', (_$: any, e: any) => { + seen.opened.push(e) + return { value: { isPlaced: opts.isPlaced ?? true } } + }) + on('prompt.submit', () => { + seen.turns += 1 + return {} + }) + on('session.send', () => { + seen.turns += 1 + return {} + }) + on('clock.after', () => { + seen.timers += 1 + return {} + }) + on('clock.every', () => { + seen.timers += 1 + return {} + }) + return seen +} + +const START = { cwd: '/work', surface: 'terminal', isInteractive: true } as const + +function run(command: string, args = '', columns = 120) { + return { + command, + args, + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns }, + } as any +} + +async function stateOf($: any, key: 'binding' | 'request'): Promise { + const out = await $.command.run(run('probe')) + return JSON.parse(out.text)[key] +} + +describe('session.start', () => { + test('registers /captains-bridge as an immediate command', WITH_PROBE, async ($, on) => { + const seen = host(on) + await $.session.start(START) + expect(seen.registered.length).toBe(1) + expect(seen.registered[0].name).toBe('captains-bridge') + expect(seen.registered[0].immediate).toBe(true) + expect(seen.opened.length).toBe(0) + }) + + test('retains the exact session id and never auto-opens', WITH_PROBE, async ($, on) => { + const seen = host(on, { id: 'sess-start' }) + await $.session.start(START) + expect(await stateOf($, 'binding')).toEqual({ sessionId: 'sess-start' }) + expect(seen.opened).toEqual([]) + }) +}) + +describe('classic.SessionStart', () => { + test('retains the session id and transcript path', WITH_PROBE, async ($, on) => { + host(on) + await $.classic.SessionStart({ + source: 'startup', + session_id: 'sess-a', + transcript_path: '/synthetic/transcripts/sess-a.jsonl', + }) + expect(await stateOf($, 'binding')).toEqual({ + sessionId: 'sess-a', + transcriptPath: '/synthetic/transcripts/sess-a.jsonl', + }) + }) + + test('a reload keeps the established binding for the same session', WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-a' }) + await $.classic.SessionStart({ + source: 'startup', + session_id: 'sess-a', + transcript_path: '/synthetic/transcripts/sess-a.jsonl', + }) + await $.session.start(START) + expect(await stateOf($, 'binding')).toEqual({ + sessionId: 'sess-a', + transcriptPath: '/synthetic/transcripts/sess-a.jsonl', + }) + }) + + test('a reload never rebinds to a different session silently', WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-b' }) + await $.classic.SessionStart({ + source: 'startup', + session_id: 'sess-a', + transcript_path: '/synthetic/transcripts/sess-a.jsonl', + }) + await $.session.start(START) + expect(await stateOf($, 'binding')).toBe(null) + }) + + for (const source of ['clear', 'resume', 'fork'] as const) { + test(`${source} ends the binding and does not rebind`, WITH_PROBE, async ($, on) => { + const seen = host(on) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.classic.SessionStart({ source, session_id: 'sess-new' }) + expect(await stateOf($, 'binding')).toBe(null) + expect((await stateOf($, 'request')).message).toBe(CHANGED) + expect(seen.opened).toEqual([]) + }) + } + + for (const source of ['clear', 'resume', 'fork'] as const) { + test(`${source} stays ended through a plugin reload`, WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-new' }) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.classic.SessionStart({ source, session_id: 'sess-new' }) + await $.session.start(START) + expect(await stateOf($, 'binding')).toBe(null) + expect((await stateOf($, 'request')).message).toBe(CHANGED) + await $.command.run(run('captains-bridge')) + expect(await stateOf($, 'binding')).toEqual({ sessionId: 'sess-new' }) + }) + } + + test('session.end stays ended through a plugin reload', WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-a' }) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.session.end({ reason: 'clear', sessionId: 'sess-a', resume: { id: 'sess-a' } }) + await $.session.start(START) + expect(await stateOf($, 'binding')).toBe(null) + expect((await stateOf($, 'request')).message).toBe(CHANGED) + }) + + test('passes the event on unchanged', async ($, on) => { + const seen = host(on) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + expect(seen.reached.length).toBe(1) + expect(seen.reached[0].source).toBe('startup') + expect(seen.reached[0].session_id).toBe('sess-a') + }) +}) + +describe('session.end', () => { + test('ends the binding and shows the changed state', WITH_PROBE, async ($, on) => { + host(on) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.session.end({ reason: 'clear', sessionId: 'sess-a', resume: { id: 'sess-a' } }) + expect(await stateOf($, 'binding')).toBe(null) + expect((await stateOf($, 'request')).message).toBe(CHANGED) + }) +}) + +describe('/captains-bridge', () => { + test('opens the pane bound to the session and starts no turn', WITH_PROBE, async ($, on) => { + const seen = host(on, { id: 'sess-cmd' }) + const out = await $.command.run(run('captains-bridge', '', 60)) + expect(seen.opened).toEqual([{ id: 'captains-bridge', title: "Captain's Bridge" }]) + expect(seen.turns).toBe(0) + expect(out.text).toContain("Captain's Bridge") + expect(await stateOf($, 'binding')).toEqual({ sessionId: 'sess-cmd' }) + }) + + test('opens at a narrow terminal width', async ($, on) => { + const seen = host(on) + await $.command.run(run('captains-bridge', '', 40)) + expect(seen.opened.length).toBe(1) + }) + + test('keeps the established binding and its transcript path', WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-a' }) + await $.classic.SessionStart({ + source: 'startup', + session_id: 'sess-a', + transcript_path: '/synthetic/transcripts/sess-a.jsonl', + }) + await $.command.run(run('captains-bridge')) + expect((await stateOf($, 'binding')).transcriptPath).toBe( + '/synthetic/transcripts/sess-a.jsonl', + ) + }) + + test('returns a text status where nothing draws', async ($, on) => { + host(on, { isPlaced: false }) + const out = await $.command.run(run('captains-bridge')) + expect(out.text).toContain('No walkthrough has been prepared') + }) + + test('a too-old host names the required version and does not bind', WITH_PROBE, async ($, on) => { + host(on, { version: '2.1.292' }) + const out = await $.command.run(run('captains-bridge')) + expect(out.text).toContain('2.1.293') + expect(out.text).toContain('2.1.292') + expect(await stateOf($, 'binding')).toBe(null) + }) + + test('after a conversation change, opening binds the new conversation', WITH_PROBE, async ($, on) => { + host(on, { id: 'sess-new' }) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-old' }) + await $.classic.SessionStart({ source: 'clear', session_id: 'sess-new' }) + expect(await stateOf($, 'binding')).toBe(null) + await $.command.run(run('captains-bridge')) + expect(await stateOf($, 'binding')).toEqual({ sessionId: 'sess-new' }) + expect((await stateOf($, 'request')).message).toBe(undefined) + }) +}) + +describe('the text overview', () => { + const body = { + objective: 'Ship the synthetic feature', + summary: 'One item merged; one open.', + evidence: ['aaaaaaaa'], + items: [ + { id: '1', title: 'Add widget', group: 'changed', status: 'Merged', summary: 's', detail: 'd', evidence: ['aaaaaaaa'] }, + { id: '2', title: 'Fix gadget', group: 'unresolved', status: 'Open', summary: 's', detail: 'd', evidence: ['aaaaaaaa'] }, + ], + } as const + + test('lists objective, summary, group labels and item statuses', () => { + const text = overviewText(body as any, null) + expect(text).toContain('Objective: Ship the synthetic feature') + expect(text).toContain('Summary: One item merged; one open.') + expect(text).toContain('What changed:') + expect(text).toContain('- Add widget (Merged)') + expect(text).toContain('What remains unresolved:') + expect(text).toContain('- Fix gadget (Open)') + expect(text).not.toContain('Other recorded activity') + }) + + test('is a short truthful status before a walkthrough exists', () => { + expect(overviewText(null, null)).toBe( + 'No walkthrough has been prepared for this conversation yet.', + ) + expect(overviewText(null, { recordsIncluded: 3, recordsTotal: 5 })).toContain( + '3 of 5 records are read', + ) + }) +}) + +describe('the drawn pane', () => { + for (const surface of SURFACES) { + test(`shows the bound conversation on ${surface}`, async ($, on) => { + host(on, { id: 'sess-draw' }) + await $.command.run(run('captains-bridge')) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ type: 'Text', text: "Captain's Bridge" })).not.toBe(undefined) + expect(await pane.find({ type: 'Text', text: 'sess-draw' })).not.toBe(undefined) + expect( + await pane.find({ type: 'Text', text: 'No walkthrough has been prepared' }), + ).not.toBe(undefined) + await pane.unmount() + }) + + test(`shows the changed-conversation state on ${surface}`, async ($, on) => { + host(on) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.classic.SessionStart({ source: 'resume', session_id: 'sess-b' }) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ type: 'Text', text: CHANGED })).not.toBe(undefined) + await pane.unmount() + }) + + test(`keeps the changed-conversation state on ${surface} after a reload`, async ($, on) => { + host(on, { id: 'sess-b' }) + await $.classic.SessionStart({ source: 'startup', session_id: 'sess-a' }) + await $.classic.SessionStart({ source: 'clear', session_id: 'sess-b' }) + await $.session.start(START) + const pane = await $.ui.mount({ ...PANE, surface }) + expect(await pane.find({ type: 'Text', text: CHANGED })).not.toBe(undefined) + await pane.unmount() + }) + + test(`names the required version on ${surface} when the host is too old`, async ($, on) => { + host(on, { version: '2.1.200' }) + const pane = await $.ui.mount({ + ...PANE, + surface, + props: { ...PANE.props, bodyColumns: 40, placement: 'inline' }, + }) + expect(await pane.find({ type: 'Text', text: '2.1.293' })).not.toBe(undefined) + await pane.unmount() + }) + } +}) + +describe('no hidden work', () => { + test('sends nothing and arms no timer on start or command', WITH_PROBE, async ($, on) => { + const seen = host(on) + await $.session.start(START) + await $.command.run(run('captains-bridge')) + expect(seen.turns).toBe(0) + expect(seen.timers).toBe(0) + }) +}) diff --git a/hooks/register.tsx b/hooks/register.tsx new file mode 100644 index 0000000..c9d9db7 --- /dev/null +++ b/hooks/register.tsx @@ -0,0 +1,213 @@ +// Captain's Bridge: the Claude Code mod of the Hermes Helmet plugin. +// +// This slice is the skeleton: the `/captains-bridge` command, the exact +// session binding, the changed-conversation state, the minimum-version gate +// and a truthful pane and text overview. It reads nothing, sends no message, +// starts no turn and runs no timer. Later slices add the reader, the +// explanation and the actions behind the same state contract (../types). +// +// The calls this module makes are checked in CI against the read-only +// allowlist from issue #55 (scripts/check_mod_calls.py). +import { atom, read, update } from 'claude-code' +import type { Register } from 'claude-code' +import type { Walkthrough } from '../types' + +export const PANE_ID = 'captains-bridge' +export const PANE_TITLE = "Captain's Bridge" +export const COMMAND = 'captains-bridge' +// The lowest Claude Code build this release is verified on (see +// docs/first-officer-plugins.md). It is never lower than 2.1.293. +export const MIN_VERSION = '2.1.293' +export const CHANGED_MESSAGE = + 'This conversation changed. Run /captains-bridge to open the Bridge for it.' + +const binding = atom({ plugin: 'hermes-helmet', key: 'binding' } as const, null) +const records = atom({ plugin: 'hermes-helmet', key: 'records' } as const, null) +const walkthrough = atom( + { plugin: 'hermes-helmet', key: 'walkthrough' } as const, + null, +) +const request = atom( + { plugin: 'hermes-helmet', key: 'request' } as const, + { generation: 0, status: 'idle', retain: false }, +) + +// Sources of a classic SessionStart that start a different conversation. +const CHANGING_SOURCES = ['clear', 'resume', 'fork'] + +export function parseVersion(text: string): number[] | null { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text.trim()) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +export function isSupported(version: string): boolean { + const have = parseVersion(version) + const need = parseVersion(MIN_VERSION) + if (have === null || need === null) return false + for (let i = 0; i < 3; i++) { + if (have[i] !== need[i]) return have[i] > need[i] + } + return true +} + +export function tooOldMessage(version: string): string { + return ( + `Captain's Bridge needs Claude Code ${MIN_VERSION} or newer; this is ` + + `${version || 'an unknown version'}. Update Claude Code, then run ` + + '/captains-bridge again.' + ) +} + +const GROUPS: Array<[Walkthrough['items'][number]['group'], string]> = [ + ['changed', 'What changed'], + ['unresolved', 'What remains unresolved'], + ['activity', 'Other recorded activity'], +] + +export function overviewText( + body: Walkthrough | null, + counts: { recordsIncluded: number; recordsTotal: number } | null, +): string { + if (body === null) { + const known = + counts === null + ? '' + : ` ${counts.recordsIncluded} of ${counts.recordsTotal} records are read.` + return `No walkthrough has been prepared for this conversation yet.${known}` + } + const lines = [`Objective: ${body.objective}`, `Summary: ${body.summary}`] + for (const [group, label] of GROUPS) { + const items = body.items.filter(item => item.group === group) + if (items.length === 0) continue + lines.push(`${label}:`) + for (const item of items) lines.push(`- ${item.title} (${item.status})`) + } + return lines.join('\n') +} + +// Whether this session draws panes at all. Set at session.start from the +// surface the host reports; unknown (null) is decided by what ui.open says. +let drawsPanes = true + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + drawsPanes = e.isInteractive !== false && e.surface !== 'vscode' + await $.command.register({ + name: COMMAND, + description: "Open the Captain's Bridge for this conversation", + immediate: true, + }) + // Retain the exact session ID on start and on reload. An established + // binding for this same session stays; a different one has ended. + // A binding ended by clear/resume/fork/end stays ended through a reload + // until an explicit /captains-bridge command (which clears the message). + const id = await $.session.id() + const ended = (await read($, request)).message === CHANGED_MESSAGE + await update($, binding, current => { + if (current === null) return ended ? null : { sessionId: id } + return current.sessionId === id ? current : null + }) + return next(e) + }) + + on('classic.SessionStart', async ($, e, next) => { + if (CHANGING_SOURCES.includes(e.source)) { + await update($, binding, () => null) + await update($, request, current => ({ + generation: current.generation + 1, + status: 'idle', + retain: false, + message: CHANGED_MESSAGE, + })) + } else { + const path = e.transcript_path + await update($, binding, () => ({ + sessionId: e.session_id, + ...(path ? { transcriptPath: path } : {}), + })) + } + return next(e) + }) + + on('session.end', async ($, e, next) => { + await update($, binding, () => null) + await update($, request, current => ({ + generation: current.generation + 1, + status: 'idle', + retain: false, + message: CHANGED_MESSAGE, + })) + return next(e) + }) + + on('command.run', { command: COMMAND }, async $ => { + const { version } = await $.session.version() + const id = await $.session.id() + if (isSupported(version)) { + // Explicit opening binds to this exact session; an established binding + // for it is kept (it carries the transcript path). + await update($, binding, current => + current !== null && current.sessionId === id ? current : { sessionId: id }, + ) + await update($, request, current => + current.message === CHANGED_MESSAGE + ? { ...current, status: 'idle', message: undefined } + : current, + ) + } + const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE }) + if (!isSupported(version)) return { text: tooOldMessage(version) } + if (drawsPanes && opened.isPlaced) { + return { text: `${PANE_TITLE} is open for this conversation.` } + } + const state = await $.state.get({ plugin: 'hermes-helmet', key: 'walkthrough' }) + const summary = await $.state.get({ plugin: 'hermes-helmet', key: 'records' }) + const body = (state.value as { body: Walkthrough } | null | undefined)?.body ?? null + const coverage = + (summary.value as { coverage: { recordsIncluded: number; recordsTotal: number } } | null | undefined) + ?.coverage ?? null + return { text: overviewText(body, coverage) } + }) + + on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => { + const { Box, Text } = $.ui.resolve(e) + const { version } = await $.session.version() + const bound = await read($, binding) + const status = await read($, request) + const saved = await read($, walkthrough) + const counts = await read($, records) + + if (!isSupported(version)) { + return ( + + {PANE_TITLE} + {tooOldMessage(version)} + + ) + } + if (bound === null) { + return ( + + {PANE_TITLE} + + {status.message ?? 'Run /captains-bridge to open the Bridge for this conversation.'} + + + ) + } + const body = saved === null ? null : saved.body + return ( + + {PANE_TITLE} + + Conversation {bound.sessionId} + + {overviewText(body, counts === null ? null : counts.coverage) + .split('\n') + .map(line => ( + {line} + ))} + + ) + }) +} diff --git a/pyproject.toml b/pyproject.toml index 0e154a8..458ceea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-helmet" -version = "0.7.0" +version = "0.8.0" description = "Open-source software factory for coding agents: delegation, review, and repair" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/verify.sh b/scripts/verify.sh index f6d99e2..0abefff 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -49,6 +49,12 @@ for required in \ skills/helmet-epic/SKILL.md \ skills/observe-chat/SKILL.md \ codex.mcp.json \ + hooks/hooks.json \ + hooks/register.tsx \ + hooks/register.test.tsx \ + types/index.d.ts \ + src/hermes_helmet/claude_mod_toolchain.py \ + tests/test_claude_mod_toolchain.py \ mcp/captains-bridge/server.py \ src/hermes_helmet/helmet_issue.py \ src/hermes_helmet/helmet_epic.py \ @@ -174,6 +180,22 @@ PYTHONPATH=src "$PYTHON_BIN" -m unittest discover -s tests -v fi ) +# Captain's Bridge Claude Code mod: run the real `claude plugin validate` and +# `claude plugin test` with the exact checked CLI, no account credentials, and +# fail on any call outside the read-only allowlist. CI must have the CLI; a +# local worker without it skips with a notice (CI is the authority). +if command -v claude >/dev/null 2>&1; then + PYTHONPATH=src "$PYTHON_BIN" -m hermes_helmet.claude_mod_toolchain \ + --check-binary "$(command -v claude)" + PYTHONPATH=src "$PYTHON_BIN" -m hermes_helmet.claude_mod_toolchain \ + --check-mod "$(command -v claude)" . +elif [ -n "${CI:-}" ]; then + echo "verify: the exact checked Claude Code CLI is required in CI for the Bridge mod" >&2 + exit 1 +else + echo "verify: skipped Bridge mod validate/test (claude not found; CI is the authority)" >&2 +fi + # Compose file must parse as YAML-ish structure (no docker required). "$PYTHON_BIN" - <<'PY' from pathlib import Path diff --git a/src/hermes_helmet/__init__.py b/src/hermes_helmet/__init__.py index 7a37429..12bfa8f 100644 --- a/src/hermes_helmet/__init__.py +++ b/src/hermes_helmet/__init__.py @@ -7,7 +7,7 @@ from hermes_helmet.authority import Policy, load_authority, load_policy, render_crew_contract -__version__ = "0.7.0" +__version__ = "0.8.0" __all__ = [ "Policy", diff --git a/src/hermes_helmet/claude_mod_toolchain.py b/src/hermes_helmet/claude_mod_toolchain.py new file mode 100644 index 0000000..a463dd1 --- /dev/null +++ b/src/hermes_helmet/claude_mod_toolchain.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Install the accepted Claude Code CLI and check the Bridge mod with it. + +The digests below are the SHA-256 values published in the signed release +manifest at ``downloads.claude.ai`` for Claude Code 2.1.296 (they equal the +digests of the matching ``@anthropic-ai/claude-code-`` npm binaries). +Keeping them beside the version makes a bump an explicit, reviewable +supply-chain change. Mods are early access, so the verified version is also +recorded in ``docs/first-officer-plugins.md``; re-verify on a minimum-version +change. + +``claude plugin validate`` and ``claude plugin test`` need no account +credentials; they run here with an empty, temporary ``HOME``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import re +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from urllib import request + + +CLAUDE_CODE_VERSION = "2.1.296" +# Lowest build the mod is verified on; never lower than 2.1.293 (issue #55). +MINIMUM_CLAUDE_CODE_VERSION = "2.1.293" +RELEASE_ROOT = "https://downloads.claude.ai/claude-code-releases" +ARTIFACTS: dict[str, str] = { + "linux-x64": "24972e3bc859fab2b46ed4c1e51f7d6130f06d3bd550811a114640de3370d0de", + "linux-arm64": "f1f6e96e0d8342b9dbf41d7e88255397a6a52ce3d8736ad6a4c6b59c9b62fefa", + "darwin-x64": "a6bf4f30be241053a923f3820ad23c5991d2f5ac2935b3a8dd64da218d9cc603", + "darwin-arm64": "c9b5341637becbd423ddffc5b254afb645682a3868cb708bbc6cc0e7bb419937", +} + +# The exact read-only allowlist from issue #55. A call outside it fails CI. +ALLOWED_CALLS = frozenset( + { + "$.session.id", + "$.session.version", + "$.process.run", + "$.model.complete", + "$.command.register", + "$.command.list", + "$.command.run", + "$.ui.open", + "$.ui.resolve", + "$.ui.status", + "$.ui.toast", + "$.state.get", + "$.state.set", + "$.clock.after", + } +) +_CALLS_RE = re.compile(r"^(?P\S+) calls: (?P.+)$") +_CALL_RE = re.compile(r"^\$\.[a-z][A-Za-z]*\.[a-z][A-Za-z]*$") + + +class ClaudeModToolchainError(RuntimeError): + """The accepted Claude Code toolchain could not be selected or verified.""" + + +def _version_tuple(version: str) -> tuple[int, int, int]: + match = re.fullmatch(r"([0-9]+)\.([0-9]+)\.([0-9]+)", version) + if match is None: + raise ClaudeModToolchainError(f"unrecognized Claude Code version: {version!r}") + return int(match[1]), int(match[2]), int(match[3]) + + +def require_version(version: str) -> None: + if version != CLAUDE_CODE_VERSION: + raise ClaudeModToolchainError( + f"Claude Code version drift: expected {CLAUDE_CODE_VERSION}, received {version}" + ) + + +def require_version_output(output: str) -> None: + match = re.fullmatch(r"([0-9]+\.[0-9]+\.[0-9]+) \(Claude Code\)\s*", output) + if match is None: + raise ClaudeModToolchainError("Claude Code version output is unrecognized") + require_version(match.group(1)) + + +def require_minimum(version: str) -> None: + if _version_tuple(version) < _version_tuple(MINIMUM_CLAUDE_CODE_VERSION): + raise ClaudeModToolchainError( + f"Claude Code {version} is below the minimum {MINIMUM_CLAUDE_CODE_VERSION}" + ) + + +def artifact_platform(*, system: str | None = None, machine: str | None = None) -> str: + system_name = (system or platform.system()).casefold() + machine_name = (machine or platform.machine()).casefold() + architecture = { + "amd64": "x64", + "x86_64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + }.get(machine_name) + if system_name not in {"darwin", "linux"} or architecture is None: + raise ClaudeModToolchainError( + f"unsupported Claude Code platform: {system_name}/{machine_name}" + ) + name = f"{system_name}-{architecture}" + if name not in ARTIFACTS: + raise ClaudeModToolchainError(f"unsupported Claude Code artifact: {name}") + return name + + +def verify_sha256(payload: bytes, expected: str) -> None: + actual = hashlib.sha256(payload).hexdigest() + if actual != expected: + raise ClaudeModToolchainError( + f"Claude Code checksum mismatch: expected {expected}, received {actual}" + ) + + +def _clean_env(home: str) -> dict[str, str]: + """An environment with no account credentials, in a throwaway home.""" + + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("ANTHROPIC_", "CLAUDE_CODE_OAUTH")) + and key not in {"CLAUDE_CONFIG_DIR", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"} + } + env["HOME"] = home + env["CLAUDE_CONFIG_DIR"] = str(Path(home) / ".claude") + env["DISABLE_AUTOUPDATER"] = "1" + env["DISABLE_TELEMETRY"] = "1" + return env + + +def validate_binary(binary: Path) -> None: + with tempfile.TemporaryDirectory() as home: + try: + completed = subprocess.run( + [str(binary), "--version"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=30, + env=_clean_env(home), + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ClaudeModToolchainError("Claude Code version check failed") from exc + require_version_output(completed.stdout) + + +def install(*, bin_dir: Path, version: str = CLAUDE_CODE_VERSION) -> Path: + require_version(version) + name = artifact_platform() + url = f"{RELEASE_ROOT}/{version}/{name}/claude" + try: + with request.urlopen(url, timeout=300) as response: + payload = response.read() + except OSError as exc: + raise ClaudeModToolchainError("Claude Code release asset download failed") from exc + verify_sha256(payload, ARTIFACTS[name]) + + bin_dir = bin_dir.expanduser() + bin_dir.mkdir(parents=True, exist_ok=True) + destination = bin_dir / "claude" + descriptor, temporary_name = tempfile.mkstemp(prefix=".claude.", dir=bin_dir) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + descriptor = -1 + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + temporary.chmod( + stat.S_IRUSR + | stat.S_IWUSR + | stat.S_IXUSR + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH + ) + os.replace(temporary, destination) + finally: + if descriptor >= 0: + os.close(descriptor) + temporary.unlink(missing_ok=True) + return destination + + +def calls_from_validation(report_text: str) -> dict[str, list[str]]: + """Return the ``calls:`` each hooks module makes, per ``claude plugin validate --json``. + + Fails closed: unparseable output, a missing hooks module, a missing + ``calls:`` line, or a malformed call name raises. + """ + + try: + report = json.loads(report_text) + except (TypeError, ValueError) as exc: + raise ClaudeModToolchainError("validation output is not JSON") from exc + if not isinstance(report, dict) or report.get("success") is not True: + raise ClaudeModToolchainError("validation did not succeed") + contents = report.get("contents") + if not isinstance(contents, list): + raise ClaudeModToolchainError("validation output has no contents list") + found: dict[str, list[str]] = {} + for entry in contents: + if not isinstance(entry, dict) or entry.get("type") != "hooks": + continue + notes = entry.get("notes") + if not isinstance(notes, list) or not all(isinstance(note, str) for note in notes): + raise ClaudeModToolchainError("validation hooks notes are unparseable") + for note in notes: + match = _CALLS_RE.match(note) + if match is None: + continue + names = [part.strip() for part in match.group("calls").split(",")] + if not names or any(_CALL_RE.match(name) is None for name in names): + raise ClaudeModToolchainError(f"validation calls are unparseable: {note!r}") + found.setdefault(match.group("module"), []).extend(names) + if not found: + raise ClaudeModToolchainError("validation listed no hooks module calls") + return found + + +def forbidden_calls(found: dict[str, list[str]]) -> list[str]: + return sorted({call for calls in found.values() for call in calls} - ALLOWED_CALLS) + + +def check_mod_calls(report_text: str) -> list[str]: + """Return the sorted calls the mod makes; raise if any is outside the allowlist.""" + + found = calls_from_validation(report_text) + forbidden = forbidden_calls(found) + if forbidden: + raise ClaudeModToolchainError( + "mod makes calls outside the read-only allowlist: " + ", ".join(forbidden) + ) + return sorted({call for calls in found.values() for call in calls}) + + +def run_mod_checks(binary: Path, plugin_root: Path) -> list[str]: + """Run the real ``claude plugin validate`` and ``claude plugin test``.""" + + validate_binary(binary) + with tempfile.TemporaryDirectory() as home: + env = _clean_env(home) + validated = subprocess.run( + [str(binary), "plugin", "validate", str(plugin_root), "--json"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=300, + env=env, + check=False, + ) + if validated.returncode != 0: + raise ClaudeModToolchainError( + "claude plugin validate failed:\n" + validated.stdout[-4000:] + validated.stderr[-2000:] + ) + calls = check_mod_calls(validated.stdout) + tested = subprocess.run( + [str(binary), "plugin", "test", str(plugin_root)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=900, + env=env, + check=False, + ) + sys.stdout.write(tested.stdout) + if tested.returncode != 0: + raise ClaudeModToolchainError("claude plugin test failed") + if not re.search(r"^\s*\d+ pass\s*$", tested.stdout, re.MULTILINE) or re.search( + r"^\s*[1-9]\d* fail\s*$", tested.stdout, re.MULTILINE + ): + raise ClaudeModToolchainError("claude plugin test output is unparseable or reports failures") + return calls + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Install and use the accepted Claude Code CLI") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--bin-dir", type=Path, help="install the checked binary here") + mode.add_argument("--check-binary", type=Path, help="verify the binary's exact version") + mode.add_argument( + "--check-mod", + nargs=2, + metavar=("BINARY", "PLUGIN_ROOT"), + help="run claude plugin validate and test, and check the call allowlist", + ) + parser.add_argument("--version", default=CLAUDE_CODE_VERSION) + args = parser.parse_args(argv) + try: + if args.check_binary is not None: + require_version(args.version) + validate_binary(args.check_binary) + print(f"claude {CLAUDE_CODE_VERSION}") + return 0 + if args.check_mod is not None: + binary, root = (Path(item) for item in args.check_mod) + calls = run_mod_checks(binary, root) + print("mod calls within allowlist: " + ", ".join(calls)) + return 0 + assert args.bin_dir is not None + destination = install(bin_dir=args.bin_dir, version=args.version) + except ClaudeModToolchainError as exc: + print(f"claude mod check failed: {exc}", file=sys.stderr) + return 1 + print(destination) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hermes_helmet/public_surface.py b/src/hermes_helmet/public_surface.py index 4747c20..9e1df6b 100644 --- a/src/hermes_helmet/public_surface.py +++ b/src/hermes_helmet/public_surface.py @@ -64,6 +64,8 @@ ] SCAN_ROOTS = ( "mcp", + "hooks", + "types", "config", "src", "tests", diff --git a/tests/test_claude_mod_toolchain.py b/tests/test_claude_mod_toolchain.py new file mode 100644 index 0000000..ce67623 --- /dev/null +++ b/tests/test_claude_mod_toolchain.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Pinned Claude Code CLI and Bridge mod call-allowlist tests (synthetic only).""" + +from __future__ import annotations + +import hashlib +import json +import re +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from hermes_helmet.claude_mod_toolchain import ( + ALLOWED_CALLS, + ARTIFACTS, + CLAUDE_CODE_VERSION, + MINIMUM_CLAUDE_CODE_VERSION, + ClaudeModToolchainError, + artifact_platform, + calls_from_validation, + check_mod_calls, + forbidden_calls, + install, + require_minimum, + require_version, + require_version_output, + validate_binary, + verify_sha256, +) + + +ROOT = Path(__file__).resolve().parents[1] +ISSUE_ALLOWLIST = ( + "$.session.id, $.session.version, $.process.run, $.model.complete, " + "$.command.register, $.command.list, $.command.run, $.ui.open, $.ui.resolve, " + "$.ui.status, $.ui.toast, $.state.get, $.state.set, $.clock.after" +) + + +def _report(*calls_lines: str, success: bool = True) -> str: + return json.dumps( + { + "success": success, + "contents": [ + { + "type": "hooks", + "file": "hooks/hooks.json", + "errors": [], + "notes": ["./register.tsx hooks: session.start", *calls_lines], + } + ], + } + ) + + +class PinTests(unittest.TestCase): + def test_pin_is_exact_and_not_below_the_issue_minimum(self) -> None: + self.assertRegex(CLAUDE_CODE_VERSION, r"^\d+\.\d+\.\d+$") + self.assertEqual(MINIMUM_CLAUDE_CODE_VERSION, "2.1.293") + require_minimum(CLAUDE_CODE_VERSION) + require_minimum("2.1.293") + with self.assertRaisesRegex(ClaudeModToolchainError, "below the minimum"): + require_minimum("2.1.292") + with self.assertRaisesRegex(ClaudeModToolchainError, "unrecognized"): + require_minimum("latest") + + def test_every_artifact_has_a_sha256_digest(self) -> None: + self.assertEqual( + set(ARTIFACTS), {"linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"} + ) + for name, digest in ARTIFACTS.items(): + self.assertRegex(digest, r"^[0-9a-f]{64}$", name) + + def test_version_drift_is_rejected(self) -> None: + require_version(CLAUDE_CODE_VERSION) + require_version_output(f"{CLAUDE_CODE_VERSION} (Claude Code)\n") + with self.assertRaisesRegex(ClaudeModToolchainError, "version drift"): + require_version("2.1.293") + with self.assertRaisesRegex(ClaudeModToolchainError, "version drift"): + require_version("latest") + with self.assertRaisesRegex(ClaudeModToolchainError, "unrecognized"): + require_version_output(f"prefix {CLAUDE_CODE_VERSION} (Claude Code)\n") + with self.assertRaisesRegex(ClaudeModToolchainError, "version drift"): + require_version_output("2.1.2960 (Claude Code)\n") + + def test_invoked_binary_version_is_checked(self) -> None: + exact = mock.Mock(returncode=0, stdout=f"{CLAUDE_CODE_VERSION} (Claude Code)\n") + drifted = mock.Mock(returncode=0, stdout="2.1.293 (Claude Code)\n") + with mock.patch( + "hermes_helmet.claude_mod_toolchain.subprocess.run", return_value=exact + ) as run: + validate_binary(Path("/synthetic/claude")) + env = run.call_args.kwargs["env"] + self.assertNotIn("ANTHROPIC_API_KEY", env) + self.assertEqual(env["DISABLE_AUTOUPDATER"], "1") + with mock.patch( + "hermes_helmet.claude_mod_toolchain.subprocess.run", return_value=drifted + ), self.assertRaisesRegex(ClaudeModToolchainError, "version drift"): + validate_binary(Path("/synthetic/claude")) + + def test_checksum_drift_is_rejected(self) -> None: + payload = b"not-a-claude-binary" + with self.assertRaisesRegex(ClaudeModToolchainError, "checksum"): + verify_sha256(payload, ARTIFACTS["linux-x64"]) + verify_sha256(payload, hashlib.sha256(payload).hexdigest()) + + def test_install_verifies_before_atomic_replacement(self) -> None: + payload = b"synthetic-claude-binary" + digest = hashlib.sha256(payload).hexdigest() + with tempfile.TemporaryDirectory() as directory, mock.patch.dict( + ARTIFACTS, {"linux-x64": digest}, clear=True + ), mock.patch( + "hermes_helmet.claude_mod_toolchain.artifact_platform", return_value="linux-x64" + ), mock.patch( + "hermes_helmet.claude_mod_toolchain.request.urlopen" + ) as urlopen: + urlopen.return_value.__enter__.return_value.read.return_value = payload + destination = install(bin_dir=Path(directory)) + self.assertEqual(destination.read_bytes(), b"synthetic-claude-binary") + self.assertTrue(destination.stat().st_mode & 0o111) + self.assertIn( + f"/claude-code-releases/{CLAUDE_CODE_VERSION}/linux-x64/claude", + urlopen.call_args.args[0], + ) + ARTIFACTS["linux-x64"] = "0" * 64 + destination.unlink() + with self.assertRaisesRegex(ClaudeModToolchainError, "checksum"): + install(bin_dir=Path(directory)) + self.assertFalse(destination.exists()) + self.assertEqual(sorted(p.name for p in Path(directory).iterdir()), []) + + def test_platforms_cover_ci_runners(self) -> None: + self.assertEqual(artifact_platform(system="Linux", machine="x86_64"), "linux-x64") + self.assertEqual(artifact_platform(system="Linux", machine="aarch64"), "linux-arm64") + self.assertEqual(artifact_platform(system="Darwin", machine="arm64"), "darwin-arm64") + self.assertEqual(artifact_platform(system="Darwin", machine="x86_64"), "darwin-x64") + with self.assertRaisesRegex(ClaudeModToolchainError, "unsupported"): + artifact_platform(system="Windows", machine="AMD64") + + +class CallAllowlistTests(unittest.TestCase): + def test_allowlist_is_exactly_the_accepted_issue_list(self) -> None: + self.assertEqual(ALLOWED_CALLS, frozenset(ISSUE_ALLOWLIST.split(", "))) + self.assertEqual(len(ALLOWED_CALLS), 14) + + def test_calls_within_the_allowlist_pass(self) -> None: + calls = check_mod_calls( + _report("./register.tsx calls: $.command.register, $.session.id, $.ui.open") + ) + self.assertEqual(calls, ["$.command.register", "$.session.id", "$.ui.open"]) + + def test_forbidden_calls_fail_closed(self) -> None: + for call in ( + "$.fs.write", + "$.agent.spawn", + "$.prompt.submit", + "$.prompt.fill", + "$.session.send", + "$.http.fetch", + "$.tool.call", + "$.process.spawn", + "$.store.set", + ): + with self.subTest(call=call), self.assertRaisesRegex( + ClaudeModToolchainError, "outside the read-only allowlist" + ): + check_mod_calls(_report(f"./register.tsx calls: $.ui.open, {call}")) + self.assertEqual( + forbidden_calls({"m": ["$.ui.open", "$.fs.write"]}), ["$.fs.write"] + ) + + def test_unparseable_validation_output_fails_closed(self) -> None: + for text in ( + "", + "not json", + "[]", + json.dumps({"success": False, "contents": []}), + json.dumps({"success": True}), + json.dumps({"success": True, "contents": []}), + _report("./register.tsx hooks: session.start"), + _report("./register.tsx calls: ui.open"), + _report("./register.tsx calls: "), + json.dumps( + {"success": True, "contents": [{"type": "hooks", "notes": "calls: $.ui.open"}]} + ), + ): + with self.subTest(text=text[:40]), self.assertRaises(ClaudeModToolchainError): + calls_from_validation(text) + + def test_every_module_in_the_report_is_checked(self) -> None: + report = json.dumps( + { + "success": True, + "contents": [ + {"type": "hooks", "notes": ["./a.ts calls: $.ui.open"]}, + {"type": "hooks", "notes": ["./b.ts calls: $.fs.write"]}, + ], + } + ) + with self.assertRaisesRegex(ClaudeModToolchainError, r"\$\.fs\.write"): + check_mod_calls(report) + + def test_shipped_module_source_names_no_forbidden_call(self) -> None: + # Static belt for hosts without the CLI; CI's real validate is the authority. + source = (ROOT / "hooks" / "register.tsx").read_text(encoding="utf-8") + used = set(re.findall(r"\$\.[a-z]+\.[A-Za-z]+(?=\()", source)) + self.assertEqual(sorted(used - ALLOWED_CALLS), []) + + +class WorkflowWiringTests(unittest.TestCase): + def test_workflow_installs_and_checks_the_exact_cli_before_verify(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "verify.yml").read_text(encoding="utf-8") + install_at = workflow.index("hermes_helmet.claude_mod_toolchain --bin-dir") + check_at = workflow.index("hermes_helmet.claude_mod_toolchain --check-binary") + verify_at = workflow.index("run: scripts/verify.sh") + self.assertLess(install_at, check_at) + self.assertLess(check_at, verify_at) + self.assertNotIn("npm install", workflow) + self.assertNotIn("curl", workflow) + self.assertNotIn("claude.ai/install.sh", workflow) + + def test_verify_runs_real_validate_and_test_and_requires_cli_in_ci(self) -> None: + verify = (ROOT / "scripts" / "verify.sh").read_text(encoding="utf-8") + self.assertIn("claude_mod_toolchain", verify) + self.assertIn("--check-mod", verify) + self.assertIn('-n "${CI:-}"', verify) + self.assertIn("Claude Code CLI is required in CI", verify) + + def test_no_account_credentials_reach_the_mod_checks(self) -> None: + source = (ROOT / "src" / "hermes_helmet" / "claude_mod_toolchain.py").read_text( + encoding="utf-8" + ) + self.assertIn("ANTHROPIC_", source) + self.assertIn("CLAUDE_CODE_OAUTH", source) + workflow = (ROOT / ".github" / "workflows" / "verify.yml").read_text(encoding="utf-8") + self.assertNotIn("secrets.", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_first_officer_plugin.py b/tests/test_first_officer_plugin.py index 385ab34..0e1af56 100644 --- a/tests/test_first_officer_plugin.py +++ b/tests/test_first_officer_plugin.py @@ -191,6 +191,148 @@ def test_codex_plugin_loads_captains_bridge_from_repository_source(self) -> None self.assertNotIn("mcpServers", claude) self.assertFalse((ROOT / "prototypes").exists()) + def test_claude_manifest_declares_the_bridge_mod(self) -> None: + claude = _load_json(".claude-plugin/plugin.json") + codex = _load_json(".codex-plugin/plugin.json") + self.assertIn("Captain's Bridge", claude["description"]) + types_path = _resolve_inside(ROOT, claude["types"]) + self.assertEqual(types_path, (ROOT / "types" / "index.d.ts").resolve()) + self.assertTrue(types_path.name.endswith(".d.ts")) + contract = types_path.read_text(encoding="utf-8") + self.assertIn("declare module 'claude-code'", contract) + self.assertIn("interface PluginState", contract) + self.assertIn("export type BridgeState", contract) + for field in ("binding", "records", "walkthrough", "request", "view", "pending"): + self.assertRegex(contract, rf"(?m)^\s+{field}: BridgeState\['{field}'\]") + for key in ("sessionId", "transcriptPath", "fingerprint", "generation", "retain"): + self.assertIn(key, contract) + for status in ("idle", "preparing", "failed", "cancelled", "superseded", "timed-out"): + self.assertIn(f"'{status}'", contract) + # A self-contained, types-only contract (Claude Code enforces this too). + self.assertNotRegex(contract, r"(?m)^\s*(import|export \{|export \*|require)\b") + self.assertNotIn("/// None: + hooks_json = ROOT / "hooks" / "hooks.json" + config = json.loads(hooks_json.read_text(encoding="utf-8")) + self.assertEqual(list(config), ["modules"]) + modules = config["modules"] + self.assertEqual(len(modules), 1, "Claude Code allows one hooks module per plugin") + module = modules[0] + # Relative to hooks.json; it must stay inside the plugin root. + self.assertTrue(module.startswith("./"), module) + self.assertNotIn("..", Path(module).parts) + resolved = (hooks_json.parent / module).resolve() + resolved.relative_to(ROOT.resolve()) + self.assertEqual(resolved, (ROOT / "hooks" / "register.tsx").resolve()) + self.assertTrue(resolved.is_file()) + source = resolved.read_text(encoding="utf-8") + self.assertRegex(source, r"export const register: Register") + for call in ("captains-bridge", "immediate: true", "$.ui.open"): + self.assertIn(call, source) + self.assertIn("This conversation changed. Run /captains-bridge to open the Bridge for it.", source) + self.assertNotIn("setInterval", source) + self.assertNotIn("$.clock.every", source) + self.assertNotIn("$.prompt", source) + self.assertNotIn("$.session.send", source) + # Every file the module imports from the plugin stays in the plugin. + for target in re.findall(r"from '(\.[^']*)'", source): + candidate = (resolved.parent / target).resolve() + candidate.relative_to(ROOT.resolve()) + + def test_mod_path_confinement_rejects_escapes(self) -> None: + for bad in ("../outside.d.ts", "./../outside.d.ts", "/abs/outside.d.ts", "types/x.d.ts"): + with self.assertRaises(AssertionError, msg=bad): + _resolve_inside(ROOT, bad) + _resolve_inside(ROOT, "./types/index.d.ts") + + def test_marketplace_still_lists_exactly_one_root_plugin(self) -> None: + marketplace = _load_json(".claude-plugin/marketplace.json") + self.assertEqual(len(marketplace["plugins"]), 1) + entry = marketplace["plugins"][0] + self.assertEqual(entry["source"], "./") + self.assertEqual(entry["name"], PLUGIN_NAME) + self.assertNotIn("hooks", entry) + self.assertNotIn("strict", entry) + + def test_mod_files_ship_in_the_plugin_cache_layout(self) -> None: + claude = _load_json(".claude-plugin/plugin.json") + with tempfile.TemporaryDirectory() as directory: + cache = Path(directory) / MARKETPLACE_NAME / PLUGIN_NAME / claude["version"] + for relative in ( + ".claude-plugin/plugin.json", + "hooks/hooks.json", + "hooks/register.tsx", + "types/index.d.ts", + ): + dest = cache / relative + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes((ROOT / relative).read_bytes()) + manifest = json.loads((cache / ".claude-plugin/plugin.json").read_text(encoding="utf-8")) + self.assertTrue(_resolve_inside(cache, manifest["types"]).is_file()) + hooks = json.loads((cache / "hooks/hooks.json").read_text(encoding="utf-8")) + self.assertTrue((cache / "hooks" / hooks["modules"][0]).resolve().is_file()) + # The skills are plain files and need no mod to load. + for skill in SKILLS: + self.assertTrue((ROOT / "skills" / skill / "SKILL.md").is_file(), skill) + + def test_skills_do_not_depend_on_the_mod(self) -> None: + # With mods disabled by policy only hooks/ is skipped; skills load from skills/. + for skill in SKILLS: + text = (ROOT / "skills" / skill / "SKILL.md").read_text(encoding="utf-8") + self.assertNotIn("hooks/register", text, skill) + + def test_mod_files_are_in_the_public_boundary_scan(self) -> None: + self.assertIn("hooks", public_surface.SCAN_ROOTS) + self.assertIn("types", public_surface.SCAN_ROOTS) + self.assertEqual(public_surface.scan_public_surface(ROOT), []) + verify = (ROOT / "scripts" / "verify.sh").read_text(encoding="utf-8") + for required in ("hooks/hooks.json", "hooks/register.tsx", "hooks/register.test.tsx", "types/index.d.ts"): + self.assertIn(required, verify) + + def test_plugin_and_tests_use_only_synthetic_fixtures(self) -> None: + for relative in ("hooks/register.tsx", "hooks/register.test.tsx", "types/index.d.ts"): + text = (ROOT / relative).read_text(encoding="utf-8") + for marker in ("/Users/", "/home/", "/opt/data", ".claude/projects"): + self.assertNotIn(marker, text, f"{relative}: {marker}") + + def test_install_guide_documents_the_bridge_mod(self) -> None: + guide = " ".join((ROOT / INSTALL_GUIDE).read_text(encoding="utf-8").split()) + for phrase in ( + "/captains-bridge", + "2.1.293", + "claude plugin update hermes-helmet@hermes-helmet", + "/reload-plugins", + "helmetCommand", + "explanationModel", + "explanationTimeoutSeconds", + "showMeCommand", + "retroCommand", + "organization policy", + "This conversation changed. Run /captains-bridge to open the Bridge for it.", + ): + self.assertIn(phrase, guide) + def test_public_surface_scan_covers_plugin_manifests(self) -> None: self.assertIn(".claude-plugin", public_surface.SCAN_ROOTS) self.assertIn(".codex-plugin", public_surface.SCAN_ROOTS) diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..c7885b7 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,129 @@ +// Type contract for the Hermes Helmet Claude Code mod (Captain's Bridge). +// +// Self-contained on purpose: Claude Code checks that this file has no import, +// export-from, require or reference, exports only types, and augments +// 'claude-code' only through `declare module`. The state shape below is the +// accepted design from issue #55; later slices add behavior, not fields. + +export type Ref = string + +export type Actor = 'Captain' | 'First officer' | 'Hermes' | 'Other agent' + +export type WalkthroughItem = { + id: string + title: string + group: 'changed' | 'unresolved' | 'activity' + status: string + summary: string + detail: string + evidence: Ref[] + steps?: Array<{ + actor: Actor + label: string + detail: string + evidence: Ref[] + }> + reported?: { label: string; actor: Actor; evidence: Ref[] } + disposition?: { label: string; actor: Actor; evidence: Ref[] } + change?: { + before: string + after: string + explanation: string + evidence: Ref[] + } + links?: Array<{ label: string; url: string; evidence: Ref[] }> +} + +export type Walkthrough = { + objective: string + summary: string + evidence: Ref[] + items: WalkthroughItem[] +} + +export type Coverage = { + recordsTotal: number + recordsIncluded: number + bytesOmitted: number + unsupportedSkipped: number + pendingTail: boolean +} + +export type RecordOrigin = + | 'person' + | 'hook' + | 'plugin' + | 'meta' + | 'assistant' + | 'agent' + +export type SessionRecord = { + ref: Ref + parentRef: Ref | null + agentId: string | null + timestamp: string + role: 'user' | 'assistant' | 'tool_result' | 'system' + origin: RecordOrigin + text: string + tool?: string + toolUseId?: string + isError?: boolean + truncated: boolean +} + +export type RecordsSummary = { + schema: 'hermes-helmet.bridge.records/1' + helmetVersion: string + sessionId: string + readAt: string + fingerprint: string + records: SessionRecord[] + coverage: Coverage + warnings: string[] +} + +export type RequestStatus = + | 'idle' + | 'preparing' + | 'failed' + | 'cancelled' + | 'superseded' + | 'timed-out' + +export type BridgeState = { + binding: { sessionId: string; transcriptPath?: string } | null + records: { + fingerprint: string + readAt: string + coverage: Coverage + summary: RecordsSummary + } | null + walkthrough: { + body: Walkthrough + readAt: string + fingerprint: string + preparedAt: string + } | null + request: { + generation: number + status: RequestStatus + retain: boolean + startedAt?: string + message?: string + } + view: { stack: Array<{ itemId?: string; scroll?: number }> } + pending: { action: 'show-me' | 'retro'; since: string } | null +} + +declare module 'claude-code' { + interface PluginState { + 'hermes-helmet': { + binding: BridgeState['binding'] + records: BridgeState['records'] + walkthrough: BridgeState['walkthrough'] + request: BridgeState['request'] + view: BridgeState['view'] + pending: BridgeState['pending'] + } + } +}