From 505fc75ca2129949eb488c588ebe1766166c8f60 Mon Sep 17 00:00:00 2001 From: Javier Astaroth Date: Tue, 27 Oct 2026 14:03:41 -0600 Subject: [PATCH 1/5] Show engine progress in PR scans --- README.md | 12 ++++ docs/pr-progress-map.md | 47 +++++++++++++ src/cli/pr_cmd.ts | 133 ++++++++++++++++++++++++++---------- src/cli/pr_progress.test.ts | 57 ++++++++++++++++ src/cli/pr_progress.ts | 80 ++++++++++++++++++++++ 5 files changed, 294 insertions(+), 35 deletions(-) create mode 100644 docs/pr-progress-map.md create mode 100644 src/cli/pr_progress.test.ts create mode 100644 src/cli/pr_progress.ts diff --git a/README.md b/README.md index 763d0d1..dfb38a3 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,18 @@ sntnl doctor --deep sntnl integrity ``` +`sntnl pr` muestra un mapa en vivo del proceso en stderr. Sentinel Lite descarga +y analiza localmente el diff del PR. Sentinel Cloud lee `package.json` del SHA +exacto del head, valida `remote_scan` con la clave elegida y envía ese manifiesto +al endpoint remoto, consumiendo un pulso. El endpoint actual no analiza el diff +completo y no expone estados de sus analizadores internos; el mapa lo indica y +muestra solo el estado y veredicto agregados que devuelve la API. + +El mapa usa color y animación en terminales interactivas, y texto sencillo en +CI o pipes. `NO_COLOR` y `TERM=dumb` desactivan color. Consulta +[`docs/pr-progress-map.md`](./docs/pr-progress-map.md) para el detalle de alcance +y estados. + --- ## 📸 Galería Visual de la Interfaz (Sentinel CLI) diff --git a/docs/pr-progress-map.md b/docs/pr-progress-map.md new file mode 100644 index 0000000..a1c7541 --- /dev/null +++ b/docs/pr-progress-map.md @@ -0,0 +1,47 @@ +# Pull request scan progress map + +The interactive `sntnl pr` flow renders a compact progress map on stderr. It +uses a spinner while a step is active and retains readable text in non-TTY +output. The displayed steps are driven by the selected engine's real actions. + +## Sentinel Lite + +Lite fetches the pull request diff through GitHub CLI and analyzes that diff on +the local machine. The map shows PR head resolution, diff retrieval, local +analysis, and the resulting findings. Its scope is changed lines and context +provided by the diff; it is not a full repository scan. + +## Sentinel Cloud + +Cloud mode pins the PR head SHA, retrieves `package.json` from that exact commit, +checks the stored API key's `remote_scan` capability, and submits the manifest +to `/api/scan/remote`. A successful request consumes one pulse. The output +reports the returned engine version, verdict, risk score, findings, and pulse +balance when the usage endpoint is available. + +This API currently accepts an npm manifest, not the pull request diff or a +repository archive. It returns an aggregate result and no per-analyzer execution +events. Therefore the progress map reports the remote request and aggregate +response only; it does not imply that individual Cloud tools were observed. +Cloud and Lite findings and verdicts are shown using their selected engine's +result, not silently substituted for one another. + +## Terminal behavior and credentials + +Example Cloud view: + +```text +Sentinel CLI | PR #42 | Sentinel Cloud | mapa de análisis + [OK] Metadatos del pull request: COMPLETE — head 3f8a21c4d019 + [OK] Manifiesto del commit propuesto: COMPLETE — 812 bytes; SHA 3f8a21c4d019 + [OK] Acceso a Sentinel Cloud: COMPLETE — plan Team + [OK] Motor de análisis: COMPLETE — motor Cloud 4.3; 2 hallazgos + [OK] Veredicto y evidencia: COMPLETE — respuesta agregada SUSPICIOUS + i El endpoint devuelve un resultado agregado; no publica eventos internos de sus analizadores. +``` + +Progress is written to stderr. Colors and redraw animation are enabled only for +interactive terminals and are disabled by `NO_COLOR` or `TERM=dumb`. The API key +is sent only as a bearer token over the configured Sentinel Cloud URL; the map +prints its label or plan, never the token. GitHub commands use argument arrays, +and the manifest is read from the pinned commit SHA. diff --git a/src/cli/pr_cmd.ts b/src/cli/pr_cmd.ts index 78440c6..ce1b9b8 100644 --- a/src/cli/pr_cmd.ts +++ b/src/cli/pr_cmd.ts @@ -1,17 +1,21 @@ import * as readline from 'readline'; -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import * as os from 'os'; import * as pc from 'picocolors'; import { listKeys, getKey, maskToken, DISCLAIMER_TEXT, StoredKey } from './cloud_keys_cmd'; import { LiteScanner, LiteFinding } from '../core/lite/lite_scanner'; +import { PRProgressMap } from './pr_progress'; +import { fetchCapabilities, fetchRemoteScan, fetchUsage, getResolvedBaseUrl } from './cloud/cloud_client'; +import type { RemoteScanResult } from './cloud/cloud_client'; interface PRInfo { number: number; title: string; author?: { login: string }; headRefName?: string; + headRefOid?: string; } function checkGitHubSession(): { authenticated: boolean; account: string; method: string } { @@ -183,13 +187,17 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number const defaultRepo = options?.repo || detectCurrentRepo(); const repoInput = await ask(`\nRepositorio [owner/repo] (default: ${defaultRepo}): `); const repo = repoInput || defaultRepo; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + console.log(pc.red('Repositorio inválido. Usa el formato owner/repo.')); + continue; + } // 3. Enumerar PRs abiertos console.log(`\nConsultando Pull Requests abiertos en ${repo}...`); let prList: PRInfo[] = []; try { - const cmd = `gh pr list --repo ${repo} --json number,title,author,headRefName --limit 15`; - const raw = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) throw new Error('Repositorio inválido; usa owner/repo.'); + const raw = execFileSync('gh', ['pr', 'list', '--repo', repo, '--json', 'number,title,author,headRefName,headRefOid', '--limit', '15'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); prList = JSON.parse(raw); } catch (err: any) { console.log(pc.yellow(`⚠ No se pudo enumerar vía gh (${err.message}).`)); @@ -229,6 +237,10 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number } const engineChoice = await ask('Selecciona [1 o 2]: '); + if (engineChoice !== '1' && engineChoice !== '2') { + console.log(pc.yellow('Selecciona 1 o 2 para elegir un motor.')); + continue; + } let selectedKey: StoredKey | null = null; if (engineChoice === '2') { @@ -250,45 +262,96 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number } } - // 5. Descargar Diff de GitHub - console.log(`\nDescargando diff de PR #${prNumber} en ${repo}...`); - let diff = ''; + const useCloud = engineChoice === '2' && !!selectedKey; + const progress = new PRProgressMap(useCloud ? 'cloud' : 'lite', prNumber); + let findings: LiteFinding[] = []; + let verdict = 'PASS'; + let riskScore = 0; + let cloudVersion = ''; + let authorInfo = prList.find(p => p.number === prNumber)?.author?.login || 'unknown'; + let activeStage = 'PR'; try { - diff = execSync(`gh pr diff ${prNumber} --repo ${repo}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + progress.update({ stage: 'PR', state: 'RUNNING', detail: `resolviendo head de PR #${prNumber}` }); + const listed = prList.find(p => p.number === prNumber); + const metadata = listed?.headRefOid ? listed : JSON.parse(execFileSync('gh', ['pr', 'view', String(prNumber), '--repo', repo, '--json', 'headRefOid,author'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const headSha = String(metadata.headRefOid || ''); + if (!/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('GitHub no devolvió el SHA completo del head del PR.'); + authorInfo = metadata.author?.login || authorInfo; + progress.update({ stage: 'PR', state: 'COMPLETE', detail: `head ${headSha.slice(0, 12)}` }); + + if (useCloud) { + activeStage = 'MANIFEST'; + progress.update({ stage: 'MANIFEST', state: 'RUNNING', detail: 'leyendo package.json del commit del PR' }); + const content = JSON.parse(execFileSync('gh', ['api', `repos/${repo}/contents/package.json?ref=${encodeURIComponent(headSha)}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 })); + if (content.encoding !== 'base64' || typeof content.content !== 'string') throw new Error('GitHub no devolvió package.json en base64.'); + const manifest = Buffer.from(content.content.replace(/\s/g, ''), 'base64').toString('utf8'); + if (Buffer.byteLength(manifest, 'utf8') > 262144) throw new Error('package.json supera el límite Cloud de 256 KB.'); + JSON.parse(manifest); + progress.update({ stage: 'MANIFEST', state: 'COMPLETE', detail: `${Buffer.byteLength(manifest, 'utf8')} bytes; SHA ${headSha.slice(0, 12)}` }); + + activeStage = 'AUTH'; + progress.update({ stage: 'AUTH', state: 'RUNNING', detail: 'validando permiso remote_scan' }); + const baseUrl = getResolvedBaseUrl(undefined, process.env); + const capabilities = await fetchCapabilities(selectedKey!.token, baseUrl); + if (!capabilities.ok) throw new Error(`No se pudo validar la clave Sentinel Cloud${capabilities.status ? ` (HTTP ${capabilities.status})` : ''}.`); + if (!capabilities.data.capabilities.remote_scan) throw new Error('El plan de esta clave no tiene habilitado remote_scan.'); + progress.update({ stage: 'AUTH', state: 'COMPLETE', detail: `plan ${capabilities.data.planLabel}` }); + + activeStage = 'ENGINE'; + progress.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'enviando manifiesto; esta solicitud consume 1 pulso' }); + const remote = await fetchRemoteScan(manifest, 'npm', selectedKey!.token, baseUrl); + if (!remote.ok) throw new Error(remote.kind === 'quota' ? 'Cuota de pulsos agotada.' : remote.kind === 'busy' ? 'El motor Cloud está ocupado; no completó este análisis.' : remote.kind === 'auth' ? 'La clave de Sentinel Cloud fue rechazada.' : remote.error || 'Sentinel Cloud no completó el análisis.'); + const cloudResult: RemoteScanResult = remote.data; + cloudVersion = cloudResult.engineVersion || 'desconocida'; + verdict = cloudResult.verdict; + riskScore = cloudResult.riskScore; + findings = cloudResult.findings.map(f => ({ type: 'cloud', intent: 'remote scan', file: 'package.json', line: 0, severity: String(f.severity).toUpperCase(), title: f.title, description: f.message || f.description || f.title, snippet: f.evidence || '' })); + progress.update({ stage: 'ENGINE', state: 'COMPLETE', detail: `motor Cloud ${cloudVersion}; ${findings.length} hallazgos` }); + activeStage = 'RESULT'; + progress.update({ stage: 'RESULT', state: 'COMPLETE', detail: `respuesta agregada ${verdict}` }); + const usage = await fetchUsage(selectedKey!.token, baseUrl); + if (usage.ok) console.log(pc.dim(`Pulsos: ${usage.data.remaining} restantes de ${usage.data.limit}.`)); + } else { + activeStage = 'DIFF'; + progress.update({ stage: 'DIFF', state: 'RUNNING', detail: 'descargando diff con GitHub CLI' }); + const diff = execFileSync('gh', ['pr', 'diff', String(prNumber), '--repo', repo], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); + progress.update({ stage: 'DIFF', state: 'COMPLETE', detail: `${Buffer.byteLength(diff, 'utf8')} bytes` }); + activeStage = 'ENGINE'; + progress.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'Sentinel Lite analiza el diff local' }); + const files = parseUnifiedDiff(diff); + const scanner = new LiteScanner(); + const auditResult = await scanner.auditPR(repo, prNumber, authorInfo, files); + findings = auditResult.findings || []; + progress.update({ stage: 'ENGINE', state: 'COMPLETE', detail: `${findings.length} señales locales` }); + const c = findings.filter(f => f.severity === 'CRITICAL').length; + const h = findings.filter(f => f.severity === 'HIGH').length; + const m = findings.filter(f => f.severity === 'MEDIUM').length; + const l = findings.filter(f => f.severity === 'LOW').length; + riskScore = c ? Math.min(100, 70 + c * 10) : h ? Math.min(69, 40 + h * 10) : m ? Math.min(39, 15 + m * 5) : l ? 10 : 0; + verdict = c || h >= 2 ? 'BLOCK' : h || m ? 'FLAG' : 'PASS'; + activeStage = 'RESULT'; + progress.update({ stage: 'RESULT', state: 'COMPLETE', detail: `resultado Lite ${verdict}` }); + } } catch (err: any) { - console.log(pc.red(`Error al obtener diff de GitHub: ${err.message}`)); - return; + progress.update({ stage: activeStage, state: 'FAILED', detail: err.message || String(err) }); + progress.finish(); + console.log(pc.red(`No se completó el análisis: ${err.message || err}`)); + continue; + } finally { + progress.finish(); } + const criticalCount = findings.filter(f => f.severity.toUpperCase() === 'CRITICAL').length; + const highCount = findings.filter(f => f.severity.toUpperCase() === 'HIGH').length; + const mediumCount = findings.filter(f => f.severity.toUpperCase() === 'MEDIUM').length; + const lowCount = findings.filter(f => ['LOW', 'WARNING'].includes(f.severity.toUpperCase())).length; - console.log(`Diff descargado (${Buffer.byteLength(diff, 'utf8')} bytes). Analizando con Motor Lite...`); - const files = parseUnifiedDiff(diff); - const scanner = new LiteScanner(); - const authorInfo = prList.find(p => p.number === prNumber)?.author?.login || 'unknown'; - - const auditResult = await scanner.auditPR(repo, prNumber, authorInfo, files); - const findings = auditResult.findings || []; - - const criticalCount = findings.filter(f => f.severity === 'CRITICAL').length; - const highCount = findings.filter(f => f.severity === 'HIGH').length; - const mediumCount = findings.filter(f => f.severity === 'MEDIUM').length; - const lowCount = findings.filter(f => f.severity === 'LOW').length; - - let riskScore = 0; - if (criticalCount > 0) riskScore = Math.min(100, 70 + criticalCount * 10); - else if (highCount > 0) riskScore = Math.min(69, 40 + highCount * 10); - else if (mediumCount > 0) riskScore = Math.min(39, 15 + mediumCount * 5); - else if (lowCount > 0) riskScore = 10; - - let verdict = 'PASS'; - if (criticalCount > 0 || highCount >= 2) verdict = 'BLOCK'; - else if (highCount > 0 || mediumCount > 0) verdict = 'FLAG'; - - console.log('\n' + pc.cyan('══════════════════════════════════════════════════════════')); + console.log('\n' + pc.cyan('──────────────────────────────────────────────────────────')); console.log(pc.bold(`RESULTADO DEL ESCANEO: PR #${prNumber} (${repo})`)); + console.log(pc.dim(cloudVersion ? `Motor: Sentinel Cloud ${cloudVersion}; evidencia enviada: package.json del head fijado.` : 'Motor: Sentinel Lite local; alcance: diff del pull request.')); const verdictColor = verdict === 'BLOCK' ? pc.red : (verdict === 'FLAG' ? pc.yellow : pc.green); console.log(`VEREDICTO: ${verdictColor(`[ ${verdict} ]`)} · RIESGO: ${riskScore}/100`); console.log(`HALLAZGOS: CRITICAL: ${criticalCount} | HIGH: ${highCount} | MEDIUM: ${mediumCount} | LOW: ${lowCount}`); - console.log(pc.cyan('══════════════════════════════════════════════════════════')); + console.log(pc.cyan('──────────────────────────────────────────────────────────')); if (findings.length > 0) { findings.slice(0, 10).forEach((f, idx) => { @@ -299,7 +362,7 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number console.log(pc.dim(` ... y ${findings.length - 10} hallazgo(s) más.`)); } } else { - console.log(pc.green(' ✓ Sin hallazgos sospechosos ni credenciales expuestas.')); + console.log(pc.green(' Sin hallazgos reportados por el motor seleccionado.')); } // Post-scan menu diff --git a/src/cli/pr_progress.test.ts b/src/cli/pr_progress.test.ts new file mode 100644 index 0000000..866ac62 --- /dev/null +++ b/src/cli/pr_progress.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { Writable } from 'stream'; +import { PRProgressMap } from './pr_progress'; + +class Capture extends Writable { + public isTTY = false; + public data = ''; + _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + this.data += chunk.toString(); + callback(); + } +} + +describe('PR progress map', () => { + it('shows actual stage states and cloud API scope without ANSI in pipes', () => { + const stream = new Capture(); + const map = new PRProgressMap('cloud', 17, stream); + map.update({ stage: 'MANIFEST', state: 'COMPLETE', detail: 'package.json loaded' }); + map.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'remote request' }); + map.finish(); + expect(stream.data).toContain('PR #17'); + expect(stream.data).toContain('MANIFEST'); + expect(stream.data).toContain('remote request'); + expect(stream.data).toContain('no publica eventos internos'); + expect(stream.data).not.toContain('\u001b['); + }); + + it('renders the lite path without claiming a cloud analysis', () => { + const stream = new Capture(); + const map = new PRProgressMap('lite', 5, stream); + map.update({ stage: 'DIFF', state: 'COMPLETE', detail: '100 bytes' }); + map.finish(); + expect(stream.data).toContain('Sentinel Lite'); + expect(stream.data).toContain('Diff del pull request: COMPLETE'); + expect(stream.data).not.toContain('no publica eventos internos'); + }); + + it('adds terminal colors only when stderr is an interactive TTY and NO_COLOR is unset', () => { + const previousNoColor = process.env.NO_COLOR; + const previousTerm = process.env.TERM; + delete process.env.NO_COLOR; + process.env.TERM = 'xterm'; + try { + const stream = new Capture(); + stream.isTTY = true; + const map = new PRProgressMap('lite', 2, stream); + map.update({ stage: 'PR', state: 'COMPLETE', detail: 'head pinned' }); + map.finish(); + expect(stream.data).toContain('\u001b[32m'); + } finally { + if (previousNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = previousNoColor; + if (previousTerm === undefined) delete process.env.TERM; + else process.env.TERM = previousTerm; + } + }); +}); diff --git a/src/cli/pr_progress.ts b/src/cli/pr_progress.ts new file mode 100644 index 0000000..6345882 --- /dev/null +++ b/src/cli/pr_progress.ts @@ -0,0 +1,80 @@ +import type { Writable } from 'stream'; + +export type PRProgressState = 'PENDING' | 'RUNNING' | 'COMPLETE' | 'FAILED' | 'NOT_RUN'; +export interface PRProgressEvent { stage: string; state: PRProgressState; detail?: string; } + +const LABELS: Record = { + PR: 'Metadatos del pull request', + DIFF: 'Diff del pull request', + MANIFEST: 'Manifiesto del commit propuesto', + AUTH: 'Acceso a Sentinel Cloud', + ENGINE: 'Motor de análisis', + RESULT: 'Veredicto y evidencia', +}; +const ICONS: Record = { PENDING: 'WAIT', RUNNING: 'RUN', COMPLETE: 'OK', FAILED: 'FAIL', NOT_RUN: 'SKIP' }; + +function clean(value: string): string { + return value.replace(/[\u0000-\u001f\u007f\u001b]/g, ' ').replace(/\s+/g, ' ').slice(0, 120); +} + +export class PRProgressMap { + private readonly stream: Writable & { isTTY?: boolean }; + private readonly color: boolean; + private readonly states = new Map(); + private drawn = false; + private closed = false; + private frame = 0; + private timer: NodeJS.Timeout | null = null; + + constructor(private readonly engine: 'lite' | 'cloud', private readonly prNumber: number, stream: Writable & { isTTY?: boolean } = process.stderr) { + this.stream = stream; + this.color = process.env.NO_COLOR == null && !!this.stream.isTTY && process.env.TERM !== 'dumb'; + const stages = engine === 'lite' ? ['PR', 'DIFF', 'ENGINE', 'RESULT'] : ['PR', 'MANIFEST', 'AUTH', 'ENGINE', 'RESULT']; + for (const stage of stages) this.states.set(stage, { state: 'PENDING', detail: '' }); + } + + update(event: PRProgressEvent): void { + if (this.closed || !this.states.has(event.stage)) return; + const item = this.states.get(event.stage)!; + item.state = event.state; + item.detail = clean(event.detail || ''); + if (event.state === 'RUNNING' && !this.timer && this.stream.isTTY) { + this.timer = setInterval(() => { this.frame++; this.draw(); }, 100); + } + if (event.state !== 'RUNNING' && this.timer) { clearInterval(this.timer); this.timer = null; } + this.draw(event.stage); + } + + finish(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + if (this.stream.isTTY) { + if (this.drawn) this.stream.write('\n'); + } else this.draw(); + this.closed = true; + } + + private draw(changedStage?: string): void { + if (this.closed) return; + const rows = ['Sentinel CLI | PR #' + this.prNumber + ' | ' + (this.engine === 'cloud' ? 'Sentinel Cloud' : 'Sentinel Lite') + ' | mapa de análisis']; + for (const [stage, item] of this.states) { + const icon = item.state === 'RUNNING' ? 'RUN ' + ['|', '/', '-', '\\'][this.frame % 4] : ICONS[item.state]; + let painted = icon; + if (this.color) { + const code = item.state === 'FAILED' ? 31 : item.state === 'COMPLETE' ? 32 : item.state === 'RUNNING' ? 36 : 90; + painted = '\u001b[' + code + 'm' + icon + '\u001b[0m'; + } + rows.push(' [' + painted + '] ' + LABELS[stage] + ': ' + item.state + (item.detail ? ' — ' + item.detail : '')); + } + if (this.engine === 'cloud') rows.push(' i El endpoint devuelve un resultado agregado; no publica eventos internos de sus analizadores.'); + if (!this.stream.isTTY && changedStage) { + const rowIndex = ['PR', 'DIFF', 'MANIFEST', 'AUTH', 'ENGINE', 'RESULT'].indexOf(changedStage); + if (rowIndex >= 0) this.stream.write(rows[rowIndex + 1] + '\n'); + } else if (!this.stream.isTTY) this.stream.write(rows.join('\n') + '\n'); + else { + if (this.drawn) this.stream.write('\u001b[' + rows.length + 'A\u001b[J'); + this.stream.write(rows.join('\n') + '\n'); + } + this.drawn = true; + } +} From b4f4085d54c88ca134c5d6bea47930d948c24d99 Mon Sep 17 00:00:00 2001 From: Javier Astaroth Date: Tue, 27 Oct 2026 14:06:13 -0600 Subject: [PATCH 2/5] Fix PR progress map test labels --- src/cli/pr_progress.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/pr_progress.test.ts b/src/cli/pr_progress.test.ts index 866ac62..c9e7123 100644 --- a/src/cli/pr_progress.test.ts +++ b/src/cli/pr_progress.test.ts @@ -19,7 +19,7 @@ describe('PR progress map', () => { map.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'remote request' }); map.finish(); expect(stream.data).toContain('PR #17'); - expect(stream.data).toContain('MANIFEST'); + expect(stream.data).toContain('Manifiesto del commit propuesto: COMPLETE'); expect(stream.data).toContain('remote request'); expect(stream.data).toContain('no publica eventos internos'); expect(stream.data).not.toContain('\u001b['); From bc02c8a2b10bd198a81c89c745b8afe521ee7f23 Mon Sep 17 00:00:00 2001 From: Javier Astaroth Date: Tue, 27 Oct 2026 14:20:44 -0600 Subject: [PATCH 3/5] fix(cli): use full Sentinel Cloud PR scans --- README.md | 18 +++++--- docs/pr-progress-map.md | 23 +++++----- src/cli/cloud/cloud_client.test.ts | 34 +++++++++++++++ src/cli/cloud/cloud_client.ts | 68 ++++++++++++++++++++++++++++++ src/cli/pr_cmd.ts | 41 +++++++++--------- src/cli/pr_progress.test.ts | 4 +- src/cli/pr_progress.ts | 6 +-- 7 files changed, 152 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index dfb38a3..8500899 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,18 @@ sntnl doctor --deep sntnl integrity ``` -`sntnl pr` muestra un mapa en vivo del proceso en stderr. Sentinel Lite descarga -y analiza localmente el diff del PR. Sentinel Cloud lee `package.json` del SHA -exacto del head, valida `remote_scan` con la clave elegida y envía ese manifiesto -al endpoint remoto, consumiendo un pulso. El endpoint actual no analiza el diff -completo y no expone estados de sus analizadores internos; el mapa lo indica y -muestra solo el estado y veredicto agregados que devuelve la API. +`sntnl pr` muestra un mapa en vivo del proceso en stderr. Puedes elegir Sentinel +Lite para analizar localmente el diff, o Sentinel Cloud para enviar el repositorio +y número de PR al endpoint completo de análisis de Cloud. La solicitud queda +fijada al SHA que mostró GitHub; Cloud rechaza el inicio si el head cambió. El +modo Cloud requiere una API key con permiso de escaneo y consume un pulso. La key +de Sentinel se envía únicamente a Sentinel Cloud; Sentinel CLI no reenvía el +token de GitHub. El acceso a repositorios privados depende de que Sentinel Cloud +pueda acceder al repositorio. + +El endpoint devuelve el resultado consolidado del motor Cloud, no eventos en +vivo de cada analizador interno. El mapa muestra autenticación, PR/SHA, ejecución +y resultado con esa limitación explícita. El mapa usa color y animación en terminales interactivas, y texto sencillo en CI o pipes. `NO_COLOR` y `TERM=dumb` desactivan color. Consulta diff --git a/docs/pr-progress-map.md b/docs/pr-progress-map.md index a1c7541..fb9a28a 100644 --- a/docs/pr-progress-map.md +++ b/docs/pr-progress-map.md @@ -13,14 +13,14 @@ provided by the diff; it is not a full repository scan. ## Sentinel Cloud -Cloud mode pins the PR head SHA, retrieves `package.json` from that exact commit, -checks the stored API key's `remote_scan` capability, and submits the manifest -to `/api/scan/remote`. A successful request consumes one pulse. The output -reports the returned engine version, verdict, risk score, findings, and pulse -balance when the usage endpoint is available. - -This API currently accepts an npm manifest, not the pull request diff or a -repository archive. It returns an aggregate result and no per-analyzer execution +Cloud mode pins the PR head SHA, checks the stored API key's `remote_scan` +capability, and submits the repository and PR number to `/api/scan/pr`. Cloud +validates that the PR head still matches the pinned SHA before scanning. A +successful full PR scan consumes one pulse. The output reports the returned +engine version, verdict, risk score, findings, and pulse balance when the usage +endpoint is available. + +The full PR endpoint returns an aggregate result and no per-analyzer execution events. Therefore the progress map reports the remote request and aggregate response only; it does not imply that individual Cloud tools were observed. Cloud and Lite findings and verdicts are shown using their selected engine's @@ -33,7 +33,7 @@ Example Cloud view: ```text Sentinel CLI | PR #42 | Sentinel Cloud | mapa de análisis [OK] Metadatos del pull request: COMPLETE — head 3f8a21c4d019 - [OK] Manifiesto del commit propuesto: COMPLETE — 812 bytes; SHA 3f8a21c4d019 + [OK] Pull request fijado por SHA: COMPLETE — Cloud resolverá el PR completo en el SHA 3f8a21c4d019 [OK] Acceso a Sentinel Cloud: COMPLETE — plan Team [OK] Motor de análisis: COMPLETE — motor Cloud 4.3; 2 hallazgos [OK] Veredicto y evidencia: COMPLETE — respuesta agregada SUSPICIOUS @@ -43,5 +43,6 @@ Sentinel CLI | PR #42 | Sentinel Cloud | mapa de análisis Progress is written to stderr. Colors and redraw animation are enabled only for interactive terminals and are disabled by `NO_COLOR` or `TERM=dumb`. The API key is sent only as a bearer token over the configured Sentinel Cloud URL; the map -prints its label or plan, never the token. GitHub commands use argument arrays, -and the manifest is read from the pinned commit SHA. +prints its label or plan, never the token. Sentinel CLI does not forward its +GitHub CLI credential to Cloud. Public repositories can be scanned directly; +private repository access depends on Cloud's configured GitHub access. diff --git a/src/cli/cloud/cloud_client.test.ts b/src/cli/cloud/cloud_client.test.ts index 3bf5114..b879ee3 100644 --- a/src/cli/cloud/cloud_client.test.ts +++ b/src/cli/cloud/cloud_client.test.ts @@ -10,6 +10,7 @@ import { getResolvedBaseUrl, resolveToken, fetchLookup, + fetchCloudPRScan, } from './cloud_client'; import type { CapabilitiesEnvelope, Session } from './cloud_client'; @@ -222,6 +223,39 @@ describe('cloud_client fetchCapabilities', () => { }); }); +describe('cloud_client fetchCloudPRScan', () => { + const fetchMock = vi.fn(); + + beforeEach(() => vi.stubGlobal('fetch', fetchMock)); + afterEach(() => vi.unstubAllGlobals()); + + it('sends the full PR coordinates, expected SHA, bearer key, and idempotency key', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ level1: { status: 'PASS', score: 0 }, level3: { samples: [] }, engine_version: '5.0' }), { status: 200 })); + const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'sentinel-secret', 'https://cloud.example.com///', { idempotencyKey: 'scan-id-1' }); + expect(result.ok).toBe(true); + const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://cloud.example.com/api/scan/pr'); + expect(request.headers).toMatchObject({ Authorization: 'Bearer sentinel-secret', 'Idempotency-Key': 'scan-id-1' }); + expect(JSON.parse(String(request.body))).toEqual({ repo: 'acme/app', prNumber: 17, expectedHeadSha: 'a'.repeat(40) }); + }); + + it('returns the Cloud result without translating the engine verdict', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ level1: { status: 'DEGRADED', score: 32 }, level3: { samples: [{ severity: 'HIGH' }] } }), { status: 200 })); + const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'key', 'https://cloud.example.com'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.level1.status).toBe('DEGRADED'); + }); + + it('surfaces auth, quota, and stale-head responses as distinct errors', async () => { + for (const [status, kind] of [[401, 'auth'], [429, 'quota'], [409, 'conflict']] as const) { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'rejected' }), { status })); + const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'key', 'https://cloud.example.com'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.kind).toBe(kind); + } + }); +}); + describe('cloud_client loginWithToken', () => { let fetchMock: ReturnType; diff --git a/src/cli/cloud/cloud_client.ts b/src/cli/cloud/cloud_client.ts index a41176b..597b9bd 100644 --- a/src/cli/cloud/cloud_client.ts +++ b/src/cli/cloud/cloud_client.ts @@ -358,6 +358,74 @@ export interface RemoteScanResult { explanation?: string[]; } +/** Full hosted pull request scan returned by Sentinel Cloud's PR engine. */ +export interface CloudPRScanResult { + level1: { + status?: string; + score?: number; + filesAnalyzed?: number; + files_analyzed?: number; + [key: string]: unknown; + }; + level3?: { samples?: Array> }; + engine_version?: string; + jobId?: string; + scan_degraded?: boolean; + degraded_reasons?: string[]; + breakdown?: Record; + [key: string]: unknown; +} + +export type CloudPRScanFetchResult = + | { ok: true; data: CloudPRScanResult } + | { ok: false; kind: 'auth' | 'forbidden' | 'quota' | 'busy' | 'bad_request' | 'conflict' | 'network'; status?: number; error?: string }; + +function validateCloudPRScan(value: unknown): CloudPRScanResult | null { + if (!isRecord(value) || !isRecord(value.level1)) return null; + if (value.level3 !== undefined && (!isRecord(value.level3) || (value.level3.samples !== undefined && !Array.isArray(value.level3.samples)))) return null; + return value as unknown as CloudPRScanResult; +} + +/** Requests the complete Cloud PR scan. The bearer key is sent only to Sentinel Cloud. */ +export async function fetchCloudPRScan( + repo: string, + prNumber: number, + expectedHeadSha: string, + token: string, + baseUrl: string, + opts?: { timeoutMs?: number; idempotencyKey?: string }, +): Promise { + const url = baseUrl.replace(/\\+$/, '') + '/api/scan/pr'; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? 180000); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Idempotency-Key': opts?.idempotencyKey ?? randomUUID(), + }, + body: JSON.stringify({ repo, prNumber, expectedHeadSha }), + signal: controller.signal, + }); + if (res.status === 401) return { ok: false, kind: 'auth', status: 401 }; + if (res.status === 403) return { ok: false, kind: 'forbidden', status: 403, error: await readErrorBody(res) }; + if (res.status === 429) return { ok: false, kind: 'quota', status: 429, error: await readErrorBody(res) }; + if (res.status === 503) return { ok: false, kind: 'busy', status: 503, error: await readErrorBody(res) }; + if (res.status === 409) return { ok: false, kind: 'conflict', status: 409, error: await readErrorBody(res) }; + if (res.status === 400 || res.status === 413) return { ok: false, kind: 'bad_request', status: res.status, error: await readErrorBody(res) }; + if (!res.ok) return { ok: false, kind: 'network', status: res.status, error: await readErrorBody(res) }; + const data = validateCloudPRScan(await res.json()); + return data ? { ok: true, data } : { ok: false, kind: 'network', status: res.status, error: 'Invalid Cloud PR scan response.' }; + } catch (err) { + return { ok: false, kind: 'network', error: err instanceof Error ? err.message : String(err) }; + } finally { + clearTimeout(timer); + } +} + export type RemoteScanFetchResult = | { ok: true; data: RemoteScanResult } | { diff --git a/src/cli/pr_cmd.ts b/src/cli/pr_cmd.ts index ce1b9b8..1702cec 100644 --- a/src/cli/pr_cmd.ts +++ b/src/cli/pr_cmd.ts @@ -7,8 +7,7 @@ import * as pc from 'picocolors'; import { listKeys, getKey, maskToken, DISCLAIMER_TEXT, StoredKey } from './cloud_keys_cmd'; import { LiteScanner, LiteFinding } from '../core/lite/lite_scanner'; import { PRProgressMap } from './pr_progress'; -import { fetchCapabilities, fetchRemoteScan, fetchUsage, getResolvedBaseUrl } from './cloud/cloud_client'; -import type { RemoteScanResult } from './cloud/cloud_client'; +import { fetchCapabilities, fetchCloudPRScan, fetchUsage, getResolvedBaseUrl } from './cloud/cloud_client'; interface PRInfo { number: number; @@ -280,14 +279,8 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number progress.update({ stage: 'PR', state: 'COMPLETE', detail: `head ${headSha.slice(0, 12)}` }); if (useCloud) { - activeStage = 'MANIFEST'; - progress.update({ stage: 'MANIFEST', state: 'RUNNING', detail: 'leyendo package.json del commit del PR' }); - const content = JSON.parse(execFileSync('gh', ['api', `repos/${repo}/contents/package.json?ref=${encodeURIComponent(headSha)}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 })); - if (content.encoding !== 'base64' || typeof content.content !== 'string') throw new Error('GitHub no devolvió package.json en base64.'); - const manifest = Buffer.from(content.content.replace(/\s/g, ''), 'base64').toString('utf8'); - if (Buffer.byteLength(manifest, 'utf8') > 262144) throw new Error('package.json supera el límite Cloud de 256 KB.'); - JSON.parse(manifest); - progress.update({ stage: 'MANIFEST', state: 'COMPLETE', detail: `${Buffer.byteLength(manifest, 'utf8')} bytes; SHA ${headSha.slice(0, 12)}` }); + activeStage = 'SOURCE'; + progress.update({ stage: 'SOURCE', state: 'COMPLETE', detail: `Cloud resolverá el PR completo en el SHA ${headSha.slice(0, 12)}` }); activeStage = 'AUTH'; progress.update({ stage: 'AUTH', state: 'RUNNING', detail: 'validando permiso remote_scan' }); @@ -298,15 +291,23 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number progress.update({ stage: 'AUTH', state: 'COMPLETE', detail: `plan ${capabilities.data.planLabel}` }); activeStage = 'ENGINE'; - progress.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'enviando manifiesto; esta solicitud consume 1 pulso' }); - const remote = await fetchRemoteScan(manifest, 'npm', selectedKey!.token, baseUrl); - if (!remote.ok) throw new Error(remote.kind === 'quota' ? 'Cuota de pulsos agotada.' : remote.kind === 'busy' ? 'El motor Cloud está ocupado; no completó este análisis.' : remote.kind === 'auth' ? 'La clave de Sentinel Cloud fue rechazada.' : remote.error || 'Sentinel Cloud no completó el análisis.'); - const cloudResult: RemoteScanResult = remote.data; - cloudVersion = cloudResult.engineVersion || 'desconocida'; - verdict = cloudResult.verdict; - riskScore = cloudResult.riskScore; - findings = cloudResult.findings.map(f => ({ type: 'cloud', intent: 'remote scan', file: 'package.json', line: 0, severity: String(f.severity).toUpperCase(), title: f.title, description: f.message || f.description || f.title, snippet: f.evidence || '' })); - progress.update({ stage: 'ENGINE', state: 'COMPLETE', detail: `motor Cloud ${cloudVersion}; ${findings.length} hallazgos` }); + progress.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'Cloud analiza el PR completo; esta solicitud consume 1 pulso' }); + const remote = await fetchCloudPRScan(repo, prNumber, headSha, selectedKey!.token, baseUrl); + if (!remote.ok) throw new Error(remote.kind === 'quota' ? 'Cuota de pulsos agotada.' : remote.kind === 'busy' ? 'El motor Cloud está ocupado; no completó este análisis.' : remote.kind === 'auth' ? 'La clave de Sentinel Cloud fue rechazada.' : remote.error || `Sentinel Cloud no completó el análisis${remote.status ? ` (HTTP ${remote.status})` : ''}.`); + const cloudResult = remote.data; + cloudVersion = cloudResult.engine_version || 'desconocida'; + verdict = String(cloudResult.level1.status || 'UNKNOWN').toUpperCase(); + riskScore = typeof cloudResult.level1.score === 'number' ? cloudResult.level1.score : 0; + findings = (cloudResult.level3?.samples || []).map((f) => { + const severity = String(f.severity || f.riskLevel || 'INFO').toUpperCase(); + const title = String(f.title || f.type || f.detector || 'Cloud finding'); + const description = String(f.description || f.message || f.why || title); + const file = String(f.file || f._file || f._fullPath || 'unknown'); + const line = Number(f.line || f.line_number || 0) || 0; + const snippet = String(f.snippet || f.evidence || ''); + return { type: String(f.type || f.detector || 'cloud'), intent: String(f.category || 'Sentinel Cloud finding'), file, line, severity, title, description, snippet }; + }); + progress.update({ stage: 'ENGINE', state: 'COMPLETE', detail: `motor Cloud ${cloudVersion}; ${findings.length} hallazgos; ${cloudResult.scan_degraded ? 'análisis degradado' : verdict}` }); activeStage = 'RESULT'; progress.update({ stage: 'RESULT', state: 'COMPLETE', detail: `respuesta agregada ${verdict}` }); const usage = await fetchUsage(selectedKey!.token, baseUrl); @@ -347,7 +348,7 @@ export async function runPRWorkflow(options?: { repo?: string; prNumber?: number console.log('\n' + pc.cyan('──────────────────────────────────────────────────────────')); console.log(pc.bold(`RESULTADO DEL ESCANEO: PR #${prNumber} (${repo})`)); - console.log(pc.dim(cloudVersion ? `Motor: Sentinel Cloud ${cloudVersion}; evidencia enviada: package.json del head fijado.` : 'Motor: Sentinel Lite local; alcance: diff del pull request.')); + console.log(pc.dim(cloudVersion ? `Motor: Sentinel Cloud ${cloudVersion}; alcance: análisis completo del PR en el SHA fijado.` : 'Motor: Sentinel Lite local; alcance: diff del pull request.')); const verdictColor = verdict === 'BLOCK' ? pc.red : (verdict === 'FLAG' ? pc.yellow : pc.green); console.log(`VEREDICTO: ${verdictColor(`[ ${verdict} ]`)} · RIESGO: ${riskScore}/100`); console.log(`HALLAZGOS: CRITICAL: ${criticalCount} | HIGH: ${highCount} | MEDIUM: ${mediumCount} | LOW: ${lowCount}`); diff --git a/src/cli/pr_progress.test.ts b/src/cli/pr_progress.test.ts index c9e7123..a3663d1 100644 --- a/src/cli/pr_progress.test.ts +++ b/src/cli/pr_progress.test.ts @@ -15,11 +15,11 @@ describe('PR progress map', () => { it('shows actual stage states and cloud API scope without ANSI in pipes', () => { const stream = new Capture(); const map = new PRProgressMap('cloud', 17, stream); - map.update({ stage: 'MANIFEST', state: 'COMPLETE', detail: 'package.json loaded' }); + map.update({ stage: 'SOURCE', state: 'COMPLETE', detail: 'PR SHA pinned' }); map.update({ stage: 'ENGINE', state: 'RUNNING', detail: 'remote request' }); map.finish(); expect(stream.data).toContain('PR #17'); - expect(stream.data).toContain('Manifiesto del commit propuesto: COMPLETE'); + expect(stream.data).toContain('Pull request fijado por SHA: COMPLETE'); expect(stream.data).toContain('remote request'); expect(stream.data).toContain('no publica eventos internos'); expect(stream.data).not.toContain('\u001b['); diff --git a/src/cli/pr_progress.ts b/src/cli/pr_progress.ts index 6345882..a53993f 100644 --- a/src/cli/pr_progress.ts +++ b/src/cli/pr_progress.ts @@ -6,7 +6,7 @@ export interface PRProgressEvent { stage: string; state: PRProgressState; detail const LABELS: Record = { PR: 'Metadatos del pull request', DIFF: 'Diff del pull request', - MANIFEST: 'Manifiesto del commit propuesto', + SOURCE: 'Pull request fijado por SHA', AUTH: 'Acceso a Sentinel Cloud', ENGINE: 'Motor de análisis', RESULT: 'Veredicto y evidencia', @@ -29,7 +29,7 @@ export class PRProgressMap { constructor(private readonly engine: 'lite' | 'cloud', private readonly prNumber: number, stream: Writable & { isTTY?: boolean } = process.stderr) { this.stream = stream; this.color = process.env.NO_COLOR == null && !!this.stream.isTTY && process.env.TERM !== 'dumb'; - const stages = engine === 'lite' ? ['PR', 'DIFF', 'ENGINE', 'RESULT'] : ['PR', 'MANIFEST', 'AUTH', 'ENGINE', 'RESULT']; + const stages = engine === 'lite' ? ['PR', 'DIFF', 'ENGINE', 'RESULT'] : ['PR', 'SOURCE', 'AUTH', 'ENGINE', 'RESULT']; for (const stage of stages) this.states.set(stage, { state: 'PENDING', detail: '' }); } @@ -68,7 +68,7 @@ export class PRProgressMap { } if (this.engine === 'cloud') rows.push(' i El endpoint devuelve un resultado agregado; no publica eventos internos de sus analizadores.'); if (!this.stream.isTTY && changedStage) { - const rowIndex = ['PR', 'DIFF', 'MANIFEST', 'AUTH', 'ENGINE', 'RESULT'].indexOf(changedStage); + const rowIndex = ['PR', 'DIFF', 'SOURCE', 'AUTH', 'ENGINE', 'RESULT'].indexOf(changedStage); if (rowIndex >= 0) this.stream.write(rows[rowIndex + 1] + '\n'); } else if (!this.stream.isTTY) this.stream.write(rows.join('\n') + '\n'); else { From 1d00973e50676144c1592aa78d7637dc62ec7e91 Mon Sep 17 00:00:00 2001 From: Javier Astaroth Date: Tue, 27 Oct 2026 14:22:54 -0600 Subject: [PATCH 4/5] fix(cli): normalize Cloud API base URL --- src/cli/cloud/cloud_client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/cloud/cloud_client.ts b/src/cli/cloud/cloud_client.ts index 597b9bd..4196564 100644 --- a/src/cli/cloud/cloud_client.ts +++ b/src/cli/cloud/cloud_client.ts @@ -395,7 +395,7 @@ export async function fetchCloudPRScan( baseUrl: string, opts?: { timeoutMs?: number; idempotencyKey?: string }, ): Promise { - const url = baseUrl.replace(/\\+$/, '') + '/api/scan/pr'; + const url = baseUrl.replace(/\/+$/, '') + '/api/scan/pr'; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? 180000); try { From 996d02ea1b06408229c36c2153d8e33a479e4c91 Mon Sep 17 00:00:00 2001 From: Javier Astaroth Date: Tue, 27 Oct 2026 14:32:33 -0600 Subject: [PATCH 5/5] chore(cli): prepare 4.3.1 release --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a035c0c..1728c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v4.3.1 + +- Added a live terminal progress map for pull-request scans in Sentinel CLI. +- Fixed Sentinel Cloud mode to scan the full PR through Cloud Pulses, pinned to the GitHub head SHA; the API key is sent only to Sentinel Cloud. +- Kept Sentinel Lite as an independent local option that analyzes the PR diff without using Cloud Pulses. + ## v4.3.0 - Added `sntnl hardening` with agile, strict, and CI preference profiles, independently configurable toggles, and an informational local audit of package scripts, `.npmrc`, and lockfile signals. diff --git a/package-lock.json b/package-lock.json index 0855e8c..14cb634 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sntnl-cli", - "version": "4.3.0", + "version": "4.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sntnl-cli", - "version": "4.3.0", + "version": "4.3.1", "license": "BUSSL-1.1", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", diff --git a/package.json b/package.json index f62612b..b18a450 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sntnl-cli", - "version": "4.3.0", + "version": "4.3.1", "description": "Sentinel Security Intelligence — SAST, supply chain, threat intel, and Skills/MCP for AI coding agents.", "main": "dist/cli/main.js", "bin": {