@hackaiz/cli 0.0.0-stage → 0.1.0

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.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # Hackaiz CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Paquet préparé, non publié sur npm. Node 22 ou supérieur.
4
+
5
+ ```sh
6
+ export HACKAIZ_API_KEY='votre clé personnelle'
7
+ node bin/hackaiz.mjs analyze UUID_PLATEFORME CLE_DE_DEMANDE_STABLE
8
+ node bin/hackaiz.mjs status UUID_PLATEFORME UUID_DEMANDE
9
+ node bin/hackaiz.mjs verify UUID_PLATEFORME UUID_DEMANDE
10
+ node bin/hackaiz.mjs report UUID_PLATEFORME
11
+ node bin/hackaiz.mjs findings UUID_PLATEFORME
12
+ ```
13
+
14
+ La clé doit autoriser la lecture et le lancement pour la plateforme. Analyse passive seulement, propriété et consentement actuels exigés, crédits normaux. Aucun correctif publié, aucune fusion. Le client demande le traitement autonome puis consulte son état, sans appel d'exécution ni clic dans Hackaiz. Le service doit être publié et synchronisé avec Inngest pour démarrer effectivement les demandes. En cas de perte de connexion, consulter la même demande plutôt que créer une nouvelle demande. Une analyse déjà commencée n'est jamais rejouée automatiquement après interruption. Une analyse partielle ne démontre pas une couverture complète.
15
+
16
+ `npm pack --dry-run` permet d'inspecter le paquet avant publication par un éditeur autorisé. Ne jamais enregistrer de clé dans le dépôt ou les journaux.
17
+
18
+ `verify` lit seulement le rapport explicitement lié à la demande : analyse complète obligatoire, constats de cette analyse paginés. Il renvoie 0 sans High/Critical non résolu, 1 si de tels constats existent, 2 si incomplet/indisponible. Ce contrôle de la plateforme enregistrée n'est pas une vérification du commit de PR/déploiement ; ne pas le présenter comme telle.
19
+ ## Check before installing
20
+
21
+ ```sh
22
+ hackaiz check ./my-skill # Skill folder, MCP config, package.json, workflow or Dockerfile
23
+ hackaiz check . --sarif > hackaiz.sarif # CI: exit code 2 on a Danger verdict
24
+ ```
25
+
26
+ ### Claude Code hook (automatic check before a Skill, MCP or install command)
27
+
28
+ Add to `.claude/settings.json` (with `HACKAIZ_API_KEY` set in your environment):
29
+
30
+ ```json
31
+ { "hooks": { "PreToolUse": [ { "matcher": "Write|Edit|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "hackaiz hook claude" } ] } ] } }
32
+ ```
33
+
34
+ Only Skill/MCP/package/workflow writes and install commands are checked, and each new check uses credits, including unchanged content. Enabling this hook opts into paid checks before these actions; it is not background monitoring. A Danger verdict blocks the action. An incomplete response, missing key, service error or exhausted credits also blocks it: no missing verdict is treated as approval. Set `HACKAIZ_HOOK_FAIL_OPEN=1` only to explicitly choose advisory mode during failures. This hook checks submitted text, not all installed package code or runtime behaviour.
package/bin/check.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // `hackaiz check <path> [--sarif]`: static check of a Skill / MCP / package / workflow before installing.
2
+ // Exit 2 on a Danger verdict so CI can block. Secret, binary and generated files are never sent.
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+ import { join, relative, basename } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__']);
8
+ const SECRET = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx|keystore|jks)|id_rsa|id_ed25519|\.npmrc|\.netrc)$/i;
9
+ const BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|exe|dll|so|dylib|wasm|woff2?|ttf|mp[34]|lockb|bin)$/i;
10
+
11
+ export async function collectComponent(root, { maxFiles = 400, maxFileBytes = 400 * 1024 } = {}) {
12
+ const files = []; const skipped = [];
13
+ const s = await stat(root);
14
+ const walk = async (dir) => {
15
+ for (const e of await readdir(dir, { withFileTypes: true })) {
16
+ if (files.length >= maxFiles) return;
17
+ const full = join(dir, e.name);
18
+ if (e.isDirectory()) { if (!SKIP_DIR.has(e.name)) await walk(full); continue; }
19
+ const rel = relative(root, full).replace(/\\/g, '/');
20
+ const size = (await stat(full)).size;
21
+ if (SECRET.test(rel) || BINARY.test(rel) || size > maxFileBytes) { skipped.push(rel); continue; }
22
+ const content = await readFile(full, 'utf8');
23
+ if (content.includes('\u0000')) { skipped.push(rel); continue; }
24
+ files.push({ path: rel, content });
25
+ }
26
+ };
27
+ if (s.isDirectory()) await walk(root);
28
+ else if (!SECRET.test(root)) files.push({ path: basename(root), content: await readFile(root, 'utf8') });
29
+ return { files, skipped };
30
+ }
31
+
32
+ export function toSarif(result) {
33
+ const level = (s) => (s === 'Critical' || s === 'High' ? 'error' : s === 'Medium' ? 'warning' : 'note');
34
+ return {
35
+ version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
36
+ runs: [{
37
+ tool: { driver: { name: 'Hackaiz Import Gate', informationUri: 'https://hackaiz.com/check', rules: [...new Map((result.findings ?? []).map((f) => [f.rule, { id: f.rule, shortDescription: { text: f.title }, help: { text: f.action }, properties: { cwe: f.cwe, owasp: f.owasp } }])).values()] } },
38
+ results: (result.findings ?? []).map((f) => ({ ruleId: f.rule, level: level(f.severity), message: { text: `${f.title}: ${f.impact}` }, locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line } } }] })),
39
+ }],
40
+ };
41
+ }
42
+
43
+ export async function checkCommand(target, flags, { env, fetcher, output }) {
44
+ if (!target) throw Error('Usage: hackaiz check <path> [--sarif] [--lang fr]');
45
+ const origin = new URL(env.HACKAIZ_URL ?? 'https://hackaiz.com');
46
+ if (origin.protocol !== 'https:' || origin.pathname !== '/' || origin.username) throw Error('HACKAIZ_URL must be an HTTPS origin');
47
+ if (!/^hkz_[A-Za-z0-9_-]{40,}$/.test(env.HACKAIZ_API_KEY ?? '')) throw Error('HACKAIZ_API_KEY is required');
48
+ const { files } = await collectComponent(target);
49
+ if (!files.length) throw Error('Nothing to check (secret, binary and large files are never sent)');
50
+ const lang = flags.includes('--lang') && flags[flags.indexOf('--lang') + 1] === 'fr' ? 'fr' : 'en';
51
+ const response = await fetcher(`${origin.origin}/api/public/v1/component-checks`, {
52
+ method: 'POST', redirect: 'error', headers: { authorization: `Bearer ${env.HACKAIZ_API_KEY}`, 'content-type': 'application/json' },
53
+ body: JSON.stringify({ request_id: randomUUID(), channel: env.GITHUB_ACTIONS === 'true' ? 'ci' : 'cli', name: basename(target).slice(0, 120) || 'component', language: lang, files }),
54
+ });
55
+ const data = await response.json();
56
+ if (!response.ok) throw Error(`Hackaiz ${response.status}: ${data.error ?? 'request_failed'}`);
57
+ output(JSON.stringify(flags.includes('--sarif') ? toSarif(data) : data, null, 2));
58
+ return data.verdict === 'danger' ? 2 : 0;
59
+ }
package/bin/gate.mjs ADDED
@@ -0,0 +1,6 @@
1
+ /** A gate refers to one saved-platform run, never to an inferred PR commit. */
2
+ export function savedRunGate(analysis, findings) {
3
+ if (analysis?.status !== 'success') return { verdict: 'inconclusive', reason: 'analysis_not_complete', exitCode: 2 };
4
+ const dangerous = findings.filter(f => ['critical', 'high'].includes(String(f.severity).toLowerCase()) && f.status !== 'resolved');
5
+ return dangerous.length ? { verdict: 'fail', unresolvedHighOrCritical: dangerous.length, exitCode: 1 } : { verdict: 'pass', unresolvedHighOrCritical: 0, exitCode: 0 };
6
+ }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from 'node:crypto';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { savedRunGate } from './gate.mjs';
5
+
6
+ export async function run(args, { env = process.env, fetcher = fetch, output = console.log, stdin, errorOut, wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
7
+ const [command, projectId, value] = args;
8
+ if (command === 'help' || !command) {
9
+ output('hackaiz analyze <project-id> [request-key]\nhackaiz status <project-id> <run-id>\nhackaiz verify <project-id> <run-id>\nhackaiz report <project-id>\nhackaiz findings <project-id>\nhackaiz check <path> [--sarif] [--lang fr] (Skill/MCP/package/workflow before installing; exit 2 on Danger)\nhackaiz hook claude (Claude Code PreToolUse hook: blocks Danger Skills/MCP/installs)\nHACKAIZ_API_KEY: personal key. HACKAIZ_URL: optional HTTPS origin.');
10
+ return 0;
11
+ }
12
+ if (command === 'hook') {
13
+ const { hookCommand } = await import('./hook.mjs');
14
+ const chunks = []; for await (const c of (stdin ?? process.stdin)) chunks.push(c);
15
+ return hookCommand({ env, fetcher, stdin: Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8'), errorOut: errorOut ?? console.error });
16
+ }
17
+ if (command === 'check') {
18
+ const { checkCommand } = await import('./check.mjs');
19
+ return checkCommand(projectId, args.slice(2), { env, fetcher, output });
20
+ }
21
+ if (!['analyze', 'status', 'verify', 'report', 'findings'].includes(command)) throw Error('Unsupported command');
22
+ if (!/^[0-9a-f-]{36}$/i.test(projectId ?? '')) throw Error('A platform UUID is required');
23
+ const origin = new URL(env.HACKAIZ_URL ?? 'https://hackaiz.com');
24
+ if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw Error('HACKAIZ_URL must be an HTTPS origin');
25
+ if (!/^hkz_[A-Za-z0-9_-]{40,}$/.test(env.HACKAIZ_API_KEY ?? '')) throw Error('HACKAIZ_API_KEY is required');
26
+ const base = `${origin.origin}/api/public/v1/projects/${projectId}`;
27
+ const call = async (path, body) => {
28
+ const response = await fetcher(`${base}/${path}`, { method: body ? 'POST' : 'GET', headers: { authorization: `Bearer ${env.HACKAIZ_API_KEY}`, 'content-type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error' });
29
+ const data = await response.json();
30
+ if (!response.ok) throw Error(`Hackaiz ${response.status}: ${data.error ?? 'request_failed'}${data.purchaseUrl ? ` — ${data.purchaseUrl}` : ''}`);
31
+ return data;
32
+ };
33
+ if (command === 'analyze') {
34
+ const requestKey = value ?? randomUUID();
35
+ const accepted = await call('analysis-requests', { request_key: requestKey, channel: env.GITHUB_ACTIONS === 'true' ? 'ci' : 'cli' });
36
+ if (!/^[0-9a-f-]{36}$/i.test(accepted.requestId ?? '')) throw Error('Invalid analysis response');
37
+ output(JSON.stringify({ requestId: accepted.requestId, requestKey, status: accepted.status }));
38
+ if (accepted.delivery === 'configuration_required') throw Error('Autonomous processing is not configured; keep this request ID');
39
+ // GET-only polling. Closing the client does not cancel autonomous work.
40
+ // A lost response never creates another run or invokes execution again.
41
+ for (let attempt = 0; attempt < 360; attempt++) {
42
+ const result = await call(`analysis-runs/${accepted.requestId}`);
43
+ const status = result.request?.status;
44
+ if (['success', 'partial', 'failed', 'cancelled'].includes(status)) {
45
+ output(JSON.stringify(result));
46
+ return ['success', 'partial'].includes(status) ? 0 : 2;
47
+ }
48
+ await wait(5000);
49
+ }
50
+ output(JSON.stringify({ requestId: accepted.requestId, status: 'still_pending', note: 'Use status with the same request ID; do not create a replacement.' }));
51
+ return 2;
52
+ }
53
+ if (command === 'status') {
54
+ if (!/^[0-9a-f-]{36}$/i.test(value ?? '')) throw Error('A run UUID is required');
55
+ output(JSON.stringify(await call(`analysis-runs/${value}`))); return 0;
56
+ }
57
+ if (command === 'verify') {
58
+ if (!/^[0-9a-f-]{36}$/i.test(value ?? '')) throw Error('A run UUID is required');
59
+ const status = await call(`analysis-runs/${value}`);
60
+ const scan = status.analysis;
61
+ if (!scan || !/^[0-9a-f-]{36}$/i.test(scan.id ?? '') || status.request?.scan_id !== scan.id || scan.status !== 'success') {
62
+ output(JSON.stringify({ verdict: 'inconclusive', reason: 'analysis_not_complete_or_unlinked', runId: value }));
63
+ return 2;
64
+ }
65
+ const findings = []; const seen = new Set(); let offset = 0; let total;
66
+ for (;;) {
67
+ const result = await call(`findings?scanId=${scan.id}&status=open&limit=100&offset=${offset}`);
68
+ const invalid = !Array.isArray(result.data) || !Number.isSafeInteger(result.total) || result.total < 0 || result.total > 100000 || (total !== undefined && result.total !== total)
69
+ || result.data.length > 100 || offset + result.data.length > result.total || (!result.data.length && offset < result.total)
70
+ || result.data.some(f => !f || f.scan_id !== scan.id || typeof f.id !== 'string' || !f.id || seen.has(f.id) || !['critical', 'high', 'medium', 'low', 'info'].includes(String(f.severity).toLowerCase()) || f.status !== 'open');
71
+ if (invalid || (Array.isArray(result.data) && new Set(result.data.map(f => f.id)).size !== result.data.length)) {
72
+ output(JSON.stringify({ verdict: 'inconclusive', reason: 'invalid_findings_pagination', runId: value, scanId: scan.id }));
73
+ return 2;
74
+ }
75
+ total = result.total;
76
+ result.data.forEach(f => seen.add(f.id));
77
+ findings.push(...result.data); offset += result.data.length;
78
+ if (offset === total) break;
79
+ }
80
+ const verdict = savedRunGate(scan, findings);
81
+ output(JSON.stringify({ ...verdict, runId: value, scanId: scan.id, scope: 'saved_platform_not_pr_commit' }));
82
+ return verdict.exitCode;
83
+ }
84
+ let offset = 0;
85
+ do {
86
+ const result = await call(`${command === 'report' ? 'scans' : 'findings'}?limit=100&offset=${offset}`);
87
+ output(JSON.stringify(result));
88
+ offset += result.data?.length ?? 0;
89
+ if (!result.data?.length || offset >= result.total) break;
90
+ } while (true);
91
+ return 0;
92
+ }
93
+
94
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
95
+ run(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(error.message); process.exitCode = 1; });
96
+ }
package/bin/hook.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // Claude Code PreToolUse hook: `hackaiz hook claude`. Reads the hook JSON on stdin.
2
+ // Only Skill/MCP/package/workflow writes and install commands are checked (each check uses credits).
3
+ // Exit 2 blocks Danger, incomplete or unavailable checks. HACKAIZ_HOOK_FAIL_OPEN=1 explicitly opts into advisory mode.
4
+ import { randomUUID } from 'node:crypto';
5
+ const COMPONENT = /(^|\/)(SKILL\.md|mcp\.json|\.mcp\.json|claude_desktop_config\.json|package\.json|requirements\.txt|Dockerfile)$|\.github\/workflows\/[^/]+\.ya?ml$/i;
6
+ const INSTALL = /\b(claude\s+mcp\s+add|npx\s+-y|npm\s+(i|install|add)\b|pnpm\s+add|yarn\s+add|bun\s+add|pip3?\s+install|uvx|pipx\s+install)\b|\b(curl|wget)\b[^|]*\|\s*(ba|z)?sh\b/i;
7
+
8
+ export function hookTarget(event) {
9
+ const tool = event?.tool_name; const input = event?.tool_input ?? {};
10
+ if (['Write', 'Edit', 'MultiEdit'].includes(tool) && typeof input.file_path === 'string' && COMPONENT.test(input.file_path.replace(/\\/g, '/'))) {
11
+ const content = tool === 'Write' ? input.content : tool === 'Edit' ? input.new_string : (input.edits ?? []).map((e) => e.new_string).join('\n');
12
+ if (typeof content === 'string' && content) return { name: input.file_path.split(/[\\/]/).slice(-2).join('/'), files: [{ path: input.file_path.split(/[\\/]/).pop(), content }] };
13
+ }
14
+ if (tool === 'Bash' && typeof input.command === 'string' && INSTALL.test(input.command)) return { name: 'install-command', files: [{ path: 'install.sh', content: input.command }] };
15
+ return null;
16
+ }
17
+
18
+ export async function hookCommand({ env, fetcher, stdin, errorOut }) {
19
+ const fr = env.HACKAIZ_LANG === 'fr';
20
+ const unavailable = (detail = '') => {
21
+ errorOut(fr ? `Vérification Hackaiz indisponible${detail} ; action non vérifiée.` : `Hackaiz check unavailable${detail}; action not checked.`);
22
+ return env.HACKAIZ_HOOK_FAIL_OPEN === '1' ? 0 : 2;
23
+ };
24
+ let event; try { event = JSON.parse(stdin); } catch { return unavailable(); }
25
+ const target = hookTarget(event);
26
+ if (!target) return 0;
27
+ if (!/^hkz_[A-Za-z0-9_-]{40,}$/.test(env.HACKAIZ_API_KEY ?? '')) return unavailable();
28
+ try {
29
+ const origin = new URL(env.HACKAIZ_URL ?? 'https://hackaiz.com');
30
+ if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) return unavailable();
31
+ // Opting into this hook authorizes paid checks, not automatic installation on a missing verdict.
32
+ const r = await fetcher(`${origin.origin}/api/public/v1/component-checks`, {
33
+ method: 'POST', redirect: 'error', headers: { authorization: `Bearer ${env.HACKAIZ_API_KEY}`, 'content-type': 'application/json' },
34
+ signal: AbortSignal.timeout(30_000),
35
+ body: JSON.stringify({ request_id: randomUUID(), channel: 'hook', name: target.name.slice(0, 120), language: env.HACKAIZ_LANG === 'fr' ? 'fr' : 'en', files: target.files }),
36
+ });
37
+ const data = await r.json();
38
+ if (!r.ok) return unavailable(` (${r.status})`);
39
+ if (data.verdict === 'danger') {
40
+ errorOut(`${fr ? 'Hackaiz bloque cette action' : 'Hackaiz blocked this'}: ${data.headline}. ${data.summary} ${(data.findings ?? []).slice(0, 3).map((f) => `[${f.rule}] ${f.title} (${f.file}:${f.line})`).join(' ')}`);
41
+ return 2;
42
+ }
43
+ if (data.complete !== true || !['safe', 'attention'].includes(data.verdict)) return unavailable();
44
+ if (data.verdict === 'attention') errorOut(`${data.headline}. ${data.summary}`);
45
+ return 0;
46
+ } catch { return unavailable(); }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@hackaiz/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Authenticated Hackaiz passive analysis client",
5
+ "type": "module",
6
+ "bin": { "hackaiz": "bin/hackaiz.mjs" },
7
+ "files": ["bin", "README.md"],
8
+ "engines": { "node": ">=22" },
9
+ "scripts": { "test": "node --test test/*.check.mjs" }
6
10
  }