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

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).
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.
/adaflow login
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.
| Comando | O que faz |
|---|---|
/adaflow login | Login por device flow (código + aprovação no navegador) |
/adaflow logout | Encerra a sessão no servidor (/sign-out) e apaga o token e a fila local |
/adaflow status | Organização, validade restante até o teto de 7 dias, último envio, fila pendente e último erro |
| Campo | Padrão | Efeito |
|---|---|---|
baseUrl | https://adalink-api-gateway.onrender.com | Gateway da plataforma (https; http só para localhost) |
enabled | true | Desligado, o mod não coleta nem envia eventos |
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) | Quando | Metadados |
|---|---|---|
app.claude_code.session.start | início da sessão e logo após o login | id da sessão do Claude Code, versão do Claude Code, superfície (terminal/desktop), se é interativa |
app.claude_code.turn.complete | fim de cada turno (inclui subagentes) | id do turno, modelo, duração, motivo do fim, tokens de entrada/saída/cache |
app.claude_code.tool.denied | uma ferramenta foi recusada por um plugin | nome 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.
adaflow-guard, como token-in-client).Os metadados passam por uma lista do que é aceito (números, booleanos, textos curtos) e ficam abaixo de 4 KB por evento.
/adaflow status mostra o último erro./adaflow login.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:
audit.ingest); o gateway nega qualquer outra rota com 403 scope_required;/token, /get-session, /sign-out): não troca e-mail, não lista nem revoga sessões, não aprova outros logins;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.
/adaflow logout (encerra no servidor e apaga o arquivo local).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./adaflow login de novo.claude plugin validate .
claude plugin test .
claude --plugin-dir .
Os testes rodam inteiramente contra mocks (rede, relógio e store), sem backend.
hooks/register.tsx 125 lines1import { 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}
125hooks/governance.ts 568 lines1import 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})
568types/index.d.ts 19 lines1// 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