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.
- package/README.md +118 -14
- package/bin/trooth.mjs +405 -74
- package/package.json +14 -5
package/README.md
CHANGED
|
@@ -1,39 +1,143 @@
|
|
|
1
1
|
# trooth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
16
|
+
npx trooth check stripe.com
|
|
13
17
|
|
|
14
|
-
# or
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
24
|
-
| `trooth
|
|
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
|
-
- `--
|
|
30
|
-
|
|
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
|
-
-
|
|
35
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
|
145
|
+
trooth scan plan.json --strict ${D}# exit 1 if any check fails${X}
|
|
41
146
|
|
|
42
147
|
${B}Flags${X}
|
|
43
|
-
--
|
|
44
|
-
--
|
|
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
|
-
${
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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}
|
|
78
|
-
|
|
79
|
-
headers: {
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
if (f.control || 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
|
-
|
|
370
|
+
out(`\n${J}✓ No findings in the declared plan.${X}`);
|
|
113
371
|
}
|
|
114
|
-
|
|
372
|
+
out(`\n${D}${report.disclaimer}${X}\n`);
|
|
373
|
+
process.exit(exitCode);
|
|
374
|
+
}
|
|
115
375
|
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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') {
|
|
130
|
-
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Trooth CLI
|
|
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": "
|
|
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
|
-
"
|
|
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",
|