SLOPSHOPPER

adaflow-governance

Trilha de auditoria do Claude Code no Adaflow: /adaflow login (device flow) e envio em lote so de metadados (sessao, turno, ferramenta negada)

newbandguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-06Adalink-ai/ada-mods/adaflow-governance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · adaflow-governance
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /adaflow ⎿ adaflow-governance: **Adaflow: status** ⎿ adaflow-governance: ⎿ adaflow-governance: - Sessao: nao conectado. Rode `/adaflow login`. ⎿ adaflow-governance: - Ultimo envio: nenhum ⎿ adaflow-governance: - Fila pendente: 0 evento(s) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

adaflow-governance

Mod do Claude Code que registra a atividade de desenvolvimento na trilha de auditoria do Adaflow (módulo Governança), como o usuário logado. O login é feito pelo navegador (device flow) e o mod envia apenas metadados: nunca o texto do prompt, código ou saída de ferramenta.

Requer Claude Code 2.1.291 ou mais recente (versão com Mods / function hooks).

Instalar

No terminal, em uma sessão do Claude Code:

/plugin install adaflow-governance --marketplace Adalink-ai/ada-mods

Responda y em Add marketplace? (se for a primeira vez), escolha o escopo (user vale para todas as sessões) e mantenha as opções padrão.

Login

/adaflow login
  1. O mod pede um código ao gateway e mostra o código e o link da página de aprovação (no macOS o navegador abre sozinho). Enquanto espera, uma faixa acima do prompt mostra o código, o link clicável e um botão Cancelar.
  2. No navegador, já logado na plataforma, confira se o código é o mesmo do seu terminal, veja a organização e o escopo pedido ("Registrar atividade de desenvolvimento na auditoria") e clique em Autorizar.
  3. O terminal fica livre durante a espera. Quando você aprova, aparece o aviso Adaflow: conectado (<organização>).

A sessão vale por no máximo 7 dias desde o login (teto absoluto, sem renovação). Depois disso o mod avisa e pede um novo /adaflow login.

Comandos

ComandoO que faz
/adaflow loginLogin por device flow (código + aprovação no navegador)
/adaflow logoutEncerra a sessão no servidor (/sign-out) e apaga o token e a fila local
/adaflow statusOrganização, validade restante até o teto de 7 dias, último envio, fila pendente e último erro

Configuração

CampoPadrãoEfeito
baseUrlhttps://adalink-api-gateway.onrender.comGateway da plataforma (https; http só para localhost)
enabledtrueDesligado, o mod não coleta nem envia eventos

O que é enviado

Eventos em lote (até 50) para POST /v1/audit/events/batch, com app: "claude-code", eventId único (o reenvio de um lote não duplica a trilha) e occurredAt:

Evento (action)QuandoMetadados
app.claude_code.session.startinício da sessão e logo após o loginid da sessão do Claude Code, versão do Claude Code, superfície (terminal/desktop), se é interativa
app.claude_code.turn.completefim de cada turno (inclui subagentes)id do turno, modelo, duração, motivo do fim, tokens de entrada/saída/cache
app.claude_code.tool.denieduma ferramenta foi recusada por um pluginnome da ferramenta, decisão, ids das regras do adaflow-guard quando foi ele

Organização e usuário não vão no corpo: o servidor os tira da credencial.

O que NÃO é enviado

  • Texto do prompt, resposta do modelo, código, conteúdo de arquivos, comandos ou saída de ferramentas.
  • O motivo textual de uma recusa (só os ids das regras do adaflow-guard, como token-in-client).
  • Caminhos de arquivo, diretório do projeto, nome do repositório, variáveis de ambiente.

Os metadados passam por uma lista do que é aceito (números, booleanos, textos curtos) e ficam abaixo de 4 KB por evento.

Falhas

  • Sem login: o mod não coleta nada e não faz requisição nenhuma.
  • Rede fora / gateway instável (5xx, 429): os eventos ficam numa fila local (até 500) e o envio tenta de novo com espera crescente (5 s até 5 min). Nada disso bloqueia o seu trabalho.
  • Lote recusado por validação (400/403/404): o lote é descartado, porque reenviar não muda o resultado; /adaflow status mostra o último erro.
  • Sessão revogada ou vencida (401 ao renovar o JWT): o mod apaga o token local e avisa para rodar /adaflow login.

Onde fica o token

O session token fica no $.store do mod: um JSON em texto puro no diretório de configuração do Claude Code, sem keychain. Isso só é aceitável porque a credencial é restrita:

  • só serve para registrar eventos de auditoria (escopo audit.ingest); o gateway nega qualquer outra rota com 403 scope_required;
  • com o próprio session token só dá para obter o JWT, consultar a sessão e sair (/token, /get-session, /sign-out): não troca e-mail, não lista nem revoga sessões, não aprova outros logins;
  • vale no máximo 7 dias e pode ser revogada a qualquer momento.

