Sign In

@looptail/cli

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@looptail/cli - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+99
-13
dist/cli.js

@@ -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": {

@@ -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 @@