@looptail/cli
Advanced tools
+99
-13
@@ -15,9 +15,10 @@ #!/usr/bin/env node | ||
| import { basename, join } from 'node:path'; | ||
| import { TrailStore, verifyChain } from '@looptail/sdk'; | ||
| import { canonical, TrailStore, verifyBytes, verifyChain } from '@looptail/sdk'; | ||
| import { buildZip } from './zip.js'; | ||
| const SPEC_URL = 'https://github.com/maxfain/looptail/blob/main/spec/trail-format.md'; | ||
| const DEFAULT_ENDPOINT = 'https://ingest.looptail.ai'; | ||
| const USAGE = `looptail — every loop leaves a tail | ||
| usage: | ||
| looptail verify [--app <name>] [--trail-dir <dir>] [--since <window>] [--json] | ||
| looptail verify [--app <name>] [--trail-dir <dir>] [--since <window>] [--anchors] [--json] | ||
| looptail export [--app <name>] [--trail-dir <dir>] [--out <file.zip>] | ||
@@ -30,2 +31,8 @@ | ||
| (the full chain is always verified — integrity is global) | ||
| --anchors also fetch the server's signed chain-head receipts and | ||
| check them: receipt signatures verify against the server | ||
| key, and every anchored head exists in the local chain | ||
| (needs LOOPTAIL_API_KEY; endpoint via --endpoint or | ||
| LOOPTAIL_ENDPOINT, default ${DEFAULT_ENDPOINT}) | ||
| --endpoint <url> ingest endpoint for --anchors | ||
| --json machine-readable output | ||
@@ -39,2 +46,4 @@ | ||
| trailDir: process.env.LOOPTAIL_TRAIL_DIR ?? '.looptail', | ||
| anchors: false, | ||
| endpoint: process.env.LOOPTAIL_ENDPOINT ?? DEFAULT_ENDPOINT, | ||
| json: false, | ||
@@ -56,2 +65,6 @@ }; | ||
| } | ||
| else if (arg === '--anchors') | ||
| args.anchors = true; | ||
| else if (arg === '--endpoint') | ||
| args.endpoint = argv[++i] ?? args.endpoint; | ||
| else if (arg === '--json') | ||
@@ -89,3 +102,45 @@ args.json = true; | ||
| } | ||
| function verify(args) { | ||
| /** Fetch the server's signed chain-head receipts and check them against the | ||
| * local chain: every receipt must verify against the server key, and every | ||
| * anchored head must be a hash the local trail actually contains. */ | ||
| async function checkAnchors(args, app, events) { | ||
| const apiKey = process.env.LOOPTAIL_API_KEY; | ||
| if (!apiKey) | ||
| fail('--anchors needs LOOPTAIL_API_KEY set'); | ||
| const url = `${args.endpoint.replace(/\/$/, '')}/v1/anchors/${encodeURIComponent(app)}`; | ||
| let payload; | ||
| try { | ||
| const res = await fetch(url, { headers: { authorization: `Bearer ${apiKey}` } }); | ||
| if (!res.ok) { | ||
| return { total: 0, errors: [`anchor fetch failed: ${res.status} from ${url}`], newest: null }; | ||
| } | ||
| payload = (await res.json()); | ||
| } | ||
| catch (err) { | ||
| return { total: 0, errors: [`anchor fetch failed: ${err.message}`], newest: null }; | ||
| } | ||
| const anchors = Array.isArray(payload.anchors) ? payload.anchors : []; | ||
| const serverKey = typeof payload.serverKey === 'string' ? payload.serverKey : ''; | ||
| const localHashes = new Set(events.map((e) => e.hash)); | ||
| const errors = []; | ||
| let newest = null; | ||
| for (const [i, raw] of anchors.entries()) { | ||
| const { sig, ...anchorPayload } = raw; | ||
| const { org, app: anchorApp, head, count, ts } = anchorPayload; | ||
| // the server signs the canonical JSON of {org, app, head, count, ts} | ||
| const signed = new TextEncoder().encode(canonical({ org, app: anchorApp, head, count, ts })); | ||
| if (!verifyBytes(signed, String(sig ?? ''), serverKey)) { | ||
| errors.push(`anchor ${i} (${ts}): receipt signature invalid`); | ||
| continue; | ||
| } | ||
| if (!localHashes.has(head)) { | ||
| errors.push(`anchor ${i} (${ts}): anchored head ${head.slice(0, 12)}… is not in the local trail — ` + | ||
| 'the local file is missing events the server witnessed, or was rewritten'); | ||
| } | ||
| if (!newest || ts > newest.ts) | ||
| newest = { head, count, ts }; | ||
| } | ||
| return { total: anchors.length, errors, newest }; | ||
| } | ||
| async function verify(args) { | ||
| const app = resolveApp(args); | ||
@@ -95,2 +150,4 @@ const store = new TrailStore(args.trailDir, app); | ||
| const result = verifyChain(events); | ||
| const anchorCheck = args.anchors ? await checkAnchors(args, app, events) : null; | ||
| const anchorsOk = anchorCheck === null || anchorCheck.errors.length === 0; | ||
| const windowCount = args.sinceMs === undefined | ||
@@ -103,3 +160,3 @@ ? events.length | ||
| path: join(args.trailDir, `${app}.jsonl`), | ||
| ok: result.ok, | ||
| ok: result.ok && anchorsOk, | ||
| events: events.length, | ||
@@ -109,4 +166,14 @@ eventsInWindow: windowCount, | ||
| keyChanges: result.keyChanges, | ||
| ...(anchorCheck | ||
| ? { | ||
| anchors: { | ||
| total: anchorCheck.total, | ||
| ok: anchorsOk, | ||
| errors: anchorCheck.errors, | ||
| newest: anchorCheck.newest, | ||
| }, | ||
| } | ||
| : {}), | ||
| }, null, 2) + '\n'); | ||
| process.exit(result.ok ? 0 : 1); | ||
| process.exit(result.ok && anchorsOk ? 0 : 1); | ||
| } | ||
@@ -119,12 +186,31 @@ if (result.ok) { | ||
| } | ||
| process.exit(0); | ||
| } | ||
| process.stdout.write(`✖ trail failed verification (${app})\n`); | ||
| for (const error of result.errors.slice(0, 5)) { | ||
| process.stdout.write(` ${error}\n`); | ||
| else { | ||
| process.stdout.write(`✖ trail failed verification (${app})\n`); | ||
| for (const error of result.errors.slice(0, 5)) { | ||
| process.stdout.write(` ${error}\n`); | ||
| } | ||
| if (result.errors.length > 5) { | ||
| process.stdout.write(` …and ${result.errors.length - 5} more\n`); | ||
| } | ||
| } | ||
| if (result.errors.length > 5) { | ||
| process.stdout.write(` …and ${result.errors.length - 5} more\n`); | ||
| if (anchorCheck) { | ||
| if (anchorCheck.total === 0 && anchorsOk) { | ||
| process.stdout.write('⚠ no server anchors recorded yet (sync with an API key first)\n'); | ||
| } | ||
| else if (anchorsOk) { | ||
| const { newest } = anchorCheck; | ||
| process.stdout.write(`✔ ${anchorCheck.total} server anchor${anchorCheck.total === 1 ? '' : 's'} · ` + | ||
| `receipts verified · heads present locally` + | ||
| (newest ? ` (newest: ${formatCount(newest.count)} events at ${newest.ts})` : '') + | ||
| '\n'); | ||
| } | ||
| else { | ||
| process.stdout.write('✖ server anchors failed verification\n'); | ||
| for (const error of anchorCheck.errors.slice(0, 5)) { | ||
| process.stdout.write(` ${error}\n`); | ||
| } | ||
| } | ||
| } | ||
| process.exit(1); | ||
| process.exit(result.ok && anchorsOk ? 0 : 1); | ||
| } | ||
@@ -202,3 +288,3 @@ function exportPack(args) { | ||
| if (command === 'verify') { | ||
| verify(parseArgs(rest)); | ||
| await verify(parseArgs(rest)); | ||
| } | ||
@@ -205,0 +291,0 @@ else if (command === 'export') { |
+13
-4
| { | ||
| "name": "@looptail/cli", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "Verify Looptail trails: hash chains and Ed25519 signatures, from your terminal.", | ||
@@ -10,3 +10,6 @@ "license": "Apache-2.0", | ||
| }, | ||
| "files": ["dist", "README.md"], | ||
| "files": [ | ||
| "dist", | ||
| "README.md" | ||
| ], | ||
| "engines": { | ||
@@ -20,3 +23,9 @@ "node": ">=18" | ||
| }, | ||
| "keywords": ["ai", "audit-trail", "llm", "verify", "eu-ai-act"], | ||
| "keywords": [ | ||
| "ai", | ||
| "audit-trail", | ||
| "llm", | ||
| "verify", | ||
| "eu-ai-act" | ||
| ], | ||
| "homepage": "https://looptail.ai", | ||
@@ -29,3 +38,3 @@ "repository": { | ||
| "dependencies": { | ||
| "@looptail/sdk": "^0.1.0" | ||
| "@looptail/sdk": "^0.2.0" | ||
| }, | ||
@@ -32,0 +41,0 @@ "devDependencies": { |
+7
-1
@@ -23,3 +23,3 @@ # @looptail/cli | ||
| ``` | ||
| looptail verify [--app <name>] [--trail-dir <dir>] [--since <window>] [--json] | ||
| looptail verify [--app <name>] [--trail-dir <dir>] [--since <window>] [--anchors] [--json] | ||
| looptail export [--app <name>] [--trail-dir <dir>] [--out <file.zip>] | ||
@@ -32,2 +32,8 @@ ``` | ||
| chain is always verified — integrity is global. | ||
| - `--anchors` — also fetch the server's signed chain-head receipts and check | ||
| them: receipt signatures must verify against the server key, and every | ||
| anchored head must exist in the local chain (a missing head means the local | ||
| file lost or rewrote events the server witnessed). Needs `LOOPTAIL_API_KEY`; | ||
| endpoint via `--endpoint` or `LOOPTAIL_ENDPOINT` (default | ||
| `https://ingest.looptail.ai`). | ||
| - `--json` — machine-readable output (verify) | ||
@@ -34,0 +40,0 @@ |
Network access
Supply chain riskThis module accesses the network.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
29375
19.16%356
31.85%52
13.04%5
66.67%1
Infinity%+ Added
- Removed
Updated