O JWT de 15 minutos usado nos envios fica só na memória. O mod nunca usa o app token (x-ada-token): toda requisição autenticada vai com Authorization: Bearer.

Como revogar

  • No terminal: /adaflow logout (encerra no servidor e apaga o arquivo local).
  • Pela plataforma: na lista de sessões do seu usuário, a sessão aparece rotulada como Claude Code; revogue-a. O próximo envio recebe 401 e o mod apaga o token local.
  • Perdeu a máquina? Revogue pela plataforma: o token local para de funcionar na hora.

Limites

  • Ordem dos plugins: tool.denied só é visto quando a recusa vem de um plugin que roda abaixo deste na cadeia de tool.call (como o adaflow-guard). Recusas do prompt de permissão do próprio Claude Code não chegam como recusa de plugin e não são registradas.
  • Login pendente e recarga: se o mod recarregar durante a espera, o login pendente é perdido; rode /adaflow login de novo.
  • Catálogo de eventos ainda a fechar com Governança (PRD, Q6).

Desenvolvimento

claude plugin validate .
claude plugin test .
claude --plugin-dir .

Os testes rodam inteiramente contra mocks (rede, relógio e store), sem backend.

Source 3 files
hooks/register.tsx 125 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { FLUSH_EVERY_MS, Governance, guardRuleIds } from './governance'
5
6const login = atom({ plugin: 'adaflow-governance', key: 'login' } as const, null)
7
8const USAGE = 'Uso: `/adaflow login` | `/adaflow logout` | `/adaflow status`'
9
10export const register: Register = (on, options) => {
11  const cfg = { baseUrl: String(options.baseUrl ?? ''), enabled: options.enabled !== false }
12  let gov: Governance | undefined
13
14  on('session.start', async ($, e, next) => {
15    const started = await next(e)
16    // `$` e o mesmo objeto em toda invocacao: a Io guarda chamadas inline a ele para os timers.
17    gov ??= new Governance(
18      {
19        fetch: (url, init) => $.http.fetch(url, init),
20        now: () => $.clock.now(),
21        get: key => $.store.get(key),
22        set: (key, value) => $.store.set(key, value),
23        del: key => $.store.delete(key),
24        after: (ms, fn) => $.clock.after(ms, fn),
25        toast: text => $.ui.toast(text, { timeoutMs: 8000 }),
26        log: text => $.ui.log(text, { to: 'debug' }),
27        setLogin: value => update($, login, () => value),
28        openUrl: async url => {
29          // So no macOS e so com alguem no terminal; qualquer falha fica em silencio.
30          if (!e.isInteractive) return
31          const os = await $.process.run(['uname', '-s'], { timeoutMs: 2000 })
32          if (os.stdout.trim() !== 'Darwin') return
33          await $.process.run(['open', url], { timeoutMs: 5000 })
34        },
35      },
36      cfg,
37    )
38    await gov.load()
39    gov.sessionId = await $.session.id().catch(() => null)
40    await $.command.register({
41      name: 'adaflow',
42      description: 'Adaflow: login, logout e status da trilha de auditoria',
43      argumentHint: 'login | logout | status',
44    })
45    const version = await $.session.version().catch(() => null)
46    await gov.record('session.start', {
47      clientVersion: version?.version,
48      surface: e.surface ?? 'headless',
49      interactive: e.isInteractive,
50    })
51    $.clock.every(FLUSH_EVERY_MS, () => {
52      void gov?.flush()
53    })
54    void gov.flush()
55    return started
56  }).catch(($, e, next) => next(e))
57
58  on('command.run', { command: 'adaflow' }, async ($, e) => {
59    if (!gov) return { text: 'adaflow-governance ainda nao terminou de carregar; tente de novo.' }
60    const sub = e.args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
61    if (sub === 'login') return { text: await gov.login() }
62    if (sub === 'logout') return { text: await gov.logout() }
63    if (sub === 'status' || sub === '') {
64      void gov.flush()
65      return { text: await gov.status() }
66    }
67    return { text: USAGE }
68  })
69
70  // So metadados: modelo, duracao, motivo e tokens. Nunca `e.answer` nem o texto do prompt.
71  on('turn.complete', async ($, e, next) => {
72    const result = await next(e)
73    await gov?.record('turn.complete', {
74      turnId: e.turnId,
75      model: e.usage?.model ?? 'desconhecido',
76      durationMs: e.durationMs,
77      reason: e.reason,
78      subagent: e.agentId !== undefined,
79      inputTokens: e.usage?.input_tokens,
80      outputTokens: e.usage?.output_tokens,
81      cacheReadInputTokens: e.usage?.cache_read_input_tokens,
82      cacheCreationInputTokens: e.usage?.cache_creation_input_tokens,
83    })
84    return result
85  }).catch(($, e, next) => next(e))
86
87  // Uma recusa vinda de baixo (adaflow-guard ou outro plugin) vira tool.denied. O motivo e a
88  // entrada da ferramenta nao saem daqui: so o nome da ferramenta e os ids de regra do guard.
89  on('tool.call', async ($, e, next) => {
90    const result = await next(e)
91    if (result.deny !== undefined) {
92      const rules = guardRuleIds(result.deny)
93      await gov?.record('tool.denied', {
94        tool: String(e.tool),
95        decision: 'deny',
96        source: rules ? 'adaflow-guard' : 'outro',
97        rules: rules ?? undefined,
98      })
99    }
100    return result
101  }).catch(($, e, next) => next(e))
102
103  on('session.end', async ($, e, next) => {
104    await gov?.flush()
105    return next(e)
106  }).catch(($, e, next) => next(e))
107
108  // Faixa acima do prompt enquanto o login espera a aprovacao: o codigo e o link clicavel.
109  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
110    const pending = await read($, login)
111    if (pending === null || e.props.hasSurvey) return next(e)
112    const { Box, Text, Link, Button } = $.ui.resolve(e)
113    const href = pending.verificationUriComplete ?? pending.verificationUri
114    return (
115      <Box flexDirection="row" gap={1}>
116        <Text>Adaflow: confirme o codigo</Text>
117        <Text bold>{pending.userCode}</Text>
118        <Text>em</Text>
119        <Link href={href} label={pending.verificationUri} />
120        <Button key="cancel" label="Cancelar" onPress={() => gov?.cancelLogin('Adaflow: login cancelado.')} />
121      </Box>
122    )
123  })
124}
125
hooks/governance.ts 568 lines
1import type { HttpInit, HttpResponse } from 'claude-code'
2import type { GovernanceLogin } from '../types'
3
4// Nucleo do mod, sem `$`: todo efeito passa pela `Io` que o register.tsx monta com chamadas
5// inline a `$`. Contrato: PRD login-cli-device-flow (adalink-platform/docs/prds), §3 e §4.5.
6
7export const APP = 'claude-code'
8export const CLIENT_ID = 'claude-code'
9export const SCOPE = 'audit.ingest'
10export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
11
12export const SESSION_CAP_MS = 7 * 24 * 60 * 60 * 1000 // teto absoluto da sessao de CLI (D2)
13export const JWT_FALLBACK_MS = 15 * 60 * 1000 // JWT sem `exp` legivel: 15 min (D4)
14export const JWT_SKEW_MS = 60 * 1000 // renova 1 min antes de expirar
15export const SLOW_DOWN_MS = 5000 // RFC 8628 §3.5
16export const DEFAULT_INTERVAL_MS = 5000
17export const DEFAULT_CODE_TTL_MS = 10 * 60 * 1000
18export const BATCH_MAX = 50 // IngestAuditEventsBatchDto @ArrayMaxSize(50)
19export const QUEUE_MAX = 500
20export const METADATA_MAX_BYTES = 4096 // metadata: max. 4KB serializado
21export const OCCURRED_MAX_AGE_MS = SESSION_CAP_MS - 60 * 60 * 1000 // occurredAt aceita ate -7 d
22export const BACKOFF_MIN_MS = 5000
23export const BACKOFF_MAX_MS = 5 * 60 * 1000
24export const FLUSH_EVERY_MS = 15 * 1000
25const HTTP_TIMEOUT_NOTE = 'falha de rede'
26
27export const STORE_SESSION = 'session'
28export const STORE_QUEUE = 'queue'
29export const STORE_LAST_SENT = 'lastSentAt'
30
31export type Io = {
32  fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
33  now: () => Promise<number>
34  get: (key: string) => Promise<unknown>
35  set: (key: string, value: unknown) => Promise<void>
36  del: (key: string) => Promise<void>
37  after: (ms: number, fn: () => void) => { cancel: () => void }
38  toast: (text: string) => void
39  log: (text: string) => void
40  setLogin: (login: GovernanceLogin | null) => Promise<unknown>
41  openUrl: (url: string) => Promise<void>
42}
43
44export type Config = { baseUrl: string; enabled: boolean }
45
46// Sessao do CLI guardada em $.store: JSON em texto puro no diretorio de configuracao do
47// Claude Code. Aceitavel so porque a credencial tem escopo `audit.ingest` (PRD D3, §4.1e).
48export type StoredSession = {
49  token: string
50  baseUrl: string
51  createdAt: number // createdAt da sessao no servidor (autoridade do teto de 7 d); senao, hora do login
52  loggedInAt: number
53  organizationId: string | null
54  organizationName: string | null
55  userEmail: string | null
56}
57
58// Shape exato de IngestAuditEventDto (security-service, origin/homolog).
59export type IngestEvent = {
60  app: string
61  eventId: string
62  action: string
63  resource: string
64  actionLabel?: string
65  severity?: 'info' | 'warning' | 'critical'
66  success?: boolean
67  metadata?: Record<string, unknown>
68  occurredAt?: string
69}
70
71export type EventKind = 'session.start' | 'turn.complete' | 'tool.denied'
72
73const EVENTS: Record<EventKind, Pick<IngestEvent, 'action' | 'resource' | 'actionLabel' | 'severity'>> = {
74  // `action` precisa casar com /^app\.[a-z0-9_-]+(\.[a-z0-9_-]+){1,4}$/ (namespace reservado
75  // de apps). Os nomes do PRD (`claude_code.session.start`) nao passariam: vao com o prefixo `app.`.
76  'session.start': {
77    action: 'app.claude_code.session.start',
78    resource: 'ClaudeCodeSession',
79    actionLabel: 'Sessão do Claude Code iniciada',
80    severity: 'info',
81  },
82  'turn.complete': {
83    action: 'app.claude_code.turn.complete',
84    resource: 'ClaudeCodeTurn',
85    actionLabel: 'Turno do Claude Code concluído',
86    severity: 'info',
87  },
88  'tool.denied': {
89    action: 'app.claude_code.tool.denied',
90    resource: 'ClaudeCodeTool',
91    actionLabel: 'Ferramenta bloqueada no Claude Code',
92    severity: 'warning',
93  },
94}
95
96// ---------- utilitarios puros ----------
97
98export const normalizeBaseUrl = (raw: string): string | null => {
99  let url: URL
100  try {
101    url = new URL(String(raw).trim())
102  } catch {
103    return null
104  }
105  const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
106  // O token vai em Authorization: nunca em texto claro fora da maquina.
107  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocal)) return null
108  return url.origin + url.pathname.replace(/\/+$/, '')
109}
110
111const parseJson = (text: string): Record<string, unknown> => {
112  try {
113    const v = JSON.parse(text)
114    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
115  } catch {
116    return {}
117  }
118}
119
120const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null)
121const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
122
123export const decodeJwtPayload = (jwt: string): Record<string, unknown> => {
124  const part = jwt.split('.')[1]
125  if (!part) return {}
126  try {
127    const b64 = part.replace(/-/g, '+').replace(/_/g, '/')
128    const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4))
129    const bytes = Uint8Array.from(bin, c => c.charCodeAt(0))
130    return parseJson(new TextDecoder().decode(bytes))
131  } catch {
132    return {}
133  }
134}
135
136// Erro do device flow: RFC 8628 usa { error }, o Better Auth as vezes { code }.
137export const deviceError = (res: HttpResponse): string => {
138  const body = parseJson(res.text)
139  return String(body.error ?? body.code ?? '').toLowerCase()
140}
141
142const bytes = (s: string) => new TextEncoder().encode(s).length
143
144// So primitivos curtos: o que nao for numero, booleano, texto curto ou lista de ids curtos fica
145// de fora. Acima de 4KB, corta campos do fim ate caber.
146export const safeMetadata = (raw: Record<string, unknown>): Record<string, unknown> => {
147  const out: Record<string, unknown> = {}
148  for (const [k, v] of Object.entries(raw)) {
149    if (!/^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(k)) continue
150    if (typeof v === 'boolean') out[k] = v
151    else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v
152    else if (typeof v === 'string' && v.length > 0 && v.length <= 120) out[k] = v
153    else if (Array.isArray(v)) {
154      const items = v.filter((x): x is string => typeof x === 'string' && /^[a-z0-9_-]{1,40}$/.test(x)).slice(0, 20)
155      if (items.length > 0) out[k] = items
156    }
157  }
158  const keys = Object.keys(out)
159  while (keys.length > 0 && bytes(JSON.stringify(out)) > METADATA_MAX_BYTES) delete out[keys.pop()!]
160  return out
161}
162
163// Ids de regra do adaflow-guard no motivo da recusa: "adaflow-guard (token-in-client, literal-token) em ...".
164export const guardRuleIds = (deny: string): string[] | null => {
165  const m = /^adaflow-guard \(([a-z0-9-]+(?:, [a-z0-9-]+)*)\)/.exec(deny)
166  return m ? m[1]!.split(', ') : null
167}
168
169const fmtDuration = (ms: number): string => {
170  if (ms <= 0) return 'expirada'
171  const h = Math.floor(ms / 3_600_000)
172  const d = Math.floor(h / 24)
173  const m = Math.floor((ms % 3_600_000) / 60_000)
174  if (d > 0) return `${d} d ${h % 24} h`
175  if (h > 0) return `${h} h ${m} min`
176  return `${Math.max(1, m)} min`
177}
178
179const fmtAgo = (now: number, at: number | null): string =>
180  at === null ? 'nenhum' : `há ${fmtDuration(Math.max(60_000, now - at))} (${new Date(at).toISOString()})`
181
182// ---------- o mod ----------
183
184type Pending = GovernanceLogin & { deviceCode: string; intervalMs: number; timer: { cancel: () => void } | null }
185
186export class Governance {
187  session: StoredSession | null = null
188  queue: IngestEvent[] = []
189  lastSentAt: number | null = null
190  lastError: string | null = null
191  sessionId: string | null = null
192  private jwt: { token: string; exp: number } | null = null
193  private pending: Pending | null = null
194  private nextAttemptAt = 0
195  private backoffMs = 0
196  private flushing: Promise<void> | null = null
197  private loaded = false
198
199  constructor(
200    private readonly io: Io,
201    private readonly cfg: Config,
202  ) {}
203
204  get baseUrl() {
205    return normalizeBaseUrl(this.cfg.baseUrl)
206  }
207
208  get isLoggedIn() {
209    return this.session !== null
210  }
211
212  async load() {
213    if (this.loaded) return
214    this.loaded = true
215    const s = (await this.io.get(STORE_SESSION).catch(() => undefined)) as StoredSession | undefined
216    this.session = s && typeof s.token === 'string' ? s : null
217    const q = await this.io.get(STORE_QUEUE).catch(() => undefined)
218    this.queue = Array.isArray(q) ? (q as IngestEvent[]) : []
219    const last = await this.io.get(STORE_LAST_SENT).catch(() => undefined)
220    this.lastSentAt = num(last)
221    // Um login pendente nao sobrevive a uma recarga do modulo (o device_code so existe na memoria).
222    await this.io.setLogin(null).catch(() => undefined)
223  }
224
225  // ---------- login (device flow, RFC 8628) ----------
226
227  async login(): Promise<string> {
228    const base = this.baseUrl
229    if (!base) return `URL do gateway invalida: \`${this.cfg.baseUrl}\`. Use https (http so para localhost).`
230    const now = await this.io.now()
231    if (this.session && now < this.session.createdAt + SESSION_CAP_MS) {
232      return 'Ja conectado ao Adaflow. Rode `/adaflow status` para ver a sessao ou `/adaflow logout` para sair.'
233    }
234    if (this.pending && now < this.pending.expiresAt) {
235      return `Login em andamento: confirme o codigo **${this.pending.userCode}** em ${this.pending.verificationUriComplete ?? this.pending.verificationUri}`
236    }
237    let res: HttpResponse
238    try {
239      res = await this.io.fetch(`${base}/v1/auth/device/code`, {
240        method: 'POST',
241        headers: { 'content-type': 'application/json', accept: 'application/json' },
242        body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
243      })
244    } catch {
245      return `Nao foi possivel contatar ${base} (${HTTP_TIMEOUT_NOTE}). Tente de novo em instantes.`
246    }
247    if (!res.ok) {
248      if (res.status === 404 || res.status === 403) {
249        return `O login do CLI ainda nao esta disponivel neste ambiente (HTTP ${res.status}; a flag \`auth.device-flow-cli\` pode estar desligada).`
250      }
251      return `O gateway recusou o pedido de login (HTTP ${res.status}).`
252    }
253    const body = parseJson(res.text)
254    const deviceCode = str(body.device_code)
255    const userCode = str(body.user_code)
256    const uri = str(body.verification_uri)
257    if (!deviceCode || !userCode || !uri) return 'Resposta inesperada do gateway ao pedir o codigo de login.'
258    const login: Pending = {
259      deviceCode,
260      userCode,
261      verificationUri: absolute(uri, base),
262      verificationUriComplete: str(body.verification_uri_complete) ? absolute(String(body.verification_uri_complete), base) : null,
263      expiresAt: now + (num(body.expires_in) ?? DEFAULT_CODE_TTL_MS / 1000) * 1000,
264      intervalMs: Math.max(1, num(body.interval) ?? DEFAULT_INTERVAL_MS / 1000) * 1000,
265      timer: null,
266    }
267    this.pending = login
268    await this.io.setLogin(publicLogin(login)).catch(() => undefined)
269    this.schedulePoll()
270    const link = login.verificationUriComplete ?? login.verificationUri
271    void this.io.openUrl(link).catch(() => undefined)
272    return [
273      '**Adaflow: login**',
274      '',
275      `1. Abra [${login.verificationUri}](${link})`,
276      `2. Confirme o codigo **${userCode}** (ele precisa ser igual ao que aparece no navegador)`,
277      '',
278      `Aguardando a aprovacao (o codigo expira em ${fmtDuration(login.expiresAt - now)}). Pode seguir trabalhando: o resultado chega como aviso.`,
279    ].join('\n')
280  }
281
282  cancelLogin(reason?: string) {
283    const p = this.pending
284    if (!p) return
285    p.timer?.cancel()
286    this.pending = null
287    void this.io.setLogin(null).catch(() => undefined)
288    if (reason) this.io.toast(reason)
289  }
290
291  private schedulePoll() {
292    const p = this.pending
293    if (!p) return
294    p.timer = this.io.after(p.intervalMs, () => {
295      void this.poll(p).catch(err => {
296        this.io.log(`adaflow-governance: poll falhou: ${String(err)}`)
297        if (this.pending === p) this.schedulePoll()
298      })
299    })
300  }
301
302  private async poll(p: Pending) {
303    if (this.pending !== p) return
304    const base = this.baseUrl
305    if (!base) return this.cancelLogin('Adaflow: URL do gateway invalida, login cancelado.')
306    if ((await this.io.now()) >= p.expiresAt) return this.cancelLogin('Adaflow: o codigo de login expirou. Rode /adaflow login de novo.')
307    let res: HttpResponse
308    try {
309      res = await this.io.fetch(`${base}/v1/auth/device/token`, {
310        method: 'POST',
311        headers: { 'content-type': 'application/json', accept: 'application/json' },
312        body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: p.deviceCode, client_id: CLIENT_ID }),
313      })
314    } catch {
315      return this.schedulePoll() // rede instavel: tenta de novo no proximo intervalo
316    }
317    if (this.pending !== p) return
318    if (res.ok) {
319      const token = str(parseJson(res.text).access_token)
320      if (!token) return this.cancelLogin('Adaflow: resposta de login sem token. Tente /adaflow login de novo.')
321      p.timer?.cancel()
322      this.pending = null
323      await this.io.setLogin(null).catch(() => undefined)
324      return this.completeLogin(token, base)
325    }
326    switch (deviceError(res)) {
327      case 'authorization_pending':
328        return this.schedulePoll()
329      case 'slow_down':
330        p.intervalMs += SLOW_DOWN_MS
331        return this.schedulePoll()
332      case 'expired_token':
333        return this.cancelLogin('Adaflow: o codigo de login expirou. Rode /adaflow login de novo.')
334      case 'access_denied':
335        return this.cancelLogin('Adaflow: login negado no navegador.')
336      default:
337        if (res.status >= 500 || res.status === 429) return this.schedulePoll()
338        return this.cancelLogin(`Adaflow: login recusado pelo gateway (HTTP ${res.status}).`)
339    }
340  }
341
342  private async completeLogin(token: string, base: string) {
343    const now = await this.io.now()
344    const session: StoredSession = {
345      token,
346      baseUrl: base,
347      createdAt: now,
348      loggedInAt: now,
349      organizationId: null,
350      organizationName: null,
351      userEmail: null,
352    }
353    // get-session e uma das tres rotas que a sessao de CLI alcanca (§4.1e): createdAt e org ativa.
354    try {
355      const res = await this.io.fetch(`${base}/v1/auth/get-session`, { headers: bearer(token) })
356      if (res.ok) {
357        const body = parseJson(res.text)
358        const s = (body.session ?? {}) as Record<string, unknown>
359        const u = (body.user ?? {}) as Record<string, unknown>
360        const created = Date.parse(String(s.createdAt ?? ''))
361        if (Number.isFinite(created) && created <= now) session.createdAt = created
362        session.organizationId = str(s.activeOrganizationId)
363        session.userEmail = str(u.email)
364      }
365    } catch {
366      // segue sem: o teto passa a contar da hora do login
367    }
368    this.session = session
369    this.jwt = null
370    await this.io.set(STORE_SESSION, session)
371    const jwt = await this.getJwt(true).catch(() => null)
372    if (jwt) {
373      const claims = decodeJwtPayload(jwt)
374      const org = (claims.organization ?? {}) as Record<string, unknown>
375      session.organizationId ??= str(claims.organizationId) ?? str(claims.activeOrganizationId) ?? str(org.id)
376      session.organizationName = str(claims.organizationName) ?? str(org.name)
377      session.userEmail ??= str(claims.email)
378      await this.io.set(STORE_SESSION, session)
379    }
380    if (!this.session) return // o JWT ja foi recusado: getJwt avisou
381    this.io.toast(`Adaflow: conectado${session.organizationName || session.organizationId ? ` (${session.organizationName ?? session.organizationId})` : ''}.`)
382    await this.record('session.start', { reason: 'login' })
383  }
384
385  async logout(): Promise<string> {
386    this.cancelLogin()
387    const s = this.session
388    let remote = 'sem sessao no servidor'
389    if (s) {
390      try {
391        const res = await this.io.fetch(`${s.baseUrl}/v1/auth/sign-out`, {
392          method: 'POST',
393          headers: { ...bearer(s.token), 'content-type': 'application/json' },
394          body: '{}',
395        })
396        remote = res.ok ? 'sessao encerrada no servidor' : `o servidor respondeu HTTP ${res.status}; revogue pela lista de sessoes se preciso`
397      } catch {
398        remote = 'servidor inacessivel; revogue pela lista de sessoes da plataforma'
399      }
400    }
401    this.session = null
402    this.jwt = null
403    this.queue = []
404    this.backoffMs = 0
405    this.nextAttemptAt = 0
406    await this.io.del(STORE_SESSION).catch(() => undefined)
407    await this.io.del(STORE_QUEUE).catch(() => undefined)
408    return s ? `Desconectado do Adaflow (${remote}). Token local e fila pendente apagados.` : 'Nenhuma sessao do Adaflow neste computador.'
409  }
410
411  async status(): Promise<string> {
412    const now = await this.io.now()
413    const lines = ['**Adaflow: status**', '']
414    if (!this.cfg.enabled) lines.push('- Envio de auditoria: **desligado** (`enabled` = false)')
415    if (this.pending) lines.push(`- Login pendente: codigo **${this.pending.userCode}**`)
416    const s = this.session
417    if (!s) {
418      lines.push('- Sessao: nao conectado. Rode `/adaflow login`.')
419    } else {
420      lines.push(`- Organizacao: ${s.organizationName ?? s.organizationId ?? 'desconhecida'}${s.userEmail ? ` (${s.userEmail})` : ''}`)
421      lines.push(`- Validade restante: ${fmtDuration(s.createdAt + SESSION_CAP_MS - now)} (teto de 7 dias desde o login)`)
422      lines.push(`- Gateway: ${s.baseUrl}`)
423    }
424    lines.push(`- Ultimo envio: ${fmtAgo(now, this.lastSentAt)}`)
425    lines.push(`- Fila pendente: ${this.queue.length} evento(s)`)
426    if (this.lastError) lines.push(`- Ultimo erro: ${this.lastError}`)
427    return lines.join('\n')
428  }
429
430  // ---------- JWT de 15 min ----------
431
432  private async getJwt(force: boolean): Promise<string | null> {
433    const s = this.session
434    if (!s) return null
435    const now = await this.io.now()
436    if (now >= s.createdAt + SESSION_CAP_MS) {
437      await this.expire('a sessao passou do teto de 7 dias')
438      return null
439    }
440    if (!force && this.jwt && this.jwt.exp - JWT_SKEW_MS > now) return this.jwt.token
441    const res = await this.io.fetch(`${s.baseUrl}/v1/auth/token`, { headers: bearer(s.token) })
442    if (res.status === 401) {
443      await this.expire('a sessao expirou ou foi revogada')
444      return null
445    }
446    if (!res.ok) throw new Error(`token HTTP ${res.status}`)
447    const token = str(parseJson(res.text).token)
448    if (!token) throw new Error('token ausente')
449    const exp = num(decodeJwtPayload(token).exp)
450    this.jwt = { token, exp: exp !== null ? exp * 1000 : now + JWT_FALLBACK_MS }
451    return token
452  }
453
454  private async expire(why: string) {
455    this.session = null
456    this.jwt = null
457    await this.io.del(STORE_SESSION).catch(() => undefined)
458    this.io.toast(`Adaflow: ${why}. Rode /adaflow login para voltar a registrar a auditoria.`)
459  }
460
461  // ---------- eventos ----------
462
463  // Sem login (ou desligado) e no-op silencioso. Nao faz rede: so enfileira.
464  async record(kind: EventKind, metadata: Record<string, unknown>) {
465    if (!this.cfg.enabled || !this.session) return
466    const now = await this.io.now()
467    const event: IngestEvent = {
468      app: APP,
469      eventId: crypto.randomUUID(),
470      ...EVENTS[kind],
471      success: kind !== 'tool.denied',
472      metadata: safeMetadata({ sessionId: this.sessionId ?? undefined, ...metadata }),
473      occurredAt: new Date(now).toISOString(),
474    }
475    if (kind === 'turn.complete' && metadata.reason !== 'answer') event.success = false
476    this.queue.push(event)
477    if (this.queue.length > QUEUE_MAX) this.queue.splice(0, this.queue.length - QUEUE_MAX)
478    await this.io.set(STORE_QUEUE, this.queue).catch(() => undefined)
479    if (this.queue.length >= BATCH_MAX) void this.flush()
480  }
481
482  flush(): Promise<void> {
483    this.flushing ??= this.doFlush()
484      .catch(err => this.io.log(`adaflow-governance: flush falhou: ${String(err)}`))
485      .finally(() => {
486        this.flushing = null
487      })
488    return this.flushing
489  }
490
491  private async doFlush() {
492    if (!this.cfg.enabled || !this.session || this.queue.length === 0) return
493    const now = await this.io.now()
494    if (now < this.nextAttemptAt) return
495    const minTime = now - OCCURRED_MAX_AGE_MS
496    this.queue = this.queue.filter(ev => !ev.occurredAt || Date.parse(ev.occurredAt) >= minTime)
497    // So o que ja estava na fila: o que chega durante o envio espera o proximo, em lote.
498    let budget = this.queue.length
499    while (this.session && budget > 0 && this.queue.length > 0) {
500      const batch = this.queue.slice(0, Math.min(BATCH_MAX, budget))
501      budget -= batch.length
502      let res: HttpResponse
503      try {
504        let jwt = await this.getJwt(false)
505        if (!jwt) return
506        res = await this.post(jwt, batch)
507        if (res.status === 401) {
508          jwt = await this.getJwt(true)
509          if (!jwt) return
510          res = await this.post(jwt, batch)
511        }
512      } catch (err) {
513        return this.backoff(now, `${HTTP_TIMEOUT_NOTE}: ${String(err)}`)
514      }
515      if (res.status === 408 || res.status === 425 || res.status === 429 || res.status >= 500) {
516        return this.backoff(now, `HTTP ${res.status}`)
517      }
518      const sent = new Set(batch.map(ev => ev.eventId))
519      this.queue = this.queue.filter(ev => !sent.has(ev.eventId))
520      if (res.ok) {
521        const body = parseJson(res.text)
522        const rejected = Array.isArray(body.rejected) ? body.rejected.length : 0
523        this.lastError = rejected > 0 ? `${rejected} evento(s) rejeitado(s) pela validacao` : null
524        this.lastSentAt = await this.io.now()
525        await this.io.set(STORE_LAST_SENT, this.lastSentAt).catch(() => undefined)
526      } else {
527        // 400/403/404/413: reenviar nao muda o resultado; o lote e descartado.
528        this.lastError = `lote descartado: HTTP ${res.status}`
529        this.io.log(`adaflow-governance: ${this.lastError} ${res.text.slice(0, 200)}`)
530      }
531      this.backoffMs = 0
532      this.nextAttemptAt = 0
533      await this.io.set(STORE_QUEUE, this.queue).catch(() => undefined)
534    }
535  }
536
537  private post(jwt: string, events: IngestEvent[]) {
538    return this.io.fetch(`${this.session!.baseUrl}/v1/audit/events/batch`, {
539      method: 'POST',
540      headers: { ...bearer(jwt), 'content-type': 'application/json' },
541      body: JSON.stringify({ events }),
542    })
543  }
544
545  private backoff(now: number, why: string) {
546    this.backoffMs = Math.min(BACKOFF_MAX_MS, Math.max(BACKOFF_MIN_MS, this.backoffMs * 2))
547    this.nextAttemptAt = now + this.backoffMs
548    this.lastError = why
549  }
550}
551
552const bearer = (token: string) => ({ authorization: `Bearer ${token}`, accept: 'application/json' })
553
554const absolute = (uri: string, base: string) => {
555  try {
556    return new URL(uri, base + '/').toString()
557  } catch {
558    return uri
559  }
560}
561
562const publicLogin = (p: Pending): GovernanceLogin => ({
563  userCode: p.userCode,
564  verificationUri: p.verificationUri,
565  verificationUriComplete: p.verificationUriComplete,
566  expiresAt: p.expiresAt,
567})
568
types/index.d.ts 19 lines
1// Login pendente do device flow, desenhado na faixa acima do prompt. So o que o usuario ve:
2// o device_code (que vira credencial) fica na memoria do modulo, nunca em $.state, que
3// qualquer plugin le.
4export type GovernanceLogin = {
5  userCode: string
6  verificationUri: string
7  verificationUriComplete: string | null
8  expiresAt: number
9}
10
11// Dentro de `declare module 'claude-code'` use o alias, para nao colidir com nomes do claude-code.
12export type GovernancePendingLogin = GovernanceLogin
13
14declare module 'claude-code' {
15  interface PluginState {
16    'adaflow-governance': { login: GovernancePendingLogin | null }
17  }
18}
19