Spike #58: a sessão consulta o núcleo antes de abrir o diálogo de permissão, pergunta ou plano

Prova mínima da decisão 0003: antes de o Claude Code abrir um diálogo de permissão, pergunta ou plano, a sessão publica o pedido no núcleo e espera um pouco pela resposta do console. Sem resposta válida, segue o diálogo local.
| Pasta | O quê |
|---|---|
| hooks | O plugin: tool.check (permissão), tool.call de AskUserQuestion (pergunta), classic.PermissionRequest de ExitPlanMode (plano). O contrato puro está em protocolo.ts |
| nucleo | A API de pedidos do núcleo, com token da sessão e token do console |
claude plugin test spikes/resposta-teste
node --test spikes/resposta-teste/nucleo/
Só para experimentar, sem instalar no escopo do usuário:
TOKEN_SESSAO=$(openssl rand -hex 32) TOKEN_CONSOLE=$(openssl rand -hex 32) node spikes/resposta-teste/nucleo/pedidos.mjs
claude --plugin-dir spikes/resposta-teste
Na sessão, ponha o TOKEN_SESSAO no campo token do plugin e um valor em esperaMs (em /config). Com esperaMs em 0, o padrão, nada é consultado. O console responde com o TOKEN_CONSOLE:
curl -s -X POST http://127.0.0.1:4721/api/pedidos/<id>/resposta -H "authorization: Bearer $TOKEN_CONSOLE" -d '{"decisao":"permitir","quem":"ana","canal":"painel"}'hooks/register.ts 146 lines1import type { EngineInterface, Register } from 'claude-code'
2import { conferir, esperas, montarPedido, sempreLocal, type Pedido, type Resposta, type Tipo } from './protocolo.ts'
3
4// Spike #58: antes de o Claude Code abrir um diálogo (permissão, pergunta,
5// plano), a sessão publica o pedido no núcleo e espera um pouco pela resposta
6// do console. Sem resposta válida, segue para o diálogo local, como sem o
7// plugin. O plugin nunca decide sozinho.
8
9type Config = { nucleo: string; token: string; esperaMs: number }
10
11// Pedidos já respondidos neste carregamento: uma resposta vale uma vez.
12const usados = new Set<string>()
13
14async function publicar($: EngineInterface, cfg: Config, pedido: Pedido): Promise<boolean> {
15 try {
16 const r = await $.http.fetch(`${cfg.nucleo}/api/pedidos`, {
17 method: 'POST',
18 headers: { authorization: `Bearer ${cfg.token}`, 'content-type': 'application/json' },
19 body: JSON.stringify(pedido),
20 })
21 return r.ok
22 } catch {
23 return false
24 }
25}
26
27async function esperar($: EngineInterface, cfg: Config, pedido: Pedido): Promise<Resposta | undefined> {
28 for (const ms of esperas(cfg.esperaMs)) {
29 let r
30 try {
31 r = await $.http.fetch(`${cfg.nucleo}/api/pedidos/${pedido.id}/resposta?esperaMs=${ms}`, {
32 headers: { authorization: `Bearer ${cfg.token}` },
33 })
34 } catch {
35 return undefined
36 }
37 if (r.status === 204) continue
38 if (!r.ok) return undefined
39 let corpo: unknown
40 try {
41 corpo = JSON.parse(r.text)
42 } catch {
43 return undefined
44 }
45 const conferida = conferir(pedido, corpo, new Date(), usados)
46 if ('aceita' in conferida) return conferida.aceita
47 $.ui.log(`resposta do console recusada: ${conferida.recusa}`, { to: 'debug' })
48 return undefined
49 }
50 return undefined
51}
52
53async function encerrar($: EngineInterface, cfg: Config, pedido: Pedido, desfecho: string): Promise<void> {
54 try {
55 await $.http.fetch(`${cfg.nucleo}/api/pedidos/${pedido.id}/encerrar`, {
56 method: 'POST',
57 headers: { authorization: `Bearer ${cfg.token}`, 'content-type': 'application/json' },
58 body: JSON.stringify({ desfecho }),
59 })
60 } catch {
61 // O núcleo expira o pedido sozinho.
62 }
63}
64
65/** Publica, espera e encerra. Sem resposta aceita, `undefined`: vale o diálogo local. */
66async function consultar(
67 $: EngineInterface,
68 cfg: Config,
69 args: { tipo: Tipo; ferramenta: string; entrada: unknown; chamada: string; soNegar: boolean },
70): Promise<Resposta | undefined> {
71 if (cfg.esperaMs <= 0 || cfg.token === '') return undefined
72 const sessao = await $.session.id()
73 const pedido = await montarPedido({ ...args, sessao, esperaMs: cfg.esperaMs, agora: new Date() })
74 if (!(await publicar($, cfg, pedido))) return undefined
75 const resposta = await esperar($, cfg, pedido)
76 await encerrar($, cfg, pedido, resposta ? `respondido pelo ${resposta.canal}` : 'segue no terminal')
77 return resposta
78}
79
80// Ferramentas cujo diálogo é a própria pessoa: o `tool.check` não as libera.
81const DA_PESSOA = new Set(['AskUserQuestion', 'ExitPlanMode'])
82
83export const register: Register = (on, options) => {
84 const cfg: Config = {
85 nucleo: String(options.nucleo ?? 'http://127.0.0.1:4721'),
86 token: String(options.token ?? ''),
87 esperaMs: Number(options.esperaMs ?? 0),
88 }
89
90 // 9.3 · Permitir ou negar uma ferramenta, uma vez. Só entra quando o
91 // veredito do Claude Code já é perguntar à pessoa.
92 on('tool.check', async ($, e, next) => {
93 const veredito = await next(e)
94 if (veredito.decision !== 'ask' || e.tool_use_id === undefined || DA_PESSOA.has(e.tool)) return veredito
95 const soNegar = e.ceiling === 'ask' || sempreLocal(e.tool, e.input)
96 const r = await consultar($, cfg, {
97 tipo: 'permissao',
98 ferramenta: e.tool,
99 entrada: e.input,
100 chamada: e.tool_use_id,
101 soNegar,
102 })
103 if (r === undefined) return veredito
104 return r.decisao === 'permitir'
105 ? { decision: 'allow', reason: `Permitido uma vez por ${r.quem}, pelo ${r.canal}.` }
106 : { decision: 'deny', reason: r.motivo ? `Negado por ${r.quem}: ${r.motivo}` : `Negado por ${r.quem}, pelo ${r.canal}.` }
107 }).catch(($, e, next) => next(e))
108
109 // 9.2 · Responder perguntas. Respondida pelo console, a pergunta não abre
110 // diálogo: o hook entrega o resultado da ferramenta.
111 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
112 const r = await consultar($, cfg, {
113 tipo: 'pergunta',
114 ferramenta: e.tool,
115 entrada: { questions: e.questions },
116 chamada: e.tool_use_id ?? '',
117 soNegar: false,
118 })
119 if (r === undefined) return next(e)
120 if (r.decisao === 'negar') return { deny: `${r.quem} preferiu não responder pelo console.` }
121 return { result: { questions: e.questions, answers: r.respostas ?? {} } }
122 }).catch(($, e, next) => next(e))
123
124 // 9.2 · Aprovar ou recusar um plano. O `tool.check` não libera o
125 // ExitPlanMode, e nenhum `$` muda o modo: só o PermissionRequest clássico.
126 on('classic.PermissionRequest', async ($, e, next) => {
127 if (e.tool_name !== 'ExitPlanMode') return next(e)
128 const r = await consultar($, cfg, {
129 tipo: 'plano',
130 ferramenta: e.tool_name,
131 entrada: e.tool_input,
132 chamada: '',
133 soNegar: false,
134 })
135 if (r === undefined) return next(e)
136 if (r.decisao === 'recusar')
137 return { decision: { behavior: 'deny', message: r.motivo ? `Plano recusado por ${r.quem}: ${r.motivo}` : `Plano recusado por ${r.quem}.` } }
138 return {
139 decision: {
140 behavior: 'allow',
141 updatedPermissions: [{ type: 'setMode', mode: r.modo === 'acceptEdits' ? 'acceptEdits' : 'default', destination: 'session' }],
142 },
143 }
144 }).catch(($, e, next) => next(e))
145}
146hooks/protocolo.ts 163 lines1// O contrato entre a sessão e o núcleo para responder pelo console, sem `$`:
2// montar o pedido, conferir a resposta e a política do que nunca sai do
3// terminal. Tudo aqui é puro, para os testes chamarem direto.
4
5export type Tipo = 'permissao' | 'pergunta' | 'plano'
6
7export type Pedido = {
8 id: string
9 sessao: string
10 tipo: Tipo
11 ferramenta: string
12 entrada: unknown
13 digest: string
14 expiraEm: string
15 // O console só pode negar: a política ou o teto da organização tiram o "permitir".
16 soNegar: boolean
17}
18
19export type Decisao = 'permitir' | 'negar' | 'responder' | 'aprovar' | 'recusar'
20
21export type Resposta = {
22 pedido: string
23 digest: string
24 decisao: Decisao
25 quem: string
26 canal: 'painel' | 'artefato' | 'hub'
27 motivo?: string
28 respostas?: Record<string, string>
29 modo?: string
30}
31
32const DECISOES: Record<Tipo, readonly Decisao[]> = {
33 permissao: ['permitir', 'negar'],
34 pergunta: ['responder', 'negar'],
35 plano: ['aprovar', 'recusar'],
36}
37
38// Modos que uma aprovação de plano pelo console pode ligar. Nunca
39// `bypassPermissions`, `dontAsk` nem `auto`: esses só pela pessoa, no terminal.
40export const MODOS_DO_PLANO = ['default', 'acceptEdits'] as const
41
42const COMANDO_SEMPRE_LOCAL = [
43 /\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\b/,
44 /\bgit\s+push\b[^|;&]*(--force\b|--force-with-lease\b|\s-f\b)/,
45 /\bgit\s+(reset\s+--hard|clean\s+-[a-zA-Z]*f)/,
46 /\bsudo\b/,
47 /\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/,
48]
49
50const ARQUIVO_SEMPRE_LOCAL = [
51 /(^|\/)\.claude\/(settings[^/]*\.json|hooks\/)/,
52 /(^|\/)\.mcp\.json$/,
53 /(^|\/)\.env(\.|$)/,
54 /(^|\/)\.ssh\//,
55 /(^|\/)\.ahrena-console\//,
56]
57
58/**
59 * Chamadas que o console nunca libera: só a pessoa no terminal libera. O
60 * console ainda pode negar, que é o lado seguro.
61 */
62export function sempreLocal(ferramenta: string, entrada: unknown): boolean {
63 const campos = (entrada ?? {}) as Record<string, unknown>
64 if (ferramenta === 'Bash' && typeof campos.command === 'string')
65 return COMANDO_SEMPRE_LOCAL.some(r => r.test(campos.command as string))
66 const caminho = campos.file_path ?? campos.notebook_path
67 if (typeof caminho === 'string') return ARQUIVO_SEMPRE_LOCAL.some(r => r.test(caminho))
68 return false
69}
70
71/** JSON com as chaves em ordem, para o digest não depender da ordem. */
72export function canonico(valor: unknown): string {
73 if (Array.isArray(valor)) return `[${valor.map(canonico).join(',')}]`
74 if (valor !== null && typeof valor === 'object') {
75 const obj = valor as Record<string, unknown>
76 const chaves = Object.keys(obj).filter(k => obj[k] !== undefined).sort()
77 return `{${chaves.map(k => `${JSON.stringify(k)}:${canonico(obj[k])}`).join(',')}}`
78 }
79 return JSON.stringify(valor ?? null)
80}
81
82/**
83 * O digest liga a resposta a exatamente esta chamada: sessão, ferramenta, id
84 * da chamada e argumentos. Uma resposta dada para outra coisa não serve aqui.
85 */
86export async function digestDe(p: Pick<Pedido, 'sessao' | 'tipo' | 'ferramenta' | 'entrada'> & { chamada: string }): Promise<string> {
87 const bytes = new TextEncoder().encode(canonico(p))
88 const hash = await crypto.subtle.digest('SHA-256', bytes)
89 return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
90}
91
92export async function montarPedido(args: {
93 sessao: string
94 tipo: Tipo
95 ferramenta: string
96 entrada: unknown
97 chamada: string
98 esperaMs: number
99 agora: Date
100 soNegar: boolean
101}): Promise<Pedido> {
102 const { sessao, tipo, ferramenta, entrada, chamada, esperaMs, agora, soNegar } = args
103 return {
104 id: crypto.randomUUID(),
105 sessao,
106 tipo,
107 ferramenta,
108 entrada,
109 digest: await digestDe({ sessao, tipo, ferramenta, entrada, chamada }),
110 expiraEm: new Date(agora.getTime() + esperaMs).toISOString(),
111 soNegar,
112 }
113}
114
115/**
116 * Confere uma resposta do núcleo: do pedido certo, com o digest certo, dentro
117 * do prazo, usada uma vez só, com uma decisão que o tipo aceita, de alguém.
118 * Devolve a resposta aceita, ou o motivo da recusa.
119 */
120export function conferir(
121 pedido: Pedido,
122 bruta: unknown,
123 agora: Date,
124 usados: Set<string>,
125): { aceita: Resposta } | { recusa: string } {
126 const r = bruta as Partial<Resposta> | null
127 if (r === null || typeof r !== 'object') return { recusa: 'resposta vazia' }
128 if (r.pedido !== pedido.id) return { recusa: 'resposta de outro pedido' }
129 if (r.digest !== pedido.digest) return { recusa: 'digest não confere' }
130 if (usados.has(pedido.id)) return { recusa: 'resposta já usada' }
131 if (agora.getTime() > Date.parse(pedido.expiraEm)) return { recusa: 'pedido expirado' }
132 if (typeof r.quem !== 'string' || r.quem.trim() === '') return { recusa: 'resposta sem autor' }
133 if (r.canal !== 'painel' && r.canal !== 'artefato' && r.canal !== 'hub') return { recusa: 'canal desconhecido' }
134 if (r.decisao === undefined || !DECISOES[pedido.tipo].includes(r.decisao)) return { recusa: 'decisão inválida para o tipo' }
135 const libera = r.decisao === 'permitir' || r.decisao === 'aprovar' || r.decisao === 'responder'
136 if (libera && pedido.soNegar) return { recusa: 'só a pessoa no terminal libera esta chamada' }
137 if (r.decisao === 'aprovar' && !(MODOS_DO_PLANO as readonly string[]).includes(r.modo ?? 'default'))
138 return { recusa: `modo ${r.modo} não sai do console` }
139 if (r.decisao === 'responder' && !respostasValidas(pedido.entrada, r.respostas))
140 return { recusa: 'respostas não batem com as perguntas' }
141 usados.add(pedido.id)
142 return { aceita: r as Resposta }
143}
144
145/** Cada resposta tem de ser de uma pergunta feita, e toda pergunta respondida. */
146function respostasValidas(entrada: unknown, respostas: unknown): boolean {
147 if (respostas === null || typeof respostas !== 'object') return false
148 const perguntas = ((entrada as { questions?: { question: string }[] }).questions ?? []).map(q => q.question)
149 const dadas = Object.entries(respostas as Record<string, unknown>)
150 return (
151 perguntas.length > 0 &&
152 dadas.length === perguntas.length &&
153 dadas.every(([q, a]) => perguntas.includes(q) && typeof a === 'string' && a.trim() !== '')
154 )
155}
156
157/** Quantas esperas longas (até 25 s cada) cabem em `esperaMs`. */
158export function esperas(esperaMs: number): number[] {
159 const fatias: number[] = []
160 for (let resta = esperaMs; resta > 0; resta -= 25_000) fatias.push(Math.min(resta, 25_000))
161 return fatias
162}
163