trooth 0.1.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +118 -14
  2. package/bin/trooth.mjs +405 -74
  3. package/package.json +14 -5
package/README.md CHANGED
@@ -1,39 +1,143 @@
1
1
  # trooth
2
2
 
3
- Compliance, automated — from your terminal. Scan a Terraform plan against SOC 2, ISO 27001,
4
- GDPR, HIPAA, NIST AI RMF, and the EU AI Act, and get a signed Compliance Delta back.
3
+ The trust network your terminal can check. Read any company's **witnessed** record on
4
+ the [Trooth Network](https://trooth.co): identity, security, privacy, AI practices and
5
+ more, each witnessed, signed and dated. It also scans your own Terraform plans against
6
+ SOC 2, ISO 27001, GDPR, HIPAA, NIST AI RMF and the EU AI Act.
5
7
 
6
- **Advisory and report-only. Trooth never applies changes to your infrastructure.**
8
+ **`check` reads only public, already-published records. No key, no account.**
9
+ **`scan` is advisory and report-only. Trooth never applies changes to your infrastructure.**
10
+ **Trooth publishes facts and counts about a company, never one number that sums it up.**
7
11
 
8
12
  ## Install / run
9
13
 
10
14
  ```bash
11
15
  # No install needed:
12
- npx trooth scan ./plan.tfplan
16
+ npx trooth check stripe.com
13
17
 
14
- # or generate JSON yourself first:
15
- terraform show -json plan.tfplan > plan.json
16
- npx trooth scan plan.json
18
+ # or install it:
19
+ npm i -g trooth
20
+ trooth check trooth.co
17
21
  ```
18
22
 
23
+ Requires Node 18+ (uses built-in `fetch`).
24
+
19
25
  ## Commands
20
26
 
21
27
  | Command | What it does |
22
28
  | --- | --- |
23
- | `trooth scan <plan>` | Posts your `terraform show -json` plan to Trooth Pre-Flight (`POST https://api.trooth.co/v1/preflight`) and prints the verdict, score, and findings. Accepts a JSON file or a binary `.tfplan` (it runs `terraform show -json` for you). |
24
- | `trooth lint [path]` | Local, read-only IaC drift check. Never transmits your code. |
29
+ | `trooth check <domain>` | Reads a company's witnessed record from the live Trooth Network (`GET https://api.trooth.co/directory/api/vendors`) and prints whether it is listed, when it was witnessed and first published, the live-probe and attestation counts, the badge / scan ids, and the signature and key id you can verify. Prints an honest "not listed yet" when a company has no published record. |
30
+ | `trooth scan <plan>` | Posts your `terraform show -json` plan to Trooth Pre-Flight (`POST https://api.trooth.co/v1/preflight`) and prints the verdict, the pass / fail / n-a counts, and the findings. Accepts a JSON file or a binary `.tfplan` (it runs `terraform show -json` for you). |
31
+ | `trooth eu ...` | Pushes EU AI Act evidence (`--datasets`, `--evals`, `--drift`, each a JSON file) into Trooth from CI/CD. Needs `--token` / `TROOTH_TOKEN` or `--company` / `TROOTH_COMPANY`. |
32
+ | `trooth lint [path]` | Local, read-only IaC drift check that runs the `@trooth/os` linter if it is installed. Never transmits your code. `@trooth/os` is not yet on the npm registry, so today this exits 3 with a message saying exactly that. |
25
33
  | `trooth --help` / `--version` | Help / version. |
26
34
 
27
35
  ## Flags
28
36
 
29
- - `--strict` — exit non-zero if there are findings (default: exit 0, advisory).
30
- - `--json` — print the raw signed Compliance Delta.
37
+ - `--json` (`check`, `scan`, `eu`): stdout carries exactly one JSON document and nothing
38
+ else; every diagnostic goes to stderr. On an error the document is
39
+ `{"ok": false, "error": "...", "exit": N}`.
40
+ - `--strict` (`scan` only): exit 1 if any check fails. Default is advisory: exit 0.
41
+
42
+ Any other flag is a usage error (exit 2) that names the flag and lists the known ones.
43
+ There is no `--fail-on`, no `--all`, no sort or filter switch.
44
+
45
+ ## Exit codes
46
+
47
+ | Code | Meaning |
48
+ | --- | --- |
49
+ | 0 | ok: company listed; scan ran (no strict failures); help / version |
50
+ | 1 | finding: company not listed; `scan --strict` and at least one check failed |
51
+ | 2 | usage error: missing argument, unknown flag or command, unreadable file |
52
+ | 3 | Trooth unreachable, non-2xx response, malformed response, or `@trooth/os` missing |
53
+
54
+ ## Examples
55
+
56
+ ```bash
57
+ trooth check trooth.co
58
+ ```
59
+
60
+ ```
61
+ Trooth Network // witnessed · public · read-only //
62
+ Trooth, LLC trooth.co
63
+ Standing: listed and witnessed witnessed 2026-08-30 first published 2026-08-01
64
+
65
+ Live probes 64/65 Attestations 27/35
66
+ Probes are checks Trooth read for itself. Attestations are the company's own declarations.
67
+ They are counts, reported apart on purpose. Trooth never adds them up into one number.
68
+ Badge bronze_rw_... Scan rw_... Key ed25519-2026-01
69
+
70
+ Recent witness events
71
+ • 2026-08-30 rewitnessed 64 of 65 live probes re-run
72
+
73
+ A witnessed, point-in-time reading of public evidence. Not a certification. Not one number.
74
+ Full record: https://trooth.co/network/trooth.co · Signing keys: https://api.trooth.co/public/keys
75
+ ```
76
+
77
+ ```bash
78
+ # For scripting: one JSON document on stdout, exit 1 when a company is not listed.
79
+ trooth check trooth.co --json
80
+ ```
81
+
82
+ ```json
83
+ {
84
+ "domain": "trooth.co",
85
+ "listed": true,
86
+ "company_name": "Trooth, LLC",
87
+ "witnessed_at": "2026-08-30T00:00:00Z",
88
+ "first_published_at": "2026-08-01T00:00:00Z",
89
+ "badge_id": "bronze_rw_...",
90
+ "scan_id": "rw_...",
91
+ "probes": { "passed": 64, "total": 65 },
92
+ "attested": { "passed": 27, "total": 35 },
93
+ "events": [ { "type": "rewitnessed", "at": "2026-08-30T00:00:00Z", "detail": "64 of 65 live probes re-run" } ],
94
+ "receipt_signature": "...",
95
+ "authority_key_id": "ed25519-2026-01",
96
+ "verify_keys": "https://api.trooth.co/public/keys",
97
+ "record_url": "https://trooth.co/network/trooth.co"
98
+ }
99
+ ```
100
+
101
+ Those are the only fields `check --json` emits. Anything else the feed happens to carry
102
+ is dropped on the way out, so a script written against this shape keeps working.
103
+
104
+ ```bash
105
+ terraform show -json plan.tfplan > plan.json
106
+ trooth scan plan.json # advisory, exit 0
107
+ trooth scan plan.json --strict # exit 1 if any check fails
108
+ ```
109
+
110
+ ```
111
+ Trooth Pre-Flight // advisory · report-only //
112
+ Verdict: WARN pass 5 · fail 1 · n/a 2
113
+
114
+ 1 finding(s):
115
+
116
+ ● MEDIUM S3 bucket allows public read
117
+ ↳ SOC2 CC6.1, ISO 27001 A.8.3
118
+
119
+ Advisory. Analyzes declared infrastructure intent, not live production state. Trooth never applies changes.
120
+ ```
31
121
 
32
122
  ## Notes
33
123
 
34
- - Only your **declared plan** is sent to the Pre-Flight API; nothing is written back, and
35
- Trooth never touches live infrastructure.
124
+ - `check` reads only the **public** witness directory. There is no key and no account, and
125
+ it never sends anything about you. It is a read.
126
+ - A "not listed" result is not a judgement. It means no witnessed record has been
127
+ published for that domain yet. A company gets witnessed by listing at
128
+ <https://trooth.co/get-started>.
129
+ - `scan` sends only your **declared plan** to the Pre-Flight API; nothing is written back,
130
+ and Trooth never touches live infrastructure.
36
131
  - Set `TROOTH_API` to point at a different base URL (defaults to `https://api.trooth.co`).
37
- - Requires Node 18+ (uses built-in `fetch`).
132
+ - Set `NO_COLOR=1` to disable ANSI colour. Colour is already off when stdout is not a TTY.
133
+
134
+ ## Use it from an AI assistant
135
+
136
+ The same public network powers Trooth's read-only MCP server, so ChatGPT, Claude, Cursor,
137
+ or any MCP client can ask about a company in plain words:
138
+
139
+ ```
140
+ https://api.trooth.co/public/mcp
141
+ ```
38
142
 
39
143
  Trooth automates. Trooth never signs for you.
package/bin/trooth.mjs CHANGED
@@ -1,134 +1,465 @@
1
1
  #!/usr/bin/env node
2
- // Trooth CLI — `trooth`
2
+ // Trooth CLI. Command name: `trooth`
3
3
  // Copyright (c) 2026 Trooth, LLC. All rights reserved.
4
4
  //
5
5
  // Commands:
6
+ // trooth check <domain> Read a company's witnessed record from the public Trooth
7
+ // Network (read-only, no key, no account).
6
8
  // trooth scan <plan> Send a Terraform plan to Trooth Pre-Flight (advisory, report-only).
7
9
  // <plan> may be a `terraform show -json` JSON file, OR a binary
8
10
  // .tfplan (the CLI will run `terraform show -json` for you).
11
+ // trooth eu ... Push EU AI Act evidence into Trooth from CI/CD (needs a token).
9
12
  // trooth lint [path] Local, read-only IaC drift check (never transmits your code).
10
13
  // trooth --help Show help. trooth --version Show version.
11
14
  //
12
- // Pre-Flight is ADVISORY and REPORT-ONLY. Trooth never applies changes to your
13
- // infrastructure. The scan posts only your declared plan to the Pre-Flight API and
14
- // returns a signed Compliance Delta. Trooth automates. Trooth never signs for you.
15
+ // `check` reads only PUBLIC, already-published witness records. `scan` is ADVISORY and
16
+ // REPORT-ONLY: Trooth never applies changes to your infrastructure, and the scan posts
17
+ // only your declared plan. Trooth automates. Trooth never signs for you.
18
+ //
19
+ // TROOTH DOES NOT SCORE COMPANIES. This binary prints no company score, no tier, no
20
+ // grade, no rank, and no percentage of trust, in any mode. It prints facts: listed or
21
+ // not, witnessed on what date, how many live probes and attestations were read, and
22
+ // the ids and signature you can verify yourself. If an upstream feed still carries a
23
+ // number of that kind, scrub() below drops it before anything reaches stdout.
24
+ //
25
+ // Exit codes (stable, for scripts):
26
+ // 0 ok (listed; scan ran with no strict failure; help/version)
27
+ // 1 finding (domain not listed; `scan --strict` with failing checks)
28
+ // 2 usage error (missing argument, unknown flag or command, unreadable file)
29
+ // 3 network/upstream error (Trooth unreachable, non-2xx, malformed response, missing linter)
30
+ //
31
+ // With --json, stdout carries exactly one JSON document and nothing else. Every
32
+ // diagnostic goes to stderr.
15
33
 
16
34
  import { readFileSync, existsSync } from 'node:fs';
17
- import { execFileSync } from 'node:child_process';
35
+ import { execFileSync, spawnSync } from 'node:child_process';
18
36
  import { createRequire } from 'node:module';
37
+ import { dirname } from 'node:path';
19
38
 
20
39
  const API = process.env.TROOTH_API || 'https://api.trooth.co';
21
- const J='\x1b[32m', D='\x1b[2m', B='\x1b[1m', R='\x1b[31m', A='\x1b[33m', X='\x1b[0m';
22
40
  const require = createRequire(import.meta.url);
23
- let VERSION = '0.1.0';
41
+ let VERSION = '0.3.0';
24
42
  try { VERSION = require('../package.json').version; } catch {}
25
43
 
44
+ const EXIT = { OK: 0, FINDING: 1, USAGE: 2, UPSTREAM: 3 };
45
+
46
+ // Colour only when stdout is a TTY and NO_COLOR is unset, so piped output is clean.
47
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
48
+ const c = (code) => (useColor ? code : '');
49
+ const J = c('\x1b[32m'), D = c('\x1b[2m'), B = c('\x1b[1m'), R = c('\x1b[31m'), A = c('\x1b[33m'), C = c('\x1b[36m'), X = c('\x1b[0m');
50
+
26
51
  const argv = process.argv.slice(2);
27
52
  const cmd = argv[0];
53
+ const asJson = argv.includes('--json');
54
+
55
+ /* ------------------------------------------------------------- output ---- */
56
+
57
+ function out(s) { process.stdout.write(s + '\n'); }
58
+ function diag(s) { process.stderr.write(s + '\n'); }
59
+ function emitJson(obj) { out(JSON.stringify(obj, null, 2)); }
60
+
61
+ /** Print a diagnostic to stderr and, in --json mode, one machine-readable error
62
+ * document to stdout, then exit with the given code. */
63
+ function fail(code, message, extra = {}) {
64
+ diag(`${R}error${X} ${message}`);
65
+ if (asJson) emitJson({ ok: false, error: message, exit: code, ...extra });
66
+ process.exit(code);
67
+ }
68
+
69
+ /** Keys that would carry a company score, tier, grade, rank, rating or percentage
70
+ * of trust. Nothing under these names is ever printed, in any mode, no matter
71
+ * what an upstream feed sends. `rate` is matched only as a whole key so that
72
+ * keys like generatedAt survive. */
73
+ const BANNED_KEY = /score|tier|grade|rank|rating|level|percent|^rate$/i;
74
+
75
+ /** Recursively drop banned keys from any JSON-ish value. */
76
+ function scrub(value) {
77
+ if (Array.isArray(value)) return value.map(scrub);
78
+ if (value && typeof value === 'object') {
79
+ const clean = {};
80
+ for (const [k, v] of Object.entries(value)) {
81
+ if (BANNED_KEY.test(k)) continue;
82
+ clean[k] = scrub(v);
83
+ }
84
+ return clean;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ /* --------------------------------------------------------------- args ---- */
90
+
91
+ const FLAGS = {
92
+ check: { bool: ['--json'], value: [] },
93
+ scan: { bool: ['--json', '--strict'], value: [] },
94
+ eu: { bool: ['--json'], value: ['--token', '--company', '--datasets', '--evals', '--drift'] },
95
+ lint: { bool: [], value: [] },
96
+ };
97
+
98
+ /** Parse argv[1..] for one command. Unknown flags are a usage error (exit 2), so a
99
+ * typo or a flag from an old README never silently becomes a filename. */
100
+ function parseArgs(command) {
101
+ const spec = FLAGS[command];
102
+ const flags = {};
103
+ const positional = [];
104
+ for (let i = 1; i < argv.length; i++) {
105
+ const a = argv[i];
106
+ if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
107
+ if (a.startsWith('--')) {
108
+ const [name, inlineVal] = a.split('=', 2);
109
+ if (spec.bool.includes(name)) { flags[name] = true; continue; }
110
+ if (spec.value.includes(name)) {
111
+ const v = inlineVal !== undefined ? inlineVal : argv[++i];
112
+ if (v === undefined || v.startsWith('--')) fail(EXIT.USAGE, `${name} needs a value.`);
113
+ flags[name] = v; continue;
114
+ }
115
+ const known = [...spec.bool, ...spec.value];
116
+ fail(EXIT.USAGE, `unknown flag ${name} for \`trooth ${command}\`. ` +
117
+ (known.length ? `Known flags: ${known.join(', ')}.` : `\`trooth ${command}\` takes no flags.`) +
118
+ ` Run \`trooth --help\`.`);
119
+ } else if (a.startsWith('-') && a.length > 1) {
120
+ fail(EXIT.USAGE, `unknown flag ${a} for \`trooth ${command}\`. Run \`trooth --help\`.`);
121
+ } else {
122
+ positional.push(a);
123
+ }
124
+ }
125
+ return { flags, positional };
126
+ }
28
127
 
29
- function help() {
30
- console.log(`
31
- ${J}${B}trooth${X} ${D}v${VERSION} — compliance, automated (advisory, report-only)${X}
128
+ /* --------------------------------------------------------------- help ---- */
129
+
130
+ function helpText() {
131
+ return `
132
+ ${J}${B}trooth${X} ${D}v${VERSION} · the trust network your terminal can check${X}
32
133
 
33
134
  ${B}Usage${X}
34
- trooth scan <plan> Scan a Terraform plan via Trooth Pre-Flight
135
+ trooth check <domain> Read a company's witnessed record on the Trooth Network
136
+ trooth scan <plan> Scan a Terraform plan via Trooth Pre-Flight (advisory)
137
+ trooth eu ... Push EU AI Act evidence from CI/CD (needs a token)
35
138
  trooth lint [path] Local read-only IaC drift check
36
139
  trooth --help | --version
37
140
 
38
141
  ${B}Examples${X}
142
+ trooth check stripe.com ${D}# read a company's witnessed record${X}
143
+ trooth check trooth.co --json ${D}# one JSON document on stdout, for scripting${X}
39
144
  terraform show -json plan.tfplan > plan.json && trooth scan plan.json
40
- trooth scan ./plan.tfplan ${D}# runs \`terraform show -json\` for you${X}
145
+ trooth scan plan.json --strict ${D}# exit 1 if any check fails${X}
41
146
 
42
147
  ${B}Flags${X}
43
- --strict Exit non-zero if findings exist (default: exit 0, advisory)
44
- --json Print raw JSON response
148
+ --json (check, scan, eu) machine-readable JSON on stdout; diagnostics on stderr
149
+ --strict (scan) exit 1 if failing checks exist (default: advisory, exit 0)
45
150
 
46
- ${D}Pre-Flight is advisory and report-only — Trooth never applies changes.
47
- Trooth automates. Trooth never signs for you.${X}
48
- `);
49
- }
151
+ ${B}Exit codes${X}
152
+ 0 ok 1 finding (not listed, or strict failures) 2 usage error 3 Trooth unreachable
50
153
 
51
- function loadPlan(file) {
52
- if (!file) { console.error(`${R}error${X} missing <plan> argument. Try: trooth scan ./plan.json`); process.exit(2); }
53
- if (!existsSync(file)) { console.error(`${R}error${X} file not found: ${file}`); process.exit(2); }
54
- // Try to read as JSON (a `terraform show -json` document).
55
- let raw = '';
56
- try { raw = readFileSync(file, 'utf8'); } catch (e) { console.error(`${R}error${X} cannot read ${file}: ${e.message}`); process.exit(2); }
57
- try { return JSON.parse(raw); } catch { /* not JSON — likely a binary .tfplan */ }
58
- // Fall back: ask Terraform to render the plan as JSON.
59
- try {
60
- const out = execFileSync('terraform', ['show', '-json', file], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
61
- return JSON.parse(out);
62
- } catch (e) {
63
- console.error(`${R}error${X} ${file} is not JSON, and \`terraform show -json\` failed.`);
64
- console.error(`${D} Generate JSON first: terraform show -json ${file} > plan.json → trooth scan plan.json${X}`);
65
- process.exit(2);
66
- }
154
+ ${D}check reads only public, already-published witness records. No key, no account.
155
+ Trooth publishes facts and counts about a company, never one number that sums it up.
156
+ scan is advisory and report-only. Trooth automates. Trooth never signs for you.${X}
157
+ `;
67
158
  }
68
159
 
69
- async function scan() {
70
- const file = argv.find((a, i) => i > 0 && !a.startsWith('--'));
71
- const strict = argv.includes('--strict');
72
- const asJson = argv.includes('--json');
73
- const plan = loadPlan(file);
160
+ /* -------------------------------------------------------------- fetch ---- */
74
161
 
162
+ /** GET/POST with the CLI user-agent. Any failure to reach Trooth, any non-2xx, and any
163
+ * unparseable body is an upstream error (exit 3), never a finding. */
164
+ async function callTrooth(path, init, what) {
75
165
  let res;
76
166
  try {
77
- res = await fetch(`${API}/v1/preflight`, {
78
- method: 'POST',
79
- headers: { 'content-type': 'application/json', 'user-agent': `trooth-cli/${VERSION}` },
80
- body: JSON.stringify({ plan }),
167
+ res = await fetch(`${API}${path}`, {
168
+ ...init,
169
+ headers: { accept: 'application/json', 'user-agent': `trooth-cli/${VERSION}`, ...(init && init.headers) },
81
170
  });
82
171
  } catch (e) {
83
- console.error(`${R}error${X} could not reach Trooth Pre-Flight at ${API}: ${e.message}`);
84
- process.exit(1);
172
+ fail(EXIT.UPSTREAM, `could not reach ${what} at ${API}: ${e && e.message ? e.message : e}`);
85
173
  }
86
174
  if (!res.ok) {
87
175
  const body = await res.text().catch(() => '');
88
- console.error(`${R}error${X} Pre-Flight returned HTTP ${res.status}. ${body.slice(0, 300)}`);
89
- process.exit(1);
176
+ fail(EXIT.UPSTREAM, `${what} returned HTTP ${res.status}.${body ? ' ' + body.slice(0, 300).replace(/\s+/g, ' ') : ''}`, { http_status: res.status });
177
+ }
178
+ try { return await res.json(); }
179
+ catch { fail(EXIT.UPSTREAM, `${what} returned a response that is not JSON.`); }
180
+ }
181
+
182
+ /* --------------------------------------------------------------- check ---- */
183
+
184
+ /** Normalize a domain the way the Trooth Network does: strip scheme, path,
185
+ * leading www, case, and trailing dot. */
186
+ function normalizeDomain(input) {
187
+ if (!input) return '';
188
+ let s = String(input).trim().toLowerCase();
189
+ s = s.replace(/^[a-z]+:\/\//, ''); // scheme
190
+ s = s.replace(/\/.*$/, ''); // path/query/hash
191
+ s = s.replace(/^www\./, ''); // leading www
192
+ s = s.replace(/\.$/, ''); // trailing dot
193
+ return s;
194
+ }
195
+
196
+ function fmtDate(iso) {
197
+ if (!iso) return '';
198
+ const d = new Date(iso);
199
+ if (isNaN(d)) return String(iso);
200
+ return d.toISOString().slice(0, 10);
201
+ }
202
+
203
+ function count(obj) {
204
+ if (!obj || typeof obj !== 'object') return null;
205
+ if (typeof obj.passed !== 'number' || typeof obj.total !== 'number') return null;
206
+ return { passed: obj.passed, total: obj.total };
207
+ }
208
+
209
+ /** The public record, projected onto the fields this CLI vouches for. Unknown fields
210
+ * from the feed are not carried, so a field added upstream later cannot reintroduce
211
+ * a number Trooth does not publish. Then scrubbed anyway, belt and braces. */
212
+ function projectRecord(v, domain) {
213
+ const events = Array.isArray(v.events)
214
+ ? v.events.filter((e) => e && e.type).map((e) => ({ type: String(e.type), at: e.at, detail: e.detail }))
215
+ : [];
216
+ const rec = {
217
+ domain,
218
+ listed: true,
219
+ company_name: v.company_name || domain,
220
+ witnessed_at: v.passed_at || null,
221
+ first_published_at: v.first_published_at || null,
222
+ badge_id: v.badge_id || null,
223
+ scan_id: v.scan_id || null,
224
+ probes: count(v.probes),
225
+ attested: count(v.attested),
226
+ events,
227
+ receipt_signature: v.receipt_signature || null,
228
+ authority_key_id: v.authority_key_id || null,
229
+ verify_keys: `${API}/public/keys`,
230
+ record_url: `https://trooth.co/network/${encodeURIComponent(domain)}`,
231
+ };
232
+ if (v.category) rec.category = String(v.category);
233
+ if (v.description) rec.description = String(v.description);
234
+ return scrub(rec);
235
+ }
236
+
237
+ async function check() {
238
+ const { positional } = parseArgs('check');
239
+ if (positional.length > 1) fail(EXIT.USAGE, `check takes one <domain>, got: ${positional.join(' ')}`);
240
+ const domain = normalizeDomain(positional[0]);
241
+ if (!domain) fail(EXIT.USAGE, 'missing <domain>. Try: trooth check stripe.com');
242
+
243
+ const data = await callTrooth('/directory/api/vendors', { method: 'GET' }, 'the Trooth Network');
244
+ const vendors = Array.isArray(data && data.vendors) ? data.vendors : [];
245
+ const vendor = vendors.find((v) => v && normalizeDomain(v.domain) === domain) || null;
246
+
247
+ if (!vendor) {
248
+ if (asJson) {
249
+ emitJson({ domain, listed: false, record_url: `https://trooth.co/network/${encodeURIComponent(domain)}` });
250
+ } else {
251
+ out(`\n${B}${domain}${X} ${D}//${X} ${A}not listed on the Trooth Network yet${X}`);
252
+ out(`\n${D}No witnessed record has been published for this domain. That is not a`);
253
+ out(`judgement. It simply has not been witnessed. A company gets witnessed by`);
254
+ out(`listing at ${X}${C}https://trooth.co/get-started${X}${D}: the scan reads its public surface and`);
255
+ out(`publishes a signed, dated record that anyone can check.${X}\n`);
256
+ }
257
+ process.exit(EXIT.FINDING);
258
+ }
259
+
260
+ const rec = projectRecord(vendor, domain);
261
+ if (asJson) { emitJson(rec); process.exit(EXIT.OK); }
262
+
263
+ const when = fmtDate(rec.witnessed_at);
264
+ const since = fmtDate(rec.first_published_at);
265
+ out(`\n${J}${B}Trooth Network${X} ${D}// witnessed · public · read-only //${X}`);
266
+ out(`${B}${rec.company_name}${X} ${C}${domain}${X}`);
267
+ out(`Standing: ${J}listed and witnessed${X}` +
268
+ (when ? ` ${D}witnessed ${when}${X}` : ` ${D}date not published${X}`) +
269
+ (since ? ` ${D}first published ${since}${X}` : ''));
270
+
271
+ const line = [];
272
+ if (rec.probes) line.push(`${B}Live probes${X} ${rec.probes.passed}/${rec.probes.total}`);
273
+ if (rec.attested) line.push(`${B}Attestations${X} ${rec.attested.passed}/${rec.attested.total}`);
274
+ if (line.length) {
275
+ out(`\n${line.join(' ')}`);
276
+ out(`${D}Probes are checks Trooth read for itself. Attestations are the company's own declarations.`);
277
+ out(`They are counts, reported apart on purpose. Trooth never adds them up into one number.${X}`);
278
+ }
279
+
280
+ const ids = [];
281
+ if (rec.badge_id) ids.push(`${D}Badge ${rec.badge_id}${X}`);
282
+ if (rec.scan_id) ids.push(`${D}Scan ${rec.scan_id}${X}`);
283
+ if (rec.authority_key_id) ids.push(`${D}Key ${rec.authority_key_id}${X}`);
284
+ if (ids.length) out(ids.join(' '));
285
+
286
+ if (rec.events.length) {
287
+ out(`\n${B}Recent witness events${X}`);
288
+ for (const e of rec.events.slice(0, 3)) {
289
+ out(` ${J}•${X} ${D}${fmtDate(e.at)}${X} ${e.type.replace(/_/g, ' ')}${e.detail ? `${D} ${e.detail}${X}` : ''}`);
290
+ }
90
291
  }
91
- const delta = await res.json();
92
292
 
93
- if (asJson) { console.log(JSON.stringify(delta, null, 2)); process.exit(strict && (delta.summary?.fail > 0) ? 1 : 0); }
293
+ out(`\n${D}A witnessed, point-in-time reading of public evidence. Not a certification. Not one number.`);
294
+ out(`Full record: ${X}${C}${rec.record_url}${X}${D} · Signing keys: ${rec.verify_keys}${X}\n`);
295
+ process.exit(EXIT.OK);
296
+ }
297
+
298
+ /* ---------------------------------------------------------------- scan ---- */
94
299
 
95
- const score = delta.score ?? '–';
96
- const verdict = (delta.verdict || 'reviewed').toUpperCase();
97
- const s = delta.summary || {};
98
- console.log(`\n${J}${B}Trooth Pre-Flight${X} ${D}// advisory · report-only //${X}`);
99
- console.log(`Verdict: ${B}${verdict}${X} Score: ${B}${score}${typeof score === 'number' ? '/100' : ''}${X} ` +
100
- `${J}pass ${s.pass ?? 0}${X} · ${A}fail ${s.fail ?? 0}${X} · ${D}n/a ${s.notApplicable ?? 0}${X}`);
101
- const findings = delta.findings || [];
300
+ function loadPlan(file) {
301
+ if (!file) fail(EXIT.USAGE, 'missing <plan> argument. Try: trooth scan ./plan.json');
302
+ if (!existsSync(file)) fail(EXIT.USAGE, `file not found: ${file}`);
303
+ let raw = '';
304
+ try { raw = readFileSync(file, 'utf8'); } catch (e) { fail(EXIT.USAGE, `cannot read ${file}: ${e.message}`); }
305
+ try { return JSON.parse(raw); } catch { /* not JSON, likely a binary .tfplan */ }
306
+ // Fall back: ask Terraform to render the plan as JSON.
307
+ try {
308
+ const rendered = execFileSync('terraform', ['show', '-json', file], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
309
+ return JSON.parse(rendered);
310
+ } catch {
311
+ diag(`${D} Generate JSON first: terraform show -json ${file} > plan.json then trooth scan plan.json${X}`);
312
+ fail(EXIT.USAGE, `${file} is not JSON, and \`terraform show -json\` failed.`);
313
+ }
314
+ }
315
+
316
+ /** Pre-Flight's response, minus anything that is not a finding, a count or a verdict.
317
+ * The upstream still computes a 0..100 number for the plan; this CLI does not print it. */
318
+ function projectPreflight(delta) {
319
+ const clean = scrub(delta || {});
320
+ const s = clean.summary || {};
321
+ return {
322
+ tool: clean.tool || 'trooth-preflight',
323
+ catalogVersion: clean.catalogVersion,
324
+ format: clean.format,
325
+ generatedAt: clean.generatedAt,
326
+ planResourceCount: clean.planResourceCount,
327
+ verdict: clean.verdict || 'reviewed',
328
+ summary: { pass: s.pass ?? 0, fail: s.fail ?? 0, notApplicable: s.notApplicable ?? 0, bySeverity: s.bySeverity },
329
+ findings: Array.isArray(clean.findings) ? clean.findings : [],
330
+ passedChecks: clean.passedChecks,
331
+ notApplicableChecks: clean.notApplicableChecks,
332
+ frameworkCoverage: clean.frameworkCoverage,
333
+ disclaimer: clean.disclaimer || 'Advisory. Analyzes declared infrastructure intent, not live production state. Trooth never applies changes.',
334
+ };
335
+ }
336
+
337
+ async function scan() {
338
+ const { flags, positional } = parseArgs('scan');
339
+ if (positional.length > 1) fail(EXIT.USAGE, `scan takes one <plan>, got: ${positional.join(' ')}`);
340
+ const strict = !!flags['--strict'];
341
+ const plan = loadPlan(positional[0]);
342
+
343
+ const delta = await callTrooth('/v1/preflight', {
344
+ method: 'POST',
345
+ headers: { 'content-type': 'application/json' },
346
+ body: JSON.stringify({ plan }),
347
+ }, 'Trooth Pre-Flight');
348
+
349
+ const report = projectPreflight(delta);
350
+ const exitCode = strict && report.summary.fail > 0 ? EXIT.FINDING : EXIT.OK; // advisory by default
351
+
352
+ if (asJson) { emitJson(report); process.exit(exitCode); }
353
+
354
+ const s = report.summary;
355
+ out(`\n${J}${B}Trooth Pre-Flight${X} ${D}// advisory · report-only //${X}`);
356
+ out(`Verdict: ${B}${String(report.verdict).toUpperCase()}${X} ` +
357
+ `${J}pass ${s.pass}${X} · ${A}fail ${s.fail}${X} · ${D}n/a ${s.notApplicable}${X}`);
358
+ const findings = report.findings;
102
359
  if (findings.length) {
103
- console.log(`\n${B}${findings.length} finding(s):${X}\n`);
360
+ out(`\n${B}${findings.length} finding(s):${X}\n`);
104
361
  for (const f of findings.slice(0, 50)) {
105
- const sev = (f.severity || '').toLowerCase();
362
+ const sev = String(f.severity || '').toLowerCase();
106
363
  const tag = sev === 'critical' || sev === 'high' ? `${R}● ${sev.toUpperCase()}${X}`
107
- : sev === 'medium' ? `${A}● MEDIUM${X}` : `${D}● ${(sev||'LOW').toUpperCase()}${X}`;
108
- console.log(` ${tag} ${B}${f.title || f.message || f.check || 'finding'}${X}`);
109
- if (f.control || f.frameworks) console.log(` ${J}↳${X} ${f.control || (Array.isArray(f.frameworks) ? f.frameworks.join(', ') : f.frameworks)}`);
364
+ : sev === 'medium' ? `${A}● MEDIUM${X}` : `${D}● ${(sev || 'LOW').toUpperCase()}${X}`;
365
+ out(` ${tag} ${B}${f.title || f.message || f.check || f.checkId || 'finding'}${X}`);
366
+ if (f.control || f.frameworks) out(` ${J}↳${X} ${f.control || (Array.isArray(f.frameworks) ? f.frameworks.join(', ') : f.frameworks)}`);
110
367
  }
368
+ if (findings.length > 50) out(` ${D}… ${findings.length - 50} more. Use --json for the full list.${X}`);
111
369
  } else {
112
- console.log(`\n${J}✓ No findings in the declared plan.${X}`);
370
+ out(`\n${J}✓ No findings in the declared plan.${X}`);
113
371
  }
114
- console.log(`\n${D}${delta.disclaimer || 'Advisory. Analyzes declared infrastructure intent, not live production state. Trooth never applies changes.'}${X}\n`);
372
+ out(`\n${D}${report.disclaimer}${X}\n`);
373
+ process.exit(exitCode);
374
+ }
115
375
 
116
- process.exit(strict && (s.fail > 0) ? 1 : 0); // advisory by default: exit 0
376
+ /* ------------------------------------------------------------------ eu ---- */
377
+ // `trooth eu` pushes EU AI Act evidence into Trooth from CI/CD (automated observability).
378
+ // trooth eu --token <t> --datasets ds.json --evals ev.json --drift dr.json
379
+ // Each file is a JSON array. Auth: TROOTH_TOKEN env or --token (bearer or x-trooth-company).
380
+ async function eu() {
381
+ const { flags, positional } = parseArgs('eu');
382
+ if (positional.length) fail(EXIT.USAGE, `eu takes no positional arguments, got: ${positional.join(' ')}`);
383
+ const token = flags['--token'] || process.env.TROOTH_TOKEN || '';
384
+ const company = flags['--company'] || process.env.TROOTH_COMPANY || '';
385
+ if (!token && !company) fail(EXIT.USAGE, 'provide --token <session> or --company <id> (or TROOTH_TOKEN / TROOTH_COMPANY env).');
386
+ const load = (f) => {
387
+ if (!f) return undefined;
388
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch (e) { fail(EXIT.USAGE, `cannot read ${f}: ${e.message}`); }
389
+ };
390
+ const body = { datasets: load(flags['--datasets']), evals: load(flags['--evals']), drift: load(flags['--drift']) };
391
+ if (!body.datasets && !body.evals && !body.drift) fail(EXIT.USAGE, 'provide at least one of --datasets/--evals/--drift <file.json>');
392
+ const headers = { 'content-type': 'application/json' };
393
+ if (token) headers['authorization'] = `Bearer ${token}`;
394
+ if (company) headers['x-trooth-company'] = company;
395
+
396
+ const raw = await callTrooth('/v1/eu/ingest', { method: 'POST', headers, body: JSON.stringify(body) }, 'Trooth');
397
+ const result = scrub(raw || {});
398
+ const ingested = result.ingested || {};
399
+ const report = {
400
+ ingested: { datasets: ingested.datasets ?? 0, evals: ingested.evals ?? 0, drift: ingested.drift ?? 0 },
401
+ disclaimer: result.disclaimer || 'Advisory. Trooth automates, computes, and documents; it never signs.',
402
+ };
403
+ if (asJson) { emitJson(report); process.exit(EXIT.OK); }
404
+ out(`\n${J}${B}Trooth EU AI Act${X} ${D}// evidence ingested //${X}`);
405
+ out(`datasets ${B}${report.ingested.datasets}${X} · evals ${B}${report.ingested.evals}${X} · drift ${B}${report.ingested.drift}${X}`);
406
+ out(`\n${D}${report.disclaimer}${X}\n`);
407
+ process.exit(EXIT.OK);
408
+ }
409
+
410
+ /* ---------------------------------------------------------------- lint ---- */
411
+
412
+ const LINTER_PKG = '@trooth/os';
413
+
414
+ /** Find the linter package if it is installed next to this CLI or in the caller's
415
+ * project. It is NOT fetched from the network: the previous release ran
416
+ * `npx --yes @trooth/os`, and that name is not on the npm registry (404), so every
417
+ * `trooth lint` ended in npx's raw error. */
418
+ function resolveLinter() {
419
+ const roots = [import.meta.url, `file://${process.cwd()}/`];
420
+ for (const root of roots) {
421
+ try {
422
+ const pkgPath = createRequire(root).resolve(`${LINTER_PKG}/package.json`);
423
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
424
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin && Object.values(pkg.bin)[0];
425
+ if (!bin) continue;
426
+ return { dir: dirname(pkgPath), entry: `${dirname(pkgPath)}/${bin}`, version: pkg.version };
427
+ } catch { /* try the next root */ }
428
+ }
429
+ return null;
117
430
  }
118
431
 
119
432
  async function lint() {
120
- // Local, read-only .tf drift check. Reuses the published @trooth/os linter if present,
121
- // otherwise runs a built-in minimal ruleset. Never transmits your code.
122
- const path = argv[1] && !argv[1].startsWith('--') ? argv[1] : '.';
123
- const { spawnSync } = await import('node:child_process');
124
- const r = spawnSync('npx', ['--yes', '@trooth/os', path], { stdio: 'inherit' });
125
- process.exit(r.status ?? 0);
433
+ const { positional } = parseArgs('lint');
434
+ if (positional.length > 1) fail(EXIT.USAGE, `lint takes one optional [path], got: ${positional.join(' ')}`);
435
+ const target = positional[0] || '.';
436
+ if (!existsSync(target)) fail(EXIT.USAGE, `path not found: ${target}`);
437
+
438
+ const linter = resolveLinter();
439
+ if (!linter) {
440
+ diag(`${R}error${X} \`trooth lint\` needs the ${LINTER_PKG} linter, and it is not available.`);
441
+ diag(`${D} ${LINTER_PKG} is not published on the npm registry and is not installed locally.`);
442
+ diag(` Nothing was linted and nothing left this machine. This CLI does not download it for you.`);
443
+ diag(` When it ships, install it (npm i -D ${LINTER_PKG}) and run \`trooth lint\` again.${X}`);
444
+ process.exit(EXIT.UPSTREAM);
445
+ }
446
+ const r = spawnSync(process.execPath, [linter.entry, target], { stdio: 'inherit' });
447
+ if (r.error) fail(EXIT.UPSTREAM, `could not start ${LINTER_PKG}@${linter.version}: ${r.error.message}`);
448
+ process.exit(r.status ?? EXIT.UPSTREAM);
126
449
  }
127
450
 
451
+ /* ---------------------------------------------------------------- main ---- */
452
+
128
453
  (async () => {
129
- if (cmd === '--version' || cmd === '-v') { console.log(VERSION); return; }
130
- if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { help(); return; }
454
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version') { out(VERSION); return; }
455
+ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { out(helpText()); return; }
456
+ if (cmd === 'check') return check();
131
457
  if (cmd === 'scan') return scan();
458
+ if (cmd === 'eu') return eu();
132
459
  if (cmd === 'lint') return lint();
133
- console.error(`${R}error${X} unknown command: ${cmd}\n`); help(); process.exit(2);
134
- })();
460
+ if (cmd.startsWith('-')) fail(EXIT.USAGE, `unknown flag ${cmd}. Run \`trooth --help\`.`);
461
+ diag(helpText());
462
+ fail(EXIT.USAGE, `unknown command: ${cmd}. Commands: check, scan, eu, lint.`);
463
+ })().catch((e) => {
464
+ fail(EXIT.UPSTREAM, `unexpected failure: ${e && e.message ? e.message : e}`);
465
+ });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "trooth",
3
- "version": "0.1.0",
4
- "description": "Trooth CLI — scan your Terraform plan against SOC 2 / ISO 27001 / GDPR / HIPAA / NIST AI RMF / EU AI Act. Advisory and report-only. Trooth never applies changes.",
3
+ "version": "0.3.0",
4
+ "description": "Trooth CLI. Read any company's witnessed record on the Trooth Network from your terminal (read-only, no key; facts and counts, never a number that sums a company up), and scan a Terraform plan against SOC 2 / ISO 27001 / GDPR / HIPAA / NIST AI RMF / EU AI Act. Advisory and report-only. Trooth never applies changes.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "trooth": "./bin/trooth.mjs"
7
+ "trooth": "bin/trooth.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin/",
@@ -14,16 +14,25 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "scripts": {
17
- "scan": "node ./bin/trooth.mjs scan"
17
+ "check": "node ./bin/trooth.mjs check",
18
+ "scan": "node ./bin/trooth.mjs scan",
19
+ "test": "node tests/cli.test.mjs"
18
20
  },
19
21
  "license": "MIT",
20
22
  "author": "Trooth, LLC",
21
23
  "homepage": "https://trooth.co",
22
24
  "repository": {
23
25
  "type": "git",
24
- "url": "https://github.com/trooth/trooth-cli.git"
26
+ "url": "git+https://github.com/trooth/trooth-cli.git"
25
27
  },
26
28
  "keywords": [
29
+ "trust",
30
+ "vendor-trust",
31
+ "trust-network",
32
+ "company-check",
33
+ "due-diligence",
34
+ "mcp",
35
+ "ai-agents",
27
36
  "compliance",
28
37
  "soc2",
29
38
  "iso27001",