Refuses production deploys and DDL from a session (MCP deploy_edge_function, wrangler/supabase CLI deploys, DDL via execute_sql); apply_migration asks. GitHub…

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.
Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.
Install (what the cloud environment setup script runs):
claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope userhooks/register.ts 47 lines1import type { Register } from 'claude-code'
2
3import type { HookOut } from './py'
4import { blockReason, contextOf, decisionOf, parseOut, resultText, runArgs, toastText, toolPayload } from './py'
5
6// Why (LCH-528; LC-421 "GitHub is production", LC-428 migrations): a production deploy or
7// DDL from a session skips the PR, the CI gate and the audit row. Denies MCP
8// deploy_edge_function, wrangler / supabase CLI deploys and DDL through execute_sql;
9// apply_migration asks. The rule's logic and tests live in ./prod-deploy-guard.py.
10const TOOLS = /^(Bash|mcp__.*__(deploy_edge_function|apply_migration|execute_sql))$/
11
12// --- $ helpers. They live in this file because the plugin validator follows $ only into functions
13// declared in the hook's own file (claude-code 2.1.292); ./py.ts stays pure.
14async function runCheck($: any, script: string, payload: unknown): Promise<HookOut | undefined> {
15 try {
16 const { argv, init } = runArgs($.plugin.root, script, payload)
17 return parseOut(await $.process.run(argv, init))
18 } catch {
19 return undefined
20 }
21}
22
23function toast($: any, text: string): void {
24 try { $.ui.toast(toastText(text)) } catch { /* a toast never changes a verdict */ }
25}
26
27// Deny and ask are toasted for the operator; a warning is not.
28function preToolUse($: any, v: HookOut | undefined) {
29 const d = decisionOf(v)
30 if (d && 'deny' in d) toast($, d.deny)
31 if (d && 'ask' in d) toast($, d.ask)
32 return d
33}
34
35function blockOf($: any, v: HookOut | undefined): string | undefined {
36 const r = blockReason(v)
37 if (r) toast($, r)
38 return r
39}
40
41export const register: Register = on => {
42 on('classic.PreToolUse', { tool: TOOLS }, async ($, e, next) =>
43 preToolUse($, await runCheck($, 'prod-deploy-guard.py', toolPayload(e as any))) ?? next(e),
44 // Fail open: a bug in the mod must never stop a tool call (runCheck already fails open).
45 ).catch(($, e, next) => next(e))
46}
47hooks/py.ts 87 lines1// Shared by every policy mod that wraps a Python check (LCH-528). A mod cannot import a
2// sibling, so each carries a byte-identical copy; claude-mods.yml fails if the copies drift.
3//
4// Why Python under a mod: the checks were built and mutation-tested as stdlib Python
5// (classic hook in, classic hook out). The mod is how they reach every repo's sessions;
6// the .py file stays the single place the rule's logic lives, with its own --selftest.
7//
8// PURE BY CONSTRAINT: the plugin validator (claude-code 2.1.292, the version CI pins) follows
9// `$` only into functions declared in the hook's own file. So nothing here takes `$`; each
10// register.ts keeps three small `$` helpers (runCheck, preToolUse, blockOf) over these.
11
12export type HookOut = {
13 hookSpecificOutput?: {
14 permissionDecision?: string
15 permissionDecisionReason?: string
16 additionalContext?: string
17 }
18 decision?: string
19 reason?: string
20}
21
22// tool.call envelope fields that are the engine's, not the tool's arguments.
23const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
24
25/** The classic hook payload's tool fields, rebuilt from a tool.call envelope. */
26export function toolPayload(e: Record<string, unknown>): { tool_name: string; tool_input: Record<string, unknown> } {
27 const tool_input: Record<string, unknown> = {}
28 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) tool_input[k] = v
29 return { tool_name: String(e.tool ?? ''), tool_input }
30}
31
32/** argv and init for `$.process.run`: hooks/<script> with the payload on stdin. */
33export function runArgs(root: string, script: string, payload: unknown): { argv: string[]; init: { stdin: string; timeoutMs: number } } {
34 return { argv: ['python3', '-I', `${root}/hooks/${script}`], init: { stdin: JSON.stringify(payload), timeoutMs: 10_000 } }
35}
36
37/**
38 * The check's JSON output, or undefined on a non-zero exit, silence or junk. The checks fail
39 * open (a guard that fails shut blocks every call and gets removed), as they did as hooks.
40 */
41export function parseOut(r: { exitCode?: number; stdout?: unknown }): HookOut | undefined {
42 const out = String(r?.stdout ?? '').trim()
43 if (r?.exitCode !== 0 || !out) return undefined
44 try {
45 const v = JSON.parse(out)
46 return v && typeof v === 'object' ? (v as HookOut) : undefined
47 } catch {
48 return undefined
49 }
50}
51
52/**
53 * A classic.PreToolUse answer from a check's output. A check that only warns
54 * (additionalContext, no decision) lets the call run with its note.
55 */
56export function decisionOf(v: HookOut | undefined):
57 { deny: string } | { ask: string } | { additionalContext: string[] } | undefined {
58 const h = v?.hookSpecificOutput
59 const reason = String(h?.permissionDecisionReason ?? '')
60 if (h?.permissionDecision === 'deny' && reason) return { deny: reason }
61 if (h?.permissionDecision === 'ask' && reason) return { ask: reason }
62 const note = contextOf(v)
63 return note ? { additionalContext: [note] } : undefined
64}
65
66/** The check's additionalContext, if it gave one. */
67export function contextOf(v: HookOut | undefined): string | undefined {
68 const c = v?.hookSpecificOutput?.additionalContext
69 return typeof c === 'string' && c ? c : undefined
70}
71
72/** A Stop block reason, if the check asked to block the turn's end. */
73export function blockReason(v: HookOut | undefined): string | undefined {
74 return v?.decision === 'block' && typeof v.reason === 'string' && v.reason ? v.reason : undefined
75}
76
77/** The text a tool call's result carries, for checks that read it (PostToolUse-style). */
78export function resultText(ran: { text?: unknown; result?: unknown }): string {
79 if (typeof ran.text === 'string') return ran.text
80 try { return JSON.stringify(ran.result ?? '') } catch { return '' }
81}
82
83/** A toast's text: the first line, capped. */
84export function toastText(text: string): string {
85 return text.split('\n')[0]!.slice(0, 200)
86}
87