SLOPSHOPPER

ahrena-console-resposta

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

guardnetwork
A shopper browsing a rack in a slop shop
README

Resposta à sessão (spike #58)

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.

PastaO quê
hooksO plugin: tool.check (permissão), tool.call de AskUserQuestion (pergunta), classic.PermissionRequest de ExitPlanMode (plano). O contrato puro está em protocolo.ts
nucleoA API de pedidos do núcleo, com token da sessão e token do console

Testar

claude plugin test spikes/resposta-teste
node --test spikes/resposta-teste/nucleo/

Rodar numa sessão

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"}'
Source 2 files
hooks/register.ts 146 lines
1import 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}
146
hooks/protocolo.ts 163 lines
1// 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