Check Claude Code's Bash and MCP tool calls with Mezmo Governance, send Claude Code telemetry to Mezmo, and connect Claude to Mezmo MCP.

This plugin puts Claude Code's tool calls under Mezmo Governance. Before Claude runs a tool, the plugin asks Mezmo Governance whether that exact call is allowed, and blocks it if the policy says no.
It can also send Claude Code's metrics, events and traces to Mezmo's OpenTelemetry source, and connect Claude to Mezmo MCP so it can query your logs and work on pipelines. All three are independent, so you can turn on any of them.
A small status display in the prompt footer tells you whether governance and telemetry are working.
Until the plugin has a key, it lets every call run and sends nothing; MEZMO_GOVERNANCE_REQUIRED=1 makes it deny instead (see Governance). With a key, a governance hook that crashes or times out blocks the call on Claude Code 2.1.295 or later. Older versions ignore the hook's onFailure: "block", so the tool runs. --safe-mode and "disableAllHooks": true turn the governance check off along with your other hooks.
Editors that run Claude Code over ACP, such as Zed, use the Claude CLI bundled with @agentclientprotocol/claude-agent-acp. An older adapter bundles an older CLI: 0.85.1 bundles 2.1.286, so a crash there lets the tool run. Set CLAUDE_CODE_EXECUTABLE to your installed claude in the agent's env (Zed: agent_servers.claude-acp.env). Verified live in Zed 1.24.2 with adapter 0.93.0 and that setting: Bash denied, Read approved.
You need Python 3.9 or later. The scripts only use the standard library. You need Claude Code v2.1.295 or later (see Fail behavior).
Tested version: Claude Code 2.1.301 with this plugin, run live against tools/mock_governance.py: Bash was denied and Read was approved, and a hook crash blocked the call.
claude plugin marketplace add mezmo/ai-governance-plugins
claude plugin install mezmo@mezmo
Or, from a clone of this repository, run ./mezmo-governance install claude-code (add --dry-run to see the change first). It removes the older mezmo-governance plugin, which would check every call twice. It then installs mezmo from the clone, unless a mezmo marketplace is already set up. Keep the clone where it is: Claude Code loads the plugin from it. ./mezmo-governance uninstall claude-code undoes it. Moving between this route and the GitHub marketplace deletes the saved key (see below). After you update the clone, run uninstall, then install: Claude Code keeps its own copy, and install leaves an existing one alone.
Then run /mezmo:setup in Claude Code and start a new session. Claude Code reads these settings when a session starts, so the session you ran setup in carries on as before.
Removing the marketplace, or uninstalling the plugin, deletes its saved settings, including the governance service account token in the keychain. That includes moving the plugin to another marketplace. Afterwards, run /reload-plugins or start a new session, then run /mezmo:setup governance again. Telemetry settings live in ~/.claude/settings.json and the MCP server in ~/.claude.json, so they aren't affected.
| Command | What it does | |||
|---|---|---|---|---|
| `/mezmo:setup [governance\ | telemetry\ | mcp\ | all]` | Sets up governance, telemetry, MCP or all of them. Governance and telemetry can also go in a managed-settings file for your organization |
/mezmo:test | Checks your keys and shows what's configured | |||
| `/mezmo:remove [governance\ | telemetry\ | mcp\ | all]` | Undoes setup. Uninstalling the plugin doesn't |
/mezmo | Shows this session's status right away, without a Claude turn |
To try the plugin for one session without installing it, run claude --plugin-dir /path/to/ai-governance-plugins/claude-code.
Setup checks your Claude version before asking for a token. The marketplace can install the plugin on an older CLI, so installation alone does not mean it will work. governance_config.py preflight explains whether you need to upgrade, install the plugin, or fix Claude configuration access. An old CLI still needs an upgrade if you store the token in settings. The repository installer reports the binary and version it checks.
For an end-to-end check after setup:
/mezmo:test; it tests only configured signals. Use /mezmo:test --signals traces to select traces explicitly, --all-signals to deliberately test all three, or --check-only to probe without publishing records.test.run_id, service.name=mezmo-claude-plugin, and mezmo.test=true in the pipeline destination. The checker confirms endpoint acceptance, not arrival.printf 'mezmo-onboarding-test\n' using Bash, then confirm its decision under the intended Governance agent. Read/Edit/Write are not governed.service.name=claude-code.Keep prompt, response and tool-detail capture off for this test. Governance still sends Bash/MCP arguments for authorization, independently of telemetry content capture. Choose your account and have your administrator prepare the token, policy and pipeline before testing. The checker does not set them up for you.
To check the setup from the repository root, run ./mezmo-governance doctor claude-code and ./mezmo-governance smoke claude-code. doctor reads MEZMO_GOVERNANCE_URL and MEZMO_GOVERNANCE_TOKEN from the shell, not the key that setup saved, so export them first. It also checks that a key reaches the hook, since without one the plugin lets every call run. That passes when claude plugin configure shows a saved key with governance on, or when settings.json has a key or MEZMO_GOVERNANCE_REQUIRED=1 under env. A key or that variable only in your shell gets a warning: Claude Code started from the Dock or another app may not see it. smoke uses its own mock. /mezmo:test checks the saved key from inside Claude Code.
Setup uses --key-file so the token is not included in the Bash call sent to Governance. The shared helper python3 scripts/mezmo_common.py create-key-file creates an empty private file (0600) in a private directory (0700). Fill it locally, or through Claude's Write tool, then pass its path with --key-file "<path>" --delete-key-file to the setup/check script. Never fill it with a Bash command containing the token. Use a fresh file for each command.
The scripts reject symlinks, hard links, shared permissions and files owned by another user. Key files need macOS or Linux; on Windows the helper stops before a file is created. The delete flag removes the file on success or action failure. After interruption or an argument-parsing failure, use python3 scripts/mezmo_common.py delete-key-file "<path>". Without the delete flag, the file is left for its owner to manage. Managed-settings commands using a key file require --output, so the resulting credentials are not printed.
File input does not erase earlier chat, secure every copy on disk, or hide credentials in child-process arguments such as claude mcp add. Prefer filling the file yourself if the token should stay out of the conversation. Existing --key and environment options remain available, but do not put literal keys in governed Bash calls.
If plugin/keychain access fails, status reports the plugin state as unknown while showing readable settings. check can still validate a supplied or readable token. remove cleans settings and reports incomplete removal if it cannot also clear the plugin token.
With governance on, a PreToolUse hook sends each Bash and MCP tool call to Mezmo Governance before it runs:
| Policy says | What happens |
|---|---|
| Allow | The tool runs |
| Deny | The tool is blocked, and Claude sees the policy's reason |
| Ask a person | The call waits while someone approves or denies it in Mezmo, for up to 590 seconds. If nobody decides, it's blocked |
| Nothing: the service is unreachable, or rejects the key | The tool is blocked |
Bash calls show up in Mezmo under namespace claude_code, one call per part of the command (see Bash commands). MCP calls show up under the server's name, such as mezmo, with the tool's own arguments. Other built-in tools, such as Read, Edit and Write, don't go to Mezmo. Neither do the MCP servers the Claude desktop app adds for its own task list, panes and sessions (ccd_turn, ccd_session and the like).
Governance stays off until you give it a key. Until then the hook lets every call through and sends nothing. Register an agent for Claude Code in Mezmo Governance, give it a service account with Governance permissions, and attach a policy. Then run /mezmo:setup governance with that service account's token, a standard Mezmo service key (see Connect to Mezmo). Setup checks the key without recording a decision, then saves it as a plugin option, so it lives in the system keychain rather than in a settings file.
| Setting | Plugin option | Environment variable (wins) | Default |
|---|---|---|---|
| Authorize URL | governance_url | MEZMO_GOVERNANCE_URL | https://api.mezmo.com/governance/workflow/authorize |
| Service account token | governance_access_key | MEZMO_GOVERNANCE_ACCESS_KEY or MEZMO_GOVERNANCE_TOKEN | none, so governance is off |
| Required | MEZMO_GOVERNANCE_REQUIRED=1 | off |
The service only accepts plain identifiers as argument names, so an MCP tool with an argument such as max-results gets blocked, and the message says which argument.
Claude often runs several commands in one Bash call, such as cd /repo && git push origin main. The plugin splits the command where Claude Code splits it for its own permission rules (&&, ||, ;, |, |&, & and new lines) and sends each part to Mezmo as its own call. Mezmo decides every part and goes with the strictest answer, so an allowed cd can't carry a git push through. A part that runs once another has finished (after &&, || or ;) names it in dependencies.
Each part carries fields a rule can match:
| Field | For git push --force origin main |
|---|---|
command | git push --force origin main |
program | git |
arg_1, arg_2, … | push, origin, main: flags left out, and only the first 10 |
flag_<name> | flag_force: true |
Flags are read the common ways, without knowing the tool:
| Written | Fields |
|---|---|
--force | flag_force: true |
--force-with-lease=main | flag_force_with_lease: "main" |
-f | flag_f: true |
-rf | flag_r and flag_f, each true |
-delete, a one-dash word of 3 letters or more | flag_d, flag_e, flag_l, flag_t and flag_delete, since find, go and java take long options after one dash |
-n5 | flag_n: "5" |
-9 | flag_9: true |
So a policy that sends force pushes to a person needs a rule for each spelling: program eq git, arg_1 eq push and flag_force eq true, then the same with flag_f, and with flag_force_with_lease. Rules can't say "either". A push of +main forces without any flag.
The first part also carries full_command, the whole command as Claude wrote it, and the call's description. The wrappers Claude Code skips (timeout, time, nice, nohup, stdbuf, command, builtin and a bare xargs) are skipped here too, so timeout 60 npm test has program npm.
A part the plugin can't fully read gets no program, arg_ or flag_ fields: one with a variable ($HOME), a redirect to a file, a subshell, an assignment such as PATH=/tmp ls, or a shell keyword. A rule that allows by program can't match it, so it falls to your other rules or the policy's default. Write those allow rules with eq or in, because neq and nin also match a part with no program. A command the plugin can't split at all (a $(...) substitution, backticks or a heredoc) goes as one call with only Claude's input, as before.
These fields are a guardrail, not a boundary. git -C . push, bash -c "…", a script or a Makefile gets past a rule that sends git push to a person, and an option's value lands among the arguments: kubectl -n prod delete has arg_1 prod. So list what may run, such as program in [ls, cat, rg] with an allow rule, and send everything else to a person.
When Mezmo Governance allows a call, the hook doesn't approve it on Claude Code's behalf, so in auto mode Claude Code's classifier still checks it. When the classifier blocks one of these calls, the hook counts it and adds a blocked_by_auto_mode line with the classifier's reason to the audit log, and /mezmo shows the count. Mezmo itself doesn't hear about these blocks yet.
You need a Mezmo ingestion key, from Settings > API Keys in Mezmo, and a pipeline with an OpenTelemetry source. Mezmo accepts data without that source but doesn't route it anywhere.
A plugin can't set environment variables for Claude Code, so /mezmo:setup telemetry writes them into the env block of ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json). It backs the file up first, and stops to ask if the file already sends OpenTelemetry somewhere else. With every signal on, it writes:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_TRACES_EXPORTER": "otlp",
"CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://logs.mezmo.com/otel",
"OTEL_EXPORTER_OTLP_HEADERS": "apikey=<your Mezmo ingestion key>"
}
}
Prompt text, response text and tool details stay out unless you opt in during setup. You can also copy the block above in by hand. It has to go in your user settings or managed settings, because Claude Code ignores these variables in a repository's .claude/settings.json.
In Mezmo, filter on service.name = claude-code. Cost and tokens are in the claude_code.cost.usage and claude_code.token.usage metrics and in claude_code.api_request events. Claude Code monitoring lists everything else Claude Code sends.
Mezmo MCP gives Claude tools to analyze your logs (root causes, patterns, histograms, correlated timelines), look at traces and AI investigations, and inspect and change Telemetry Pipelines. You need a Mezmo service key, from Settings > API Keys in Mezmo. It's a different key from the ingestion key.
/mezmo:setup mcp checks the key against https://mcp.mezmo.com/mcp, then runs the command Mezmo documents (inside the script; do not paste a literal key into a governed Bash call):
claude mcp add --scope user --transport http mezmo https://mcp.mezmo.com/mcp --header "Authorization: Bearer <service key>"
The server is available in every project, in new sessions, with tools named mcp__mezmo__<tool>. Claude Code keeps the key as plain text in ~/.claude.json, as with any server added this way. Some tools change pipelines: they create, update, delete, pause and publish them. Claude Code asks before each MCP tool call unless you allow it. With governance on, these calls also go through Mezmo Governance under namespace mezmo, so a policy can send deletes and publishes to a person.
The plugin includes a mod that adds one label at the right of the prompt footer, such as Mezmo · Governance 14 ✓ 1 ✗ · Telemetry ✓. It only shows the parts you've set up:
| Part | Meaning |
|---|---|
Governance 14 ✓ 1 ✗ | This session's governance checks so far |
Governance ⏳ 1 waiting | A tool call is waiting for a person to decide |
Telemetry ✓ | This session sends telemetry to Mezmo |
Telemetry: start a new session to begin exporting | You set up telemetry after this session started |
Telemetry ✗ key rejected, Telemetry ✗ unreachable | The telemetry key check it runs once per session failed |
While a call waits for a person, a band above the prompt shows which call it is, how long it has waited and the policy's reason, and the time ticks up every second. When the person decides, a toast tells you. /mezmo prints the same information as text, and also works in claude -p.
The mod needs Claude Code 2.1.287 or later. Governance doesn't depend on it: the Python hook does the blocking, so it keeps working where an organization blocks user-installed mods. The hook keeps each session's counts and pending decisions in ~/.mezmo/governance/sessions/ for the mod to read. It never writes tool arguments there.
Pick "My organization" in /mezmo:setup. It writes one managed-settings file with the governance and telemetry variables, plus MEZMO_GOVERNANCE_REQUIRED=1 if you want every Bash and MCP tool call blocked on machines where governance isn't set up. The file holds your keys, so treat it like a secret. Deploy it one of three ways:
managed-settings.d/, under /Library/Application Support/ClaudeCode/ on macOS, /etc/claude-code/ on Linux and WSL, or C:\Program Files\ClaudeCode\ on Windows.Governance also needs the plugin installed on each machine. Add extraKnownMarketplaces and enabledPlugins ("mezmo@mezmo": true) to the same file to handle that.
| Problem | Fix |
|---|---|
Tool calls blocked with HTTP 401 | Mezmo Governance rejected the service account token. Run /mezmo:setup governance again |
Blocked with ENOAGENT, EAGENTINACTIVE or EPOLICYUNASSIGNED | The key works, but no active agent with a policy is linked to it |
Blocked with approval still pending | Nobody decided in time. Approve the call in Mezmo, and Claude's retry goes through |
| Every call blocked, and governance isn't set up | Your organization set MEZMO_GOVERNANCE_REQUIRED=1. Ask them for the service account token |
Every call blocked with HTTP 401 right after reinstalling the plugin | The running session still has the old plugin's settings. Run /reload-plugins or start a new session, then /mezmo:setup governance |
| Governance is off after reinstalling the plugin | Reinstalling deleted the saved service account token. Run /mezmo:setup governance again |
| Telemetry checks pass, but nothing shows up in Mezmo | Start a new session, make sure a pipeline has an OpenTelemetry source, and give metrics a minute |
| Telemetry setup reports conflicting settings | Your settings already send OpenTelemetry somewhere else. Claude Code only exports to one endpoint |
The mezmo MCP server fails to connect with 401 | Mezmo rejected the service key. Run /mezmo:setup mcp with a current key and replace the server |
To see Claude Code's own telemetry export log:
claude -p "hello" --debug-file /tmp/claude-otel.log && grep "3P telemetry" /tmp/claude-otel.log
python3 -m unittest discover -s tests
From the repo root: make test-claude-code. make test-claude-code-mod runs claude plugin test.
claude plugin test
claude plugin validate .claude-plugin/plugin.json
Validation also lists every event the mod hooks and every call it makes. To type-check the mod, use the config Claude Code generates when it validates or tests the plugin:
npx -p typescript tsc -p .claude-plugin/types/tsconfig.json
scripts/ holds the Python that runs the governance hook, writes settings, adds the MCP server and checks keys. skills/ has the slash commands, and hooks/ has the mod and the governance hook registration.
hooks/register.ts 307 lines1// The Mezmo mod: says in the prompt footer whether this session exports to
2// Mezmo, checks the ingestion key once, and answers /mezmo without a turn.
3// For Mezmo Governance it shows, above the prompt, each tool call waiting for a
4// person to approve it, toasts that person's decision, and counts this
5// session's checks in the footer.
6//
7// Claude Code reads its OTel settings once, at startup, but copies a changed
8// settings `env` into its own environment mid-session. So the environment is
9// read once, at session.start, and kept; the user settings file is read from
10// disk each time, at the path scripts/claude_code_otel.py writes.
11//
12// The governance check is scripts/governance_hook.py, a PreToolUse command hook
13// that runs inside this mod's tool.call `next()`. It writes the session's status
14// file, which this mod reads; enforcement never depends on the mod.
15
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { MezmoCheck, MezmoFacts } from './state'
20import {
21 bandLines,
22 decisionToast,
23 governanceLabel,
24 governanceStatePath,
25 governanceText,
26 livePending,
27 NO_GOVERNANCE_TEXT,
28 parseGovernanceState,
29 summarizeCall,
30} from './governance'
31import type { GovernanceState, InFlightCall } from './governance'
32import {
33 CHECK_URL,
34 describe,
35 factsOf,
36 footerLabel,
37 isExporting,
38 keyOf,
39 mask,
40 otelEnvOf,
41 outcomeOf,
42 parseSettings,
43 reasonOf,
44 settingsPath,
45} from './status'
46
47const sessionFacts = atom({ plugin: 'mezmo', key: 'session' } as const, null)
48const settingsFacts = atom({ plugin: 'mezmo', key: 'settings' } as const, null)
49const lastCheck = atom({ plugin: 'mezmo', key: 'check' } as const, null)
50
51// $.state keeps the values above across a reload of this module; these copies
52// keep them across /clear, /resume and /branch, which reset $.state.
53let sessionCopy: MezmoFacts | null = null
54let checkCopy: MezmoCheck | null = null
55// The key this process exports with. It never leaves the module.
56let sessionKey: string | undefined
57const running = new Map<string, Promise<MezmoCheck>>()
58
59// Governance: this session's status file as governance_hook.py last wrote it, and
60// the tool calls in flight, which only this mod sees. Arguments stay in memory.
61let governancePath: string | undefined
62let governance: GovernanceState | undefined
63const inFlight = new Map<number, InFlightCall>()
64let nextCallId = 0
65let ticker: { cancel(): void } | undefined
66// Decisions already toasted, or already made when the session started.
67const seenDecisions = new Set<string>()
68
69type Refresh = 'toast' | 'silent' | 'baseline'
70
71// Read the status file. `toast` announces a person's decision not seen before;
72// `baseline` marks the current one as seen, for a session that resumes.
73async function refreshGovernance($: EngineInterface, mode: Refresh): Promise<void> {
74 if (!governancePath) return
75 let text: unknown
76 try {
77 text = await $.fs.read(governancePath)
78 } catch {
79 return // no file yet: governance is off, or hasn't checked a call in this session
80 }
81 const state = typeof text === 'string' ? parseGovernanceState(text) : undefined
82 if (!state) return // half-written: keep the last reading
83 const last = state.last
84 if (last?.decision_id && !seenDecisions.has(last.decision_id) && mode !== 'silent') {
85 seenDecisions.add(last.decision_id)
86 if (mode === 'toast') {
87 const call = [...inFlight.values()].find(c => c.tool === last.tool_name)
88 const message = decisionToast(last, call?.summary)
89 if (message) $.ui.toast(message, { timeoutMs: 8000 })
90 }
91 }
92 governance = state
93 $.ui.invalidate('ui.render')
94}
95
96async function locateGovernance($: EngineInterface): Promise<void> {
97 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
98 let sessionId: string | undefined
99 try {
100 sessionId = await $.session.id()
101 } catch {
102 sessionId = undefined
103 }
104 governancePath = governanceStatePath(home, sessionId)
105 governance = undefined
106 seenDecisions.clear()
107 await refreshGovernance($, 'baseline')
108}
109
110// While a call has run for over a second, read the status file and redraw once a second.
111function startTicking($: EngineInterface): void {
112 if (ticker) return
113 ticker = $.clock.every(1000, async () => {
114 await refreshGovernance($, 'toast')
115 $.ui.invalidate('ui.render')
116 })
117}
118
119function stopTicking(): void {
120 ticker?.cancel()
121 ticker = undefined
122}
123
124// The state with stale pending entries dropped, and the time it was taken at. The clock is
125// read only once governance has checked a call in this session.
126async function liveGovernance($: EngineInterface): Promise<[GovernanceState | undefined, number]> {
127 if (!governance) return [undefined, 0]
128 const now = await $.clock.now()
129 return [livePending(governance, inFlight.size > 0, now), now]
130}
131
132async function readSessionEnv($: EngineInterface) {
133 return {
134 enable: await $.env.get('CLAUDE_CODE_ENABLE_TELEMETRY'),
135 endpoint: await $.env.get('OTEL_EXPORTER_OTLP_ENDPOINT'),
136 headers: await $.env.get('OTEL_EXPORTER_OTLP_HEADERS'),
137 }
138}
139
140async function runCheck($: EngineInterface, apikey: string): Promise<MezmoCheck> {
141 const key = mask(apikey)
142 let result: MezmoCheck
143 try {
144 const response = await $.http.fetch(CHECK_URL, {
145 method: 'POST',
146 headers: { apikey, 'content-type': 'application/json' },
147 body: '{}',
148 })
149 result = { key, outcome: outcomeOf(response.status), status: response.status }
150 } catch (error) {
151 result = { key, outcome: 'unreachable', reason: reasonOf(error, apikey) }
152 }
153 checkCopy = result
154 running.delete(key)
155 await update($, lastCheck, () => result).catch(() => undefined)
156 return result
157}
158
159// One check per key per session: a key already checked, or being checked, isn't sent again.
160function ensureCheck($: EngineInterface, apikey: string): Promise<MezmoCheck> | undefined {
161 const key = mask(apikey)
162 const pending = running.get(key)
163 if (pending) return pending
164 if (checkCopy?.key === key) return undefined
165 checkCopy = { key, outcome: 'running' }
166 const started = runCheck($, apikey)
167 running.set(key, started)
168 return started
169}
170
171// The user settings file as it is on disk now: {} when there is none, undefined when it
172// isn't a JSON object. It is read from disk because ConfigChange arrives before Claude
173// Code's own copy of the settings changes.
174async function readUserSettings($: EngineInterface): Promise<Record<string, unknown> | undefined> {
175 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
176 const path = settingsPath(await $.env.get('CLAUDE_CONFIG_DIR'), home)
177 if (path === undefined) return {}
178 let text: string
179 try {
180 text = await $.fs.read(path)
181 } catch {
182 return {}
183 }
184 return parseSettings(text)
185}
186
187// Read the user settings file, note what it says, and start the check if one is due.
188// The check comes back unawaited, so only /mezmo waits on the network.
189async function refresh($: EngineInterface): Promise<{ pending?: Promise<MezmoCheck> }> {
190 const settings = await readUserSettings($)
191 // A file that isn't JSON (say, half-written by an editor) leaves the last reading in place.
192 if (settings === undefined && (await read($, settingsFacts)) !== null) return {}
193 const env = otelEnvOf(settings)
194 const facts = factsOf(env)
195 await update($, settingsFacts, () => facts)
196 if (!sessionCopy) return {}
197 const apikey = isExporting(sessionCopy) ? sessionKey : facts.isMezmo ? keyOf(env) : undefined
198 const pending = apikey === undefined ? undefined : ensureCheck($, apikey)
199 if (pending !== undefined) await update($, lastCheck, () => checkCopy)
200 return { pending }
201}
202
203async function start($: EngineInterface) {
204 const env = await readSessionEnv($)
205 // After a reload, keep what the process started with: the environment may have changed since.
206 const facts = (await read($, sessionFacts)) ?? sessionCopy ?? factsOf(env)
207 const apikey = keyOf(env)
208 sessionKey = apikey !== undefined && mask(apikey) === facts.key ? apikey : undefined
209 sessionCopy = facts
210 await update($, sessionFacts, () => facts)
211 const kept = (await read($, lastCheck)) ?? checkCopy
212 // A check the reload cut short runs again.
213 checkCopy = kept?.outcome === 'running' ? null : kept
214 await refresh($)
215 await locateGovernance($)
216}
217
218export const register: Register = on => {
219 on('session.start', async ($, e, next) => {
220 await start($)
221 try {
222 await $.command.register({
223 name: 'mezmo',
224 description: 'Show whether this session sends telemetry to Mezmo',
225 immediate: true,
226 })
227 } catch {
228 // A taken name leaves the footer indicator working.
229 }
230 return next(e)
231 })
232
233 // /mezmo:setup and /mezmo:remove write the user settings file: show the change now.
234 on('classic.ConfigChange', { source: 'user_settings' }, async ($, e, next) => {
235 await refresh($)
236 return next(e)
237 }).catch(($, e, next) => next(e))
238
239 // /clear, /resume and /branch reset $.state: put back what this process started with.
240 // They also start a new session id, so governance moves to that session's file.
241 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
242 await update($, sessionFacts, () => sessionCopy)
243 await update($, lastCheck, () => checkCopy)
244 await refresh($)
245 await locateGovernance($)
246 return next(e)
247 })
248
249 // Bash and MCP tool calls run the governance hook inside next(). Track each call, so the
250 // band can say which one is waiting, and read the status file once it's done.
251 on('tool.call', async ($, e, next) => {
252 if (!governancePath) return next(e)
253 const id = ++nextCallId
254 inFlight.set(id, { tool: e.tool, summary: summarizeCall(e.tool, e as unknown as Record<string, unknown>) })
255 // Most calls finish within a second; only a slower one starts the once-a-second reads.
256 const slow = $.clock.after(1000, () => startTicking($))
257 try {
258 return await next(e)
259 } finally {
260 slow.cancel()
261 await refreshGovernance($, 'toast').catch(() => undefined)
262 inFlight.delete(id)
263 if (inFlight.size === 0) stopTicking()
264 $.ui.invalidate('ui.render')
265 }
266 })
267
268 on('command.run', { command: 'mezmo' }, async $ => {
269 const { pending } = await refresh($)
270 if (pending) await pending
271 await refreshGovernance($, 'silent')
272 const settings = await read($, settingsFacts)
273 const telemetry =
274 !sessionCopy || !settings ? "Mezmo telemetry: status isn't known yet." : describe(sessionCopy, settings, checkCopy).text
275 const gov = governanceText(...(await liveGovernance($))) ?? NO_GOVERNANCE_TEXT
276 return { text: `${gov}\n\n${telemetry}` }
277 })
278
279 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
280 // One label that names Mezmo: governance first, then telemetry.
281 const [state] = await liveGovernance($)
282 const session = await read($, sessionFacts)
283 const settings = await read($, settingsFacts)
284 const telemetry = session && settings ? describe(session, settings, await read($, lastCheck)).label : undefined
285 const label = footerLabel([governanceLabel(state), telemetry])
286 return label ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } }) : next(e)
287 })
288
289 // The band above the prompt, while a call waits for a person. Other mods share it.
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 const [state, now] = await liveGovernance($)
292 const lines = bandLines(state, [...inFlight.values()], now)
293 if (lines.length === 0) return next(e)
294 const { Box, Text } = $.ui.resolve(e)
295 const ours = Box({
296 flexDirection: 'column',
297 children: lines.map(line =>
298 line.tone === 'waiting'
299 ? Text({ children: [line.text], color: 'yellow', wrap: 'truncate-end' })
300 : Text({ children: [line.text], dimColor: true, wrap: 'truncate-end' }),
301 ),
302 })
303 const theirs = await next(e)
304 return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
305 })
306}
307hooks/governance.ts 231 lines1// What the mod shows about Mezmo Governance, as plain functions shared by the hooks and the tests.
2//
3// The governance check itself is scripts/governance_hook.py, a PreToolUse command hook. It
4// writes each session's counts, pending decisions and last result to
5// ~/.mezmo/governance/sessions/<session id>.json, never the tool's arguments. The mod reads
6// that file and adds what only it sees: which call is waiting, from its own tool.call hook.
7
8export type GovernanceCounts = {
9 allowed: number
10 denied: number
11 expired: number
12 errors: number
13 /** Calls auto mode's classifier blocked after Mezmo Governance allowed them. */
14 auto_mode_blocked?: number
15}
16
17export type PendingDecision = {
18 tool: string
19 tool_name: string
20 reason: string | null
21 since_ms: number
22 deadline_ms: number
23}
24
25export type LastDecision = {
26 ts: string
27 decision_id: string | null
28 tool: string
29 tool_name: string
30 outcome: 'allowed' | 'denied' | 'expired' | 'error'
31 detail: string | null
32 by_person: boolean
33}
34
35export type GovernanceState = {
36 counts: GovernanceCounts
37 pending: Record<string, PendingDecision>
38 last?: LastDecision
39 host?: string | null
40}
41
42/** A tool call the mod has seen start and not finish. */
43export type InFlightCall = {
44 tool: string
45 summary: string
46}
47
48export type BandLine = {
49 text: string
50 tone: 'waiting' | 'note'
51}
52
53/** Where governance_hook.py writes this session's status. Keep in step with its session_state_path. */
54export function governanceStatePath(home: string | undefined, sessionId: string | undefined): string | undefined {
55 if (!home || !sessionId) return undefined
56 const safe = sessionId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 128)
57 return `${home.replace(/\/+$/, '')}/.mezmo/governance/sessions/${safe}.json`
58}
59
60const count = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
61
62/** The status file's text as a state, or undefined when it isn't one. */
63export function parseGovernanceState(text: string): GovernanceState | undefined {
64 let value: unknown
65 try {
66 value = JSON.parse(text)
67 } catch {
68 return undefined
69 }
70 if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
71 const raw = value as Record<string, unknown>
72 const counts = (raw.counts && typeof raw.counts === 'object' ? raw.counts : {}) as Record<string, unknown>
73 const pending: Record<string, PendingDecision> = {}
74 if (raw.pending && typeof raw.pending === 'object') {
75 for (const [id, entry] of Object.entries(raw.pending as Record<string, unknown>)) {
76 if (!entry || typeof entry !== 'object') continue
77 const p = entry as Record<string, unknown>
78 pending[id] = {
79 tool: String(p.tool ?? ''),
80 tool_name: String(p.tool_name ?? ''),
81 reason: typeof p.reason === 'string' ? p.reason : null,
82 since_ms: count(p.since_ms),
83 deadline_ms: count(p.deadline_ms),
84 }
85 }
86 }
87 const last = raw.last && typeof raw.last === 'object' ? (raw.last as LastDecision) : undefined
88 return {
89 counts: {
90 allowed: count(counts.allowed),
91 denied: count(counts.denied),
92 expired: count(counts.expired),
93 errors: count(counts.errors),
94 auto_mode_blocked: count(counts.auto_mode_blocked),
95 },
96 pending,
97 last,
98 host: typeof raw.host === 'string' ? raw.host : null,
99 }
100}
101
102function clip(text: string, max: number): string {
103 const line = text.split('\n')[0]!.trim()
104 return line.length > max ? `${line.slice(0, max - 1)}…` : line
105}
106
107/** A few words that say what a tool call does, from its arguments. Shown only on screen. */
108export function summarizeCall(tool: string, input: Record<string, unknown>): string {
109 const field = (...names: string[]) => {
110 for (const name of names) {
111 const value = input[name]
112 if (typeof value === 'string' && value.trim()) return value
113 }
114 return undefined
115 }
116 const text = field('command', 'file_path', 'notebook_path', 'url', 'pattern', 'query', 'description', 'path')
117 return text ? clip(text, 60) : ''
118}
119
120/** A tool name as people read it: MCP tools without their mcp__ prefix. */
121export function toolLabel(toolName: string): string {
122 const parts = toolName.split('__')
123 return parts.length === 3 && parts[0] === 'mcp' ? `${parts[1]}/${parts[2]}` : toolName
124}
125
126/** 72 seconds as 1:12. */
127export function formatDuration(ms: number): string {
128 const seconds = Math.max(0, Math.floor(ms / 1000))
129 const minutes = Math.floor(seconds / 60)
130 return `${minutes}:${String(seconds % 60).padStart(2, '0')}`
131}
132
133function describeCall(entry: PendingDecision, inFlight: readonly InFlightCall[]): string {
134 const call = inFlight.find(c => c.tool === entry.tool_name && c.summary)
135 const name = toolLabel(entry.tool_name || entry.tool)
136 return call ? `${name}: ${call.summary}` : name
137}
138
139/**
140 * The state as it stands while calls are in flight: a decision can only be pending while the
141 * hook that waits for it runs inside a tool call, so without one, or past its deadline, an
142 * entry is left over from a hook that was stopped.
143 */
144export function livePending(state: GovernanceState | undefined, hasInFlight: boolean, nowMs: number): GovernanceState | undefined {
145 if (!state) return undefined
146 const pending: Record<string, PendingDecision> = {}
147 if (hasInFlight) {
148 for (const [id, entry] of Object.entries(state.pending)) {
149 if (entry.deadline_ms > nowMs) pending[id] = entry
150 }
151 }
152 return { ...state, pending }
153}
154
155/** The band above the prompt: one line per call waiting for a person, then its reason. */
156export function bandLines(state: GovernanceState | undefined, inFlight: readonly InFlightCall[], nowMs: number): BandLine[] {
157 if (!state) return []
158 const lines: BandLine[] = []
159 const entries = Object.values(state.pending).sort((a, b) => a.since_ms - b.since_ms)
160 for (const entry of entries) {
161 const waited = formatDuration(nowMs - entry.since_ms)
162 const left = formatDuration(entry.deadline_ms - nowMs)
163 lines.push({
164 text: `⏳ Mezmo Governance · waiting for approval · ${describeCall(entry, inFlight)} · ${waited}, blocked in ${left}`,
165 tone: 'waiting',
166 })
167 if (entry.reason) lines.push({ text: ` ${entry.reason}`, tone: 'note' })
168 }
169 return lines
170}
171
172export function blockedCount(counts: GovernanceCounts): number {
173 return counts.denied + counts.expired + counts.errors
174}
175
176/** The footer label: calls waiting, or this session's counts. Undefined before the first check. */
177export function governanceLabel(state: GovernanceState | undefined): string | undefined {
178 if (!state) return undefined
179 const waiting = Object.keys(state.pending).length
180 if (waiting > 0) return `Governance ⏳ ${waiting} waiting`
181 const allowed = state.counts.allowed
182 const blocked = blockedCount(state.counts)
183 if (allowed + blocked === 0) return undefined
184 return blocked > 0 ? `Governance ${allowed} ✓ ${blocked} ✗` : `Governance ${allowed} ✓`
185}
186
187/** The toast for a decision a person made, or for one nobody made in time. */
188export function decisionToast(last: LastDecision, summary?: string): string | undefined {
189 if (!last.by_person) return undefined
190 const call = `${toolLabel(last.tool_name || last.tool)}${summary ? `: ${summary}` : ''}`
191 const reason = last.detail ? ` (${clip(last.detail, 80)})` : ''
192 if (last.outcome === 'allowed') return `✓ Approved in Mezmo Governance: ${call}${reason}`
193 if (last.outcome === 'denied') return `✗ Denied in Mezmo Governance: ${call}${reason}`
194 if (last.outcome === 'expired') return `✗ Nobody decided in time, so ${call} was blocked`
195 return undefined
196}
197
198/**
199 * What /mezmo says before governance has checked a call in this session. The access key is in
200 * the keychain, out of the mod's reach, so it can't tell "off" from "on but not used yet".
201 */
202export const NO_GOVERNANCE_TEXT =
203 "Mezmo Governance hasn't checked a tool call in this session. If it isn't set up, run /mezmo:setup governance."
204
205/** What /mezmo says about governance in this session. */
206export function governanceText(state: GovernanceState | undefined, nowMs: number): string | undefined {
207 if (!state) return undefined
208 const c = state.counts
209 const lines = [
210 `Mezmo Governance${state.host ? ` (${state.host})` : ''} in this session: ${c.allowed} allowed, ${c.denied} denied, ` +
211 `${c.expired} not decided in time, ${c.errors} errors.`,
212 ]
213 const blockedByAutoMode = c.auto_mode_blocked ?? 0
214 if (blockedByAutoMode > 0) {
215 lines.push(`Auto mode blocked ${blockedByAutoMode} call${blockedByAutoMode === 1 ? '' : 's'} that Mezmo Governance allowed.`)
216 }
217 for (const entry of Object.values(state.pending)) {
218 lines.push(
219 `Waiting for a person: ${toolLabel(entry.tool_name || entry.tool)} for ${formatDuration(nowMs - entry.since_ms)}, ` +
220 `blocked in ${formatDuration(entry.deadline_ms - nowMs)}.`,
221 )
222 }
223 const last = state.last
224 if (last) {
225 const who = last.by_person ? ' by a person' : ''
226 const detail = last.detail ? `: ${clip(last.detail, 200)}` : ''
227 lines.push(`Last check: ${toolLabel(last.tool_name || last.tool)} ${last.outcome}${who}${detail}`)
228 }
229 return lines.join('\n')
230}
231hooks/status.ts 209 lines1// The mod's status logic as plain functions, shared by the hooks and the tests.
2// It mirrors scripts/mezmo_common.py and `claude_code_otel.py status`.
3
4import type { MezmoCheck, MezmoFacts } from './state'
5
6export const MEZMO_OTEL_ENDPOINT = 'https://logs.mezmo.com/otel'
7// Mezmo answers an empty JSON object here with 207 and publishes nothing.
8export const CHECK_URL = `${MEZMO_OTEL_ENDPOINT}/v1/logs`
9
10/** The variables that decide where Claude Code exports, from the process or a settings file's `env`. */
11export type OtelEnv = {
12 enable?: string
13 endpoint?: string
14 headers?: string
15}
16
17export type View = {
18 /** The telemetry part of the footer label, or undefined to show nothing. */
19 label?: string
20 /** What /mezmo prints. */
21 text: string
22}
23
24export function isMezmoEndpoint(value?: string | null): boolean {
25 return !!value && value.trim().replace(/\/+$/, '').toLowerCase() === MEZMO_OTEL_ENDPOINT
26}
27
28function decode(value: string): string {
29 try {
30 return decodeURIComponent(value)
31 } catch {
32 return value
33 }
34}
35
36/** Parse OTEL_EXPORTER_OTLP_HEADERS ("k1=v1,k2=v2", values percent-encoded). */
37export function parseOtlpHeaders(value?: string | null): Record<string, string> {
38 const headers: Record<string, string> = {}
39 for (const item of (value ?? '').split(',')) {
40 const at = item.indexOf('=')
41 const name = item.slice(0, at).trim()
42 if (at < 0 || !name) continue
43 headers[name] = decode(item.slice(at + 1).trim())
44 }
45 return headers
46}
47
48/** Show only the first and last four characters of a secret. */
49export function mask(secret?: string | null): string {
50 if (!secret) return '(not set)'
51 if (secret.length < 12) return '****'
52 return `${secret.slice(0, 4)}…${secret.slice(-4)}`
53}
54
55/** The ingestion key the env sends, when it exports to Mezmo. */
56export function keyOf(env: OtelEnv): string | undefined {
57 if (!isMezmoEndpoint(env.endpoint)) return undefined
58 return parseOtlpHeaders(env.headers).apikey || undefined
59}
60
61export function factsOf(env: OtelEnv): MezmoFacts {
62 const key = keyOf(env)
63 return {
64 isMezmo: isMezmoEndpoint(env.endpoint),
65 isEnabled: env.enable === '1' || env.enable === 'true',
66 hasKey: !!key,
67 key: mask(key),
68 }
69}
70
71/** The user settings file the scripts write: $CLAUDE_CONFIG_DIR/settings.json, else ~/.claude/settings.json. */
72export function settingsPath(configDir?: string, home?: string): string | undefined {
73 if (configDir) {
74 const dir = home && (configDir === '~' || configDir.startsWith('~/')) ? home + configDir.slice(1) : configDir
75 return `${dir.replace(/\/+$/, '')}/settings.json`
76 }
77 return home ? `${home.replace(/\/+$/, '')}/.claude/settings.json` : undefined
78}
79
80/** A settings file's text as an object: {} when blank, undefined when it isn't a JSON object. */
81export function parseSettings(text: string): Record<string, unknown> | undefined {
82 if (!text.trim()) return {}
83 try {
84 const value: unknown = JSON.parse(text)
85 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
86 } catch {
87 return undefined
88 }
89}
90
91/** The OTel variables in the `env` block of a settings object. */
92export function otelEnvOf(settings: unknown): OtelEnv {
93 const env = (settings as { env?: unknown } | null)?.env
94 if (!env || typeof env !== 'object') return {}
95 const text = (name: string) => {
96 const value = (env as Record<string, unknown>)[name]
97 return typeof value === 'string' ? value : undefined
98 }
99 return {
100 enable: text('CLAUDE_CODE_ENABLE_TELEMETRY'),
101 endpoint: text('OTEL_EXPORTER_OTLP_ENDPOINT'),
102 headers: text('OTEL_EXPORTER_OTLP_HEADERS'),
103 }
104}
105
106export function isExporting(facts: MezmoFacts): boolean {
107 return facts.isMezmo && facts.isEnabled
108}
109
110export function outcomeOf(status: number): MezmoCheck['outcome'] {
111 if (status === 207) return 'ok'
112 if (status === 401 || status === 403) return 'rejected'
113 return 'unexpected'
114}
115
116function checkLine(check: MezmoCheck): string | undefined {
117 switch (check.outcome) {
118 case 'ok':
119 return `Connection check: Mezmo accepted the key (HTTP ${check.status}). Nothing was published.`
120 case 'rejected':
121 return `Connection check: Mezmo rejected the ingestion key (HTTP ${check.status}). Check it under Settings > API Keys in Mezmo, then run /mezmo:setup.`
122 case 'unexpected':
123 return (check.status ?? 0) >= 500
124 ? `Connection check: Mezmo returned a server error (HTTP ${check.status}). Try again in a few minutes.`
125 : `Connection check: unexpected response from Mezmo (HTTP ${check.status}).`
126 case 'unreachable':
127 return `Connection check: couldn't reach Mezmo${check.reason ? ` (${check.reason})` : ''}.`
128 case 'running':
129 return 'Connection check: running…'
130 }
131}
132
133function failureLabel(check: MezmoCheck): string | undefined {
134 switch (check.outcome) {
135 case 'rejected':
136 return 'Telemetry ✗ key rejected'
137 case 'unreachable':
138 return 'Telemetry ✗ unreachable'
139 case 'unexpected':
140 return `Telemetry ✗ HTTP ${check.status}`
141 }
142 return undefined
143}
144
145/**
146 * Compare what this session started with against what the settings file says
147 * now, together with the connection check for the key that matters: the one
148 * this session exports with, else the one the next session will use.
149 */
150export function describe(session: MezmoFacts, settings: MezmoFacts, check: MezmoCheck | null): View {
151 const exporting = isExporting(session)
152 if (!exporting && !settings.isMezmo) {
153 return {
154 text: "Mezmo telemetry is off. Run /mezmo:setup telemetry to send Claude Code's metrics, events and traces to Mezmo.",
155 }
156 }
157
158 const lines: string[] = []
159 let label: string
160 if (exporting) {
161 if (session.hasKey) {
162 label = 'Telemetry ✓'
163 lines.push(`This session exports telemetry to ${MEZMO_OTEL_ENDPOINT} (ingestion key ${session.key}).`)
164 } else {
165 label = 'Telemetry ✗ no ingestion key'
166 lines.push(
167 `This session exports to ${MEZMO_OTEL_ENDPOINT}, but OTEL_EXPORTER_OTLP_HEADERS has no apikey header, so Mezmo rejects the data. Run /mezmo:setup, then start a new session.`,
168 )
169 }
170 if (!settings.isMezmo) {
171 lines.push("Your user settings no longer point at Mezmo, so the next session won't export.")
172 } else if (settings.key !== session.key) {
173 lines.push(`Your user settings now hold ingestion key ${settings.key}; a new session will use it.`)
174 }
175 } else if (settings.isEnabled && settings.hasKey) {
176 label = 'Telemetry: start a new session to begin exporting'
177 lines.push(
178 `Your user settings send telemetry to Mezmo (ingestion key ${settings.key}), but this session started without them. Start a new session to begin exporting.`,
179 )
180 } else {
181 label = 'Telemetry ✗ setup incomplete'
182 const missing = [
183 settings.isEnabled ? undefined : 'CLAUDE_CODE_ENABLE_TELEMETRY is not 1',
184 settings.hasKey ? undefined : 'OTEL_EXPORTER_OTLP_HEADERS has no apikey header',
185 ].filter(Boolean)
186 lines.push(`Your user settings point at Mezmo, but ${missing.join(' and ')}. Run /mezmo:setup again.`)
187 }
188
189 const key = exporting ? session.key : settings.key
190 const result = check?.key === key ? check : null
191 if (result) {
192 label = failureLabel(result) ?? label
193 lines.push(checkLine(result) ?? '')
194 }
195 return { label, text: lines.filter(Boolean).join('\n') }
196}
197
198/** An error's message, short, with any copy of the key masked. */
199export function reasonOf(error: unknown, key: string): string {
200 const message = error instanceof Error ? error.message : String(error)
201 return message.split(key).join(mask(key)).slice(0, 160)
202}
203
204/** The one footer label: Mezmo, then each part that has something to say. */
205export function footerLabel(parts: readonly (string | undefined)[]): string | undefined {
206 const shown = parts.filter((part): part is string => !!part)
207 return shown.length ? ['Mezmo', ...shown].join(' · ') : undefined
208}
209hooks/state.d.ts 37 lines1// The values the status mod keeps in $.state. The manifest names this file as the mod's
2// types contract, which `claude plugin validate` checks the mod's state against.
3
4/**
5 * What one source says about the Mezmo export: this Claude Code process's
6 * environment as it started, or the user settings file as it is now. The
7 * ingestion key appears only masked (first4…last4).
8 */
9export type MezmoFacts = {
10 /** OTEL_EXPORTER_OTLP_ENDPOINT is https://logs.mezmo.com/otel. */
11 isMezmo: boolean
12 /** CLAUDE_CODE_ENABLE_TELEMETRY is 1 or true. */
13 isEnabled: boolean
14 /** OTEL_EXPORTER_OTLP_HEADERS carries an apikey header. */
15 hasKey: boolean
16 /** The apikey, masked. */
17 key: string
18}
19
20/** The connection check for one ingestion key (by its masked form). */
21export type MezmoCheck = {
22 key: string
23 outcome: 'running' | 'ok' | 'rejected' | 'unexpected' | 'unreachable'
24 status?: number
25 reason?: string
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 mezmo: {
31 session: MezmoFacts | null
32 settings: MezmoFacts | null
33 check: MezmoCheck | null
34 }
35 }
36}
37