@orkastery/cli 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.lerCacheIncremental = void 0;
37
+ exports.resolverRegrasSemgrep = resolverRegrasSemgrep;
38
+ exports.consultarCacheIncremental = consultarCacheIncremental;
39
+ exports.gravarCacheIncremental = gravarCacheIncremental;
40
+ /** Cache de resultado normalizado do Agile, por conteúdo e fora do repositório. */
41
+ const crypto = __importStar(require("node:crypto"));
42
+ const node_child_process_1 = require("node:child_process");
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ const run_1 = require("./run");
46
+ const CONTRATO = 'ork.security-scan.incremental/v1';
47
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'poetry.lock',
48
+ 'Pipfile.lock', 'go.mod', 'go.sum']);
49
+ const sha256 = (valor) => crypto.createHash('sha256').update(valor).digest('hex');
50
+ const SCRIPT_DO_REGISTRO = String.raw `
51
+ # ORK_SEMGREP_RESOLVE_V1
52
+ import sys, urllib.request
53
+ pedido = urllib.request.Request(sys.argv[1], headers={"Accept": "application/json", "User-Agent": "orkastery-security-scan"})
54
+ with urllib.request.urlopen(pedido, timeout=20) as resposta:
55
+ if resposta.status != 200:
56
+ raise RuntimeError("HTTP " + str(resposta.status))
57
+ sys.stdout.buffer.write(resposta.read())
58
+ `;
59
+ /** O mesmo endpoint público para o qual o resolvedor do Semgrep mapeia um id do registro. */
60
+ function urlDoRegistro(referencia) {
61
+ if (/^[prs]\/[A-Za-z0-9._/-]+$/.test(referencia))
62
+ return `https://semgrep.dev/c/${referencia}`;
63
+ if (/^https:\/\//.test(referencia))
64
+ return referencia;
65
+ return null;
66
+ }
67
+ function baixarDoRegistro(ferramenta, url) {
68
+ const uid = process.getuid?.() ?? 65534;
69
+ const gid = process.getgid?.() ?? 65534;
70
+ const nome = `ork-security-scan-semgrep-registry-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
71
+ const r = (0, node_child_process_1.spawnSync)('docker', ['run', '--rm', '--name', nome, '--user', `${uid === 0 ? 65534 : uid}:${uid === 0 ? 65534 : gid}`,
72
+ '--network', 'default', '--entrypoint', 'python3', ferramenta.image, '-c', SCRIPT_DO_REGISTRO, url], { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, killSignal: 'SIGKILL' });
73
+ if (r.error || r.status !== 0)
74
+ (0, node_child_process_1.spawnSync)('docker', ['rm', '-f', nome], { stdio: 'ignore', timeout: 10_000 });
75
+ return !r.error && r.status === 0 && r.stdout.trim() ? r.stdout : null;
76
+ }
77
+ /** O conteúdo que o próprio Semgrep extrai da resposta do registro (`rules` direto ou envelope `rule_config`). */
78
+ function conteudoResolvido(resposta) {
79
+ try {
80
+ const json = JSON.parse(resposta);
81
+ const regras = Object.prototype.hasOwnProperty.call(json, 'rules') ? json : json.rule_config;
82
+ return typeof regras === 'string' ? regras : JSON.stringify(regras);
83
+ }
84
+ catch {
85
+ return resposta;
86
+ }
87
+ }
88
+ const MARCAS_ENTRE_ARQUIVOS = ['join', 'interfile', 'crossfile', 'multifile'];
89
+ const MARCA_NA_LINHA = /(?:^|[\s{,])(?:mode\s*:\s*)?(?:join|inter[-_ ]?file|cross[-_ ]?file|multi[-_ ]?file)(?:\s*:|\s*$)/i;
90
+ const CHAVE_DA_LINHA = /^(\s*)(-\s+)?([A-Za-z_][\w-]*)\s*:(?:\s+(.*))?$/;
91
+ const normalizar = (chave) => chave.toLowerCase().replace(/[-_]/g, '');
92
+ /**
93
+ * Se a marca cruza arquivos de fato. `join` cruza em qualquer motor. `interfile` em `options` só cruza no motor Pro do
94
+ * Semgrep; em `metadata` é rótulo do registro, que o motor ignora. Em qualquer outro lugar, cruza (conservador).
95
+ */
96
+ function marcaCruzaArquivos(marca, pai, pro) {
97
+ if (marca === 'join')
98
+ return true;
99
+ if (pai === 'metadata')
100
+ return false;
101
+ if (pai === 'options')
102
+ return pro;
103
+ return true;
104
+ }
105
+ /**
106
+ * O registro responde em YAML. Toda linha com marca cruza arquivos, menos a chave da própria marca, em bloco e com valor
107
+ * booleano, cujo pai pela indentação a torna inócua no motor da rodada. Linha de texto que parece chave nunca relaxa nada.
108
+ */
109
+ function marcaNoYaml(conteudo, pro) {
110
+ const pais = [];
111
+ for (const linha of conteudo.split(/\r?\n/)) {
112
+ const m = CHAVE_DA_LINHA.exec(linha);
113
+ const coluna = m ? m[1].length + (m[2]?.length ?? 0) : 0;
114
+ if (m)
115
+ while (pais.length && pais[pais.length - 1].coluna >= coluna)
116
+ pais.pop();
117
+ if (MARCA_NA_LINHA.test(linha)) {
118
+ const chave = m ? normalizar(m[3]) : null;
119
+ const booleano = !!m && /^(?:true|false|yes|no|on|off)?\s*(?:#.*)?$/i.test(m[4] ?? '');
120
+ if (!chave || !MARCAS_ENTRE_ARQUIVOS.includes(chave) || !booleano
121
+ || marcaCruzaArquivos(chave, pais[pais.length - 1]?.chave ?? null, pro))
122
+ return true;
123
+ }
124
+ if (m)
125
+ pais.push({ coluna, chave: normalizar(m[3]) });
126
+ }
127
+ return false;
128
+ }
129
+ function regraEntreArquivos(conteudo, pro) {
130
+ let valor;
131
+ try {
132
+ valor = JSON.parse(conteudo);
133
+ }
134
+ catch {
135
+ return marcaNoYaml(conteudo, pro);
136
+ }
137
+ if (typeof valor === 'string')
138
+ return marcaNoYaml(valor, pro);
139
+ const visitar = (atual, pai) => {
140
+ if (Array.isArray(atual))
141
+ return atual.some((item) => visitar(item, pai));
142
+ if (!atual || typeof atual !== 'object')
143
+ return false;
144
+ return Object.entries(atual).some(([chave, filho]) => {
145
+ const normal = normalizar(chave);
146
+ if (normal === 'mode' && typeof filho === 'string' && /^(join|interfile|crossfile|multifile)$/i.test(filho))
147
+ return true;
148
+ if (MARCAS_ENTRE_ARQUIVOS.includes(normal) && filho !== false && filho !== null
149
+ && marcaCruzaArquivos(normal, pai, pro))
150
+ return true;
151
+ return visitar(filho, normal);
152
+ });
153
+ };
154
+ return visitar(valor, null);
155
+ }
156
+ /** O Semgrep da rodada usa o motor Pro, o único que cruza arquivos nas regras `interfile`: `--pro*`, `semgrep ci` ou token. */
157
+ function motorPro(ferramenta, unidades) {
158
+ const stacks = new Set(unidades.map((u) => u.stack).filter((s) => !!s));
159
+ const todos = [ferramenta.args, ...[...stacks].map((s) => ferramenta.argsByStack?.[s] ?? [])];
160
+ return todos.some((args) => args.some((a) => a.startsWith('--pro')) || args.includes('ci'))
161
+ || Object.keys(ferramenta.env ?? {}).some((k) => /^SEMGREP_APP_TOKEN$/i.test(k));
162
+ }
163
+ function referenciasDasRegras(ferramenta, unidades) {
164
+ const stacks = new Set(unidades.map((u) => u.stack).filter((s) => !!s));
165
+ const referencias = new Set();
166
+ for (const stack of stacks) {
167
+ const args = ferramenta.argsByStack?.[stack] ?? [];
168
+ for (let i = 0; i < args.length - 1; i++)
169
+ if (args[i] === '--config' || args[i] === '-f')
170
+ referencias.add(args[++i]);
171
+ }
172
+ return [...referencias].sort();
173
+ }
174
+ function resolverRegrasSemgrep(ferramenta, unidades, baixar = baixarDoRegistro) {
175
+ const referencias = referenciasDasRegras(ferramenta, unidades);
176
+ if (referencias.length === 0)
177
+ return { reutilizavel: false, hash: null, motivo: 'regras-sem-referencia', referencias };
178
+ const resolvidas = [];
179
+ const pro = motorPro(ferramenta, unidades);
180
+ for (const referencia of referencias) {
181
+ const url = urlDoRegistro(referencia);
182
+ if (!url)
183
+ return { reutilizavel: false, hash: null, motivo: 'referencia-de-regra-nao-resolvivel', referencias };
184
+ const resposta = baixar(ferramenta, url);
185
+ if (!resposta)
186
+ return { reutilizavel: false, hash: null, motivo: 'registro-indisponivel', referencias };
187
+ const conteudo = conteudoResolvido(resposta);
188
+ if (regraEntreArquivos(conteudo, pro))
189
+ return { reutilizavel: false, hash: null, motivo: 'regra-entre-arquivos', referencias };
190
+ resolvidas.push([referencia, conteudo]);
191
+ }
192
+ return { reutilizavel: true, hash: sha256(JSON.stringify(resolvidas)), referencias };
193
+ }
194
+ /** Só habilita o que tem garantia explícita: Semgrep por arquivo e dependências por lockfile. */
195
+ function escopoDaFerramenta(t, trabalho) {
196
+ if (t.name === 'semgrep') {
197
+ // A API direta antecede o trabalhador e continua útil para conferir o retrato por arquivo isoladamente. No caminho
198
+ // real do Agile, `incremental: true` torna obrigatória a identidade das regras resolvidas: ausência, registro
199
+ // indisponível ou regra entre arquivos nunca cai nesta compatibilidade.
200
+ if (trabalho.incremental !== true)
201
+ return 'arquivo';
202
+ return trabalho.regrasSemgrep?.reutilizavel && trabalho.regrasSemgrep.hash ? 'arquivo' : null;
203
+ }
204
+ if (t.category === 'dependency')
205
+ return 'lockfile';
206
+ return null;
207
+ }
208
+ function arquivos(root, escopo) {
209
+ const saida = {};
210
+ const visitar = (dir) => {
211
+ let nomes;
212
+ try {
213
+ nomes = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
214
+ }
215
+ catch {
216
+ return;
217
+ }
218
+ for (const nome of nomes) {
219
+ if (nome.name === '.git')
220
+ continue;
221
+ const absoluto = path.join(dir, nome.name);
222
+ if (nome.isDirectory()) {
223
+ visitar(absoluto);
224
+ continue;
225
+ }
226
+ if (!nome.isFile())
227
+ continue;
228
+ const relativo = path.relative(root, absoluto).split(path.sep).join('/');
229
+ const lockfile = LOCKFILES.has(nome.name) || /^requirements(?:\.[^.]+)?\.txt$/.test(nome.name);
230
+ if (escopo === 'lockfile' && !lockfile)
231
+ continue;
232
+ try {
233
+ saida[relativo] = sha256(fs.readFileSync(absoluto));
234
+ }
235
+ catch { /* arquivo mudou durante a leitura: nao entra */ }
236
+ }
237
+ };
238
+ visitar(root);
239
+ return saida;
240
+ }
241
+ function identidade(trabalho, ferramenta) {
242
+ return sha256(JSON.stringify({ ferramenta, unidades: trabalho.units, configuracao: trabalho.config, base: trabalho.ctx.base,
243
+ regrasSemgrep: trabalho.regrasSemgrep?.hash ?? null }));
244
+ }
245
+ function localDaEntrada(chave) {
246
+ return path.join((0, run_1.cacheRoot)(), 'incremental', `${chave}.json`);
247
+ }
248
+ function consultarCacheIncremental(trabalho, ferramenta) {
249
+ const escopo = escopoDaFerramenta(ferramenta, trabalho);
250
+ if (!escopo)
251
+ return { escopo: null, atual: null, anterior: null, alterados: [], reutilizado: null };
252
+ const retrato = { escopo, arquivos: arquivos(trabalho.ctx.headTree, escopo) };
253
+ if (escopo === 'lockfile' && Object.keys(retrato.arquivos).length === 0) {
254
+ return { escopo, atual: retrato, anterior: null, alterados: [], reutilizado: null };
255
+ }
256
+ const chave = identidade(trabalho, ferramenta);
257
+ try {
258
+ const entrada = JSON.parse(fs.readFileSync(localDaEntrada(chave), 'utf8'));
259
+ if (entrada.contrato !== CONTRATO || entrada.chave !== chave || entrada.resultado?.run?.state !== 'ran') {
260
+ return { escopo, atual: retrato, anterior: null, alterados: [], reutilizado: null };
261
+ }
262
+ const nomes = new Set([...Object.keys(entrada.retrato.arquivos), ...Object.keys(retrato.arquivos)]);
263
+ const alterados = [...nomes].filter((nome) => entrada.retrato.arquivos[nome] !== retrato.arquivos[nome]).sort();
264
+ return { escopo, atual: retrato, anterior: entrada.resultado, alterados,
265
+ reutilizado: alterados.length === 0 ? entrada.resultado : null };
266
+ }
267
+ catch {
268
+ return { escopo, atual: retrato, anterior: null, alterados: [], reutilizado: null };
269
+ }
270
+ }
271
+ const lerCacheIncremental = (trabalho, ferramenta) => consultarCacheIncremental(trabalho, ferramenta).reutilizado;
272
+ exports.lerCacheIncremental = lerCacheIncremental;
273
+ function gravarCacheIncremental(trabalho, ferramenta, resultado) {
274
+ const escopo = escopoDaFerramenta(ferramenta, trabalho);
275
+ if (!escopo || resultado.run.state !== 'ran')
276
+ return;
277
+ const retrato = { escopo, arquivos: arquivos(trabalho.ctx.headTree, escopo) };
278
+ if (escopo === 'lockfile' && Object.keys(retrato.arquivos).length === 0)
279
+ return;
280
+ const chave = identidade(trabalho, ferramenta), arquivo = localDaEntrada(chave);
281
+ try {
282
+ fs.mkdirSync(path.dirname(arquivo), { recursive: true, mode: 0o700 });
283
+ const temporario = `${arquivo}.${process.pid}.tmp`;
284
+ const entrada = { contrato: CONTRATO, chave, retrato, resultado };
285
+ fs.writeFileSync(temporario, JSON.stringify(entrada), { mode: 0o600 });
286
+ fs.renameSync(temporario, arquivo);
287
+ }
288
+ catch { /* cache ausente custa CPU, mas nunca muda o veredito */ }
289
+ }
@@ -41,9 +41,11 @@ exports.comCacheNoHost = comCacheNoHost;
41
41
  * `runScanAgil`, um trabalhador por ferramenta, ao mesmo tempo.
42
42
  */
43
43
  const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
44
45
  const executor_1 = require("./executor");
45
46
  const guard_1 = require("./guard");
46
47
  const run_1 = require("./run");
48
+ const incremental_1 = require("./incremental");
47
49
  /**
48
50
  * S6 do CHECK: o binario nativo usa o cache por usuario que o Docker monta (o caminho do host no lugar do caminho do
49
51
  * container), e nao o `/tmp` compartilhado do catalogo, onde outro usuario da maquina deixaria um banco do Trivy.
@@ -51,10 +53,49 @@ const run_1 = require("./run");
51
53
  function comCacheNoHost(t) {
52
54
  if (!t.cacheMounts?.length)
53
55
  return t;
54
- let args = t.args;
55
- for (const m of t.cacheMounts)
56
- args = args.map((a) => a.split(m.container).join(m.host));
57
- return { ...t, args };
56
+ const trocar = (args) => {
57
+ let resposta = args;
58
+ for (const m of t.cacheMounts ?? [])
59
+ resposta = resposta.map((a) => a.split(m.container).join(m.host));
60
+ return resposta;
61
+ };
62
+ return { ...t, args: trocar(t.args), ...(t.argsByStack
63
+ ? { argsByStack: Object.fromEntries(Object.entries(t.argsByStack).map(([stack, args]) => [stack, trocar(args ?? [])])) }
64
+ : {}) };
65
+ }
66
+ /** Semgrep é a garantia por arquivo: roda apenas conteúdo mudado e conserva os achados dos demais arquivos. */
67
+ function semgrepIncremental(trabalho, ferramenta, consulta, executar) {
68
+ if (ferramenta.name !== 'semgrep' || !consulta.anterior || consulta.escopo !== 'arquivo' || !ferramenta.argsByStack)
69
+ return null;
70
+ if (new Set(trabalho.units.map((u) => u.stack)).size !== trabalho.units.length)
71
+ return null;
72
+ const existentes = consulta.alterados.filter((arquivo) => {
73
+ try {
74
+ return fs.statSync(path.join(trabalho.ctx.headTree, arquivo)).isFile();
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ });
80
+ const argsByStack = {};
81
+ const unidades = trabalho.units.filter((unidade) => {
82
+ const prefixo = unidade.dir === '.' ? '' : `${unidade.dir.replace(/\\/g, '/')}/`;
83
+ const alvos = existentes.filter((arquivo) => !prefixo || arquivo.startsWith(prefixo));
84
+ const base = unidade.stack ? ferramenta.argsByStack?.[unidade.stack] : undefined;
85
+ if (!base?.length || alvos.length === 0 || !unidade.stack)
86
+ return false;
87
+ argsByStack[unidade.stack] = [...base.slice(0, -1), ...alvos.map((arquivo) => `{src}/${arquivo}`)];
88
+ return true;
89
+ });
90
+ const mudaram = new Set(consulta.alterados);
91
+ const preservados = consulta.anterior.head.filter((achado) => !mudaram.has(achado.file));
92
+ if (unidades.length === 0)
93
+ return { ...consulta.anterior, head: preservados };
94
+ const parcial = executar({ ...ferramenta, argsByStack }, unidades, true);
95
+ if (parcial.run.state !== 'ran')
96
+ return parcial;
97
+ const novos = parcial.head.filter((achado) => mudaram.has(achado.file));
98
+ return { ...parcial, head: [...preservados, ...novos], base: consulta.anterior.base };
58
99
  }
59
100
  if (require.main === module) {
60
101
  const trabalho = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
@@ -65,7 +106,14 @@ if (require.main === module) {
65
106
  // chooseExecutor escolher o nativo); sem ele, o Docker, como no Classic.
66
107
  const sonda = (0, executor_1.defaultProbe)();
67
108
  const probe = trabalho.nativoPrimeiro ? { ...sonda, dockerCli: () => false, dockerDaemon: () => false } : sonda;
68
- const r = (0, run_1.scanTool)(trabalho.nativoPrimeiro ? comCacheNoHost(trabalho.tool) : trabalho.tool, trabalho.units, trabalho.config, { ...resto, deadline: deadline ?? Infinity, probe, guard, images: new Map() });
109
+ const ferramenta = trabalho.nativoPrimeiro ? comCacheNoHost(trabalho.tool) : trabalho.tool;
110
+ const executar = (tool, units, somenteHead = false) => (0, run_1.scanTool)(tool, units, trabalho.config, { ...resto, ...(somenteHead ? { base: resto.head, baseTree: resto.headTree, rangeCommits: 0 } : {}),
111
+ deadline: deadline ?? Infinity, probe, guard, images: new Map() });
112
+ const consulta = trabalho.incremental ? (0, incremental_1.consultarCacheIncremental)(trabalho, ferramenta) : null;
113
+ const r = consulta?.reutilizado ?? (consulta ? semgrepIncremental(trabalho, ferramenta, consulta, executar) : null)
114
+ ?? executar(ferramenta, trabalho.units);
115
+ if (trabalho.incremental && !consulta?.reutilizado)
116
+ (0, incremental_1.gravarCacheIncremental)(trabalho, ferramenta, r);
69
117
  process.stdout.write(JSON.stringify(r));
70
118
  }
71
119
  finally {
@@ -70,6 +70,7 @@ const fs = __importStar(require("node:fs"));
70
70
  const path = __importStar(require("node:path"));
71
71
  const node_crypto_1 = require("node:crypto");
72
72
  const claude_bg_1 = require("./adapters/claude-bg");
73
+ const codex_events_1 = require("./adapters/codex-events");
73
74
  const ratelimit_1 = require("./ratelimit");
74
75
  const runtime_profiles_1 = require("./runtime-profiles");
75
76
  const codex_runner_1 = require("./adapters/codex-runner");
@@ -181,27 +182,19 @@ function classificarSessaoClaude(e) {
181
182
  const bloqueio = (fonte, motivo = 'runtime.unavailable', conclusaoNativa = false) => ({ terminal: true, classificacao: 'gate_blocked', motivo, fonte, conclusaoNativa, stop });
182
183
  // D12: o `done` classifica o texto final do agente; a fase só conclui com a prova do `ork`.
183
184
  const concluir = (sinal, conclusaoNativa) => {
185
+ // RM-075: indisponibilidade da tela não fabrica espera humana. Só uma pergunta
186
+ // efetivamente lida pode produzir human.pending.
187
+ const perguntaReal = e.perguntaReal?.() ?? false;
188
+ if (perguntaReal)
189
+ return { ...bloqueio(`${sinal}; a mensagem final pergunta ao humano`, 'human.pending', conclusaoNativa),
190
+ perguntaReal, dependeDoStop: true, diagnostico: 'a mensagem final pede uma resposta humana' };
184
191
  const prova = e.prova();
185
192
  const c = !prova.ok
186
193
  ? { ...bloqueio(`${sinal}; sem prova do ork: ${prova.fonte}`, prova.motivo ?? 'human.pending', conclusaoNativa),
187
194
  diagnostico: `sem prova do ork: ${prova.fonte}` }
188
195
  : e.pausaAoFim ? bloqueio(`${sinal}; ${prova.fonte}; pausa prevista ao fim do bloco`, 'human.pending', conclusaoNativa)
189
196
  : { terminal: true, classificacao: 'fase_concluida', motivo: null, fonte: `${sinal}; ${prova.fonte}`, conclusaoNativa, stop };
190
- return { ...c, dependeDoStop: true };
191
- };
192
- // RM-037 (defeitosdeco D-1): turno encerrado pelo Stop e sessão viva em `blocked` é o Claude Code
193
- // dizendo que o texto final pediu resposta. Nunca conclui direto, e a pausa humana abre sempre
194
- // (`human.pending`): GO-FIX (R6 do CHECK), sem a prova o motivo também é o humano, porque um
195
- // `artifact.missing` automático abriria correção sobre uma sessão viva que espera resposta.
196
- const esperaHumana = () => {
197
- const sinal = 'Stop correlacionado e sessão viva à espera humana (blocked)';
198
- const prova = e.prova();
199
- const c = !prova.ok
200
- ? { ...bloqueio(`${sinal}; sem prova do ork: ${prova.fonte}; o humano decide`, 'human.pending'),
201
- diagnostico: `a sessão espera resposta humana (blocked) e a fase ainda não tem a prova do ork: ${prova.fonte}` }
202
- : { ...bloqueio(`${sinal}; ${prova.fonte}; o humano decide`, 'human.pending'),
203
- diagnostico: 'a fase tem a prova do ork e a sessão espera resposta humana (blocked): o humano decide' };
204
- return { ...c, dependeDoStop: true };
197
+ return { ...c, perguntaReal, dependeDoStop: true };
205
198
  };
206
199
  const venceu = (desde) => agora - desde >= e.limiteMs;
207
200
  // Sem prova para concluir nem para bloquear: `runtime.unavailable` reexecutaria o mesmo prompt
@@ -276,7 +269,7 @@ function classificarSessaoClaude(e) {
276
269
  cursor.bloqueadoDesde ??= agora;
277
270
  if (agora - cursor.bloqueadoDesde < exports.INTERVALO_WATCH_CLAUDE_MS)
278
271
  return espera('turno encerrado pelo Stop com a sessão à espera humana (blocked); aguardando a segunda observação');
279
- return esperaHumana();
272
+ return concluir('Stop correlacionado e sessão blocked sem pergunta de permissão pendente', false);
280
273
  }
281
274
  delete cursor.bloqueadoDesde;
282
275
  // RM-037 (defeito 4 de 19 e 20/10/2026): turno encerrado pelo Stop e a sessão ociosa no prompt (`status: idle`). O
@@ -570,18 +563,18 @@ function commitConferido(cwd, sha, headNoDespacho) {
570
563
  */
571
564
  function provaDoOrk(raiz, threadId, sessao, eventos, fonte) {
572
565
  const intervalo = eventosDoIntervalo(eventos, sessao);
573
- const falta = (texto, extra = {}) => ({ ok: false, fonte: texto, motivo: sessao.fase === 'PLAN' ? 'artifact.missing' : 'human.pending', ...extra });
566
+ const falta = (texto, extra = {}) => ({ ok: false, fonte: texto, ...extra });
574
567
  const saida = saidaDoArtefato(raiz, threadId, sessao, fonte, eventos);
575
568
  if (saida) {
576
569
  if (!saida.produzido)
577
- return falta(`${saida.arquivo} não foi gravado no intervalo do despacho`);
570
+ return falta(`${saida.arquivo} não foi gravado no intervalo do despacho`, { motivo: 'artifact.missing' });
578
571
  if (sessao.fase !== 'CHECK')
579
572
  return { ok: true, fonte: `${saida.arquivo} gravado no intervalo do despacho` };
580
573
  const v = intervalo.filter(e => e.tipo === 'verify_run' && typeof e.commit === 'string').at(-1);
581
574
  const verify = v ? { eventId: String(v.eventId), commit: String(v.commit), ts: v.ts } : null;
582
575
  const veredito = saida.texto === null ? null : vereditoDoParecer(saida.texto);
583
576
  if (!veredito)
584
- return falta(`${saida.arquivo} gravado sem exatamente um veredito legível`, { veredito: null, verify });
577
+ return falta(`${saida.arquivo} gravado sem exatamente um veredito legível`, { motivo: 'artifact.missing', veredito: null, verify });
585
578
  return { ok: true, fonte: `${saida.arquivo} gravado com veredito ${veredito}`, veredito, verify };
586
579
  }
587
580
  if (sessao.fase === 'GO') {
@@ -589,9 +582,22 @@ function provaDoOrk(raiz, threadId, sessao, eventos, fonte) {
589
582
  const registrados = [...new Set(intervalo.filter(e => (e.tipo === 'mcp_git_committed' ||
590
583
  (e.tipo === 'commit' && e.sessionId === sessao.sessionId)) &&
591
584
  typeof e.commit === 'string' && SHA_COMMIT.test(e.commit)).map(e => String(e.commit)))].slice(-20);
592
- if (!registrados.length)
593
- return falta('nenhum commit registrado pelo núcleo no intervalo do despacho', { commits: [] });
594
585
  const cwd = fonte?.cwd, headNoDespacho = fonte?.head;
586
+ if (!registrados.length) {
587
+ if (!cwd || typeof headNoDespacho !== 'string')
588
+ return falta('HEAD da worktree no despacho não registrado; GO sem mudança não conferível', { commits: [] });
589
+ if ((0, procedencia_1.cwdLocalOuNulo)(raiz, threadId, cwd, 'ledger') === null)
590
+ return falta('cwd do despacho fora das worktrees registradas; GO sem mudança não conferível', { commits: [] });
591
+ if (headDaWorktree(cwd) !== headNoDespacho)
592
+ return falta('HEAD da worktree mudou sem commit registrado pelo núcleo', { commits: [], worktreeLimpa: false });
593
+ const status = (0, util_1.exec)('git', ['status', '--porcelain'], cwd, 30000);
594
+ if (!status.ok)
595
+ return falta('git status da worktree do despacho falhou', { commits: [], worktreeLimpa: false });
596
+ if (status.stdout.trim() !== '')
597
+ return falta('worktree com alterações não commitadas', { commits: [], worktreeLimpa: false });
598
+ return { ok: true, fonte: 'GO sem mudança: nenhum commit, HEAD igual ao despacho e worktree limpa',
599
+ commits: [], worktreeLimpa: true, resultado: 'go.sem-mudanca' };
600
+ }
595
601
  if (!cwd || typeof headNoDespacho !== 'string')
596
602
  return falta('HEAD da worktree no despacho não registrado; commit não conferível', { commits: [], recusados: registrados });
597
603
  // RM-047 (P2): o git da prova só roda na worktree registrada; o cwd vem do ledger.
@@ -733,9 +739,17 @@ function observarClaudeSobLock(ctx) {
733
739
  // e ao fecho.
734
740
  let erroLido;
735
741
  const erroDaTranscricao = () => erroLido !== undefined ? erroLido : (erroLido = ultimoErroDeApi(perfil, fonte.cwd, sessao.sessionId));
742
+ let perguntaLida;
743
+ const perguntaReal = () => {
744
+ if (perguntaLida !== undefined)
745
+ return perguntaLida;
746
+ const tela = (ctx.tela ?? (() => (0, claude_bg_1.logsDaSessao)(registro?.id ?? sessao.sessionId.slice(0, 8), 60, (0, claude_bg_1.ambienteDoPerfil)(perfil))))();
747
+ perguntaLida = tela.ok && (0, codex_events_1.mensagemFinalPedeHumano)(tela.texto);
748
+ return perguntaLida;
749
+ };
736
750
  const { c, falha } = comFalhaDeConta(classificarSessaoClaude({ sessao, eventos, consulta, registro, cursor, agoraMs: ctx.agoraMs,
737
751
  limiteMs: ctx.limiteMs, pausaAoFim: (0, thread_1.pausaNaThread)(thread, sessao.fase),
738
- prova: () => prova ??= provaDoOrk(raiz, thread.id, sessao, eventos, fonte), processo: processoNativo }), () => falhaDoErro(erroDaTranscricao(), ctx.agoraMs));
752
+ prova: () => prova ??= provaDoOrk(raiz, thread.id, sessao, eventos, fonte), processo: processoNativo, perguntaReal }), () => falhaDoErro(erroDaTranscricao(), ctx.agoraMs));
739
753
  (0, codex_runner_1.gravarAtomico)(arquivoCursor, cursor);
740
754
  // GO-FIX 3 (achado 1): cursor gravado numa thread que sumiu durante a consulta é desfeito aqui;
741
755
  // as pastas criadas pela observação, pelo `session-watcher`.
@@ -787,6 +801,8 @@ function observarClaudeSobLock(ctx) {
787
801
  evidencia: { fonte: 'claude agents --json --all', consultadoEm: consulta.consultadoEm, registro: evidenciaDoRegistro(registro) },
788
802
  ...(artefato ? { artefato: { arquivo: artefato.arquivo, sha256Base: artefato.sha256Base, sha256Atual: artefato.sha256 } } : {}),
789
803
  ...(prova ? { provaOrk: prova } : {}),
804
+ ...(prova?.resultado ? { resultado: prova.resultado } : {}),
805
+ ...(c.perguntaReal !== undefined ? { perguntaReal: c.perguntaReal } : {}),
790
806
  conclusaoNativa: c.conclusaoNativa, conclusaoNativaAusente: c.conclusaoNativa ? null : c.fonte,
791
807
  ...(perfil ? { perfil } : {}),
792
808
  ...(falha ? { falhaDeConta: { motivo: falha.motivo, resetEm: falha.resetEm, fonte: falha.fonte, trecho: falha.trecho } } : {}),
@@ -144,8 +144,14 @@ function classificarSessaoGrok(e) {
144
144
  const bloqueio = (motivo, fonte, extra = {}) => ({ terminal: true, classificacao: 'gate_blocked', motivo, fonte, conclusaoNativa: false, diagnostico: fonte, ...extra });
145
145
  const interrupcao = (codigo, sinal, fonte) => bloqueio('human.pending', fonte, { interrupcao: { codigo, sinal, stopReason: estado.fim?.stopReason ?? null },
146
146
  retomar: true, diagnostico: `${fonte}; ${RETOMADA}` });
147
+ const porNegacao = () => {
148
+ const comando = estado.negacaoTerminal.comando;
149
+ return bloqueio('policy.violation', `comando de terminal negado pela política fechada: ${comando}`, { comandoNegado: comando, diagnostico: `a sessão terminou imediatamente depois de negar o comando: ${comando}` });
150
+ };
147
151
  const pelaFim = (comRecibo) => {
148
152
  const fim = estado.fim;
153
+ if (estado.negacaoTerminal)
154
+ return porNegacao();
149
155
  if (fim.stopReason === grok_events_1.STOP_CANCELADO)
150
156
  return interrupcao(recibo?.exitCode ?? null, recibo?.signal ?? null, 'end com stopReason cancelled');
151
157
  if (fim.stopReason !== grok_events_1.STOP_FIM_NORMAL) {
@@ -180,6 +186,8 @@ function classificarSessaoGrok(e) {
180
186
  }
181
187
  if (recibo.erro)
182
188
  return bloqueio('runtime.unavailable', `o supervisor não iniciou o grok (${recibo.erro})`);
189
+ if (estado.negacaoTerminal && valido)
190
+ return porNegacao();
183
191
  if (recibo.exitCode === 130 || recibo.exitCode === 143 || recibo.signal === 'SIGINT' || recibo.signal === 'SIGTERM') {
184
192
  return interrupcao(recibo.exitCode, recibo.signal, `processo interrompido (${recibo.signal ?? `saida ${recibo.exitCode}`}) no recibo do supervisor`);
185
193
  }
@@ -406,6 +414,8 @@ function observarGrokSobLock(ctx) {
406
414
  ...(c.erroDoRuntime ? { erroDoRuntime: c.erroDoRuntime } : {}),
407
415
  ...(c.falhaDeConta ? { falhaDeConta: { motivo: c.falhaDeConta.motivo, resetEm: c.falhaDeConta.resetEm,
408
416
  fonte: c.falhaDeConta.fonte, trecho: c.falhaDeConta.trecho } } : {}),
417
+ ...(c.comandoNegado ? { comandoNegado: c.comandoNegado,
418
+ evidencia: `comando negado pela lista fechada do Grok: ${c.comandoNegado}` } : {}),
409
419
  // D18 da I-34: o diagnóstico vai ao pedido HITL e, como `detalhe` do `gate_blocked`, ao monitor e ao pulse.
410
420
  diagnostico: c.diagnostico, ...(detalhe ? { detalhe } : {}),
411
421
  observadoEm: new Date(agoraMs).toISOString(), gate: 'phase.dispatch', origem: 'sessions.watch',