trooth 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  The Trooth Network from your terminal. Published on npm as **`trooth`**.
4
4
 
5
- Trooth operates the Trooth Network: one public, signed, machine-readable record per company, carrying its identity, products and demos, commercial terms, domain and marketing links, people, documents, security and privacy posture, AI practices, procurement terms and relationships. It is Trooth's only product and it is free.
5
+ Trooth operates the Trooth Network: one public, machine-readable record per company, carrying its identity, products and demos, commercial terms, domain and marketing links, people, documents, security and privacy posture, AI practices, procurement terms and relationships. It is Trooth's only product and it is free. Trooth signs one object in that record, the witness statement for a reading Trooth took of the company's public surface. The rest of the profile, including what the company declares about itself, is not signed.
6
6
 
7
7
  DNS says where a company is. A TLS certificate says the connection is authentic. The Trooth Network says who the company is and what it does with your data.
8
8
 
9
9
  This CLI is the terminal interface to that record. It does two things:
10
10
 
11
- - `trooth check <domain>` reads a company's published record from the public Network. No key, no account, and nothing about you is sent beyond what any web request carries (your IP address and a `trooth-cli/<version>` user-agent).
11
+ - `trooth check <domain>` reads a company's published record from the public Network. No key and no account. It sends one request to `api.trooth.co` with **the domain you ask about in the request URL**, plus what every web request carries: your IP address and a `trooth-cli/<version>` user agent. Trooth's servers can therefore see which domain you looked up; [trooth.co/privacy](https://trooth.co/privacy) and the [retention schedule](https://trooth.co/retention) say what is kept and for how long. Nothing else about you or your machine is sent.
12
12
  - `trooth lint [path]` reads what your own infrastructure declares and prints those declarations as facts. Entirely local: it opens files and opens no sockets.
13
13
 
14
14
  **Trooth witnesses and dates facts. It does not grade, rate or rank anyone.** The CLI prints counts, reported apart, and never adds them into one number.
@@ -24,20 +24,20 @@ npm install -g trooth
24
24
  trooth --version
25
25
  ```
26
26
 
27
- Node 18 or newer, because the binary uses the built-in `fetch`. Zero dependencies.
27
+ Node 18 or newer, because the binary uses the built-in `fetch`. One dependency, pinned to an exact version and locked in `npm-shrinkwrap.json`: [`yaml`](https://www.npmjs.com/package/yaml), the maintained YAML parser, which itself has none. It is there so Kubernetes files are parsed rather than pattern-matched. Install with `--ignore-scripts` if you like; neither package has an install script.
28
28
 
29
29
  ## Commands
30
30
 
31
31
  | Command | What it does |
32
32
  |---|---|
33
- | `trooth check <domain>` | Reads a company's record from the live Network and prints whether it is listed, when it was last witnessed and first published, the live-probe and self-attestation counts, the badge id, the id of the key that signed the record, and the three newest events in its ledger, newest first. `--json` adds the signature itself and every event the feed returns. |
34
- | `trooth lint [path]` | Reads the infrastructure the given directory declares and prints those declarations plus a canonical SHA-256 digest of them. Local and offline. `path` defaults to `.`. |
33
+ | `trooth check <domain>` | Reads a company's record from the live Network and prints its listing and evidence state, the date of the witnessed reading and of first publication, the live-probe and self-attestation counts, the badge id, the id of the signing key, and the three newest events in its ledger, newest first. `--json` adds the signature itself and every event the feed returns. It does not check the signature. |
34
+ | `trooth lint [path]` | Reads the infrastructure the given directory declares and prints those declarations, a coverage report and an aggregate digest of the counts. Local and offline. `path` defaults to `.`. |
35
35
  | `trooth --help` | Help. Also `-h` and `help`. |
36
36
  | `trooth --version` | Version. Also `-v` and `version`. |
37
37
 
38
38
  ## Flags
39
39
 
40
- `--json` is the only flag `check` and `lint` take. With it, stdout carries exactly one JSON document and nothing else, and every diagnostic goes to stderr. On an error the document is `{"ok": false, "error": "...", "exit": N}`; a non-2xx response adds `http_status`, and `lint` with nothing to read adds `files_opened`. `--help` and `--version` print plain text whether or not `--json` is given.
40
+ `--json` is the flag both commands take; `lint` also takes `--allow-incomplete`. With `--json`, stdout carries exactly one JSON document and nothing else, and every diagnostic goes to stderr. On an error the document is `{"ok": false, "error": "...", "exit": N}`; a non-2xx response adds `http_status`, and `lint` with nothing to read adds `files_opened`. `--help` and `--version` print plain text whether or not `--json` is given.
41
41
 
42
42
  Any other flag is a usage error. The message names the flag, and for a `--` flag given to `check` or `lint` it also lists the ones that exist.
43
43
 
@@ -45,12 +45,14 @@ Any other flag is a usage error. The message names the flag, and for a `--` flag
45
45
 
46
46
  | Code | Meaning |
47
47
  |---|---|
48
- | 0 | The company is listed, or `lint` read at least one declaration, or you asked for help or the version. |
49
- | 1 | The company is not listed, or `lint` found nothing to read. |
50
- | 2 | Usage error: a missing argument, an unknown flag or command, or a path that does not exist. |
51
- | 3 | Trooth could not be read: unreachable, a non-2xx response, or a response that is not JSON. An unexpected failure inside the CLI also exits 3. |
48
+ | 0 | `check`: listed, and the record carries a dated reading Trooth witnessed. `lint`: a complete read of at least one declaration. Help and version also exit 0. |
49
+ | 1 | `check`: not listed, or revoked. `lint`: nothing to read. |
50
+ | 2 | Usage error: a missing argument, an unknown flag or command, input that is not one domain, or a path that does not exist. |
51
+ | 3 | Service or contract error: Trooth unreachable or slower than 15 seconds, a status other than 2xx or the documented not-listed 404, a body over 1 MiB, a body that is not JSON, or a record for a different domain. Never an answer about a company. An unexpected failure inside the CLI also exits 3. |
52
+ | 4 | `lint`: the read was incomplete. A selected file was over the size limit, did not parse or could not be read, or the walk stopped at its file limit. `--allow-incomplete` reports the same and exits 0 (or 1 when nothing was read). New in 0.5.0. |
53
+ | 5 | `check`: the company is listed, but its record carries no reading this CLI can confirm Trooth witnessed. New in 0.5.0. |
52
54
 
53
- A company with no record and a Network that could not be read are different answers, so they exit differently. A pipeline can tell them apart without parsing prose, and a Trooth outage never reads as a company with no record.
55
+ A company with no record, a listed company without a witnessed reading, and a Network that could not be read are different answers, so they exit differently. A pipeline can tell them apart without parsing prose, and a Trooth outage never reads as a company with no record.
54
56
 
55
57
  ## `trooth check`
56
58
 
@@ -61,30 +63,47 @@ trooth check trooth.co
61
63
  Example output. The values are illustrative; the shape is what the binary prints.
62
64
 
63
65
  ```
64
- Trooth Network // public · signed · read-only //
66
+ Trooth Network // public record · read-only //
65
67
  Trooth, LLC trooth.co
66
- Listing state: listed and witnessed last witnessed 2026-08-30 first published 2026-08-01
68
+ Listing state: listed; Trooth witnessed a reading reading dated 2026-09-26 first published 2026-08-01
67
69
 
68
- Live probes: 65 read; 64 as expected
70
+ Live probes: 65 read; 63 as expected
69
71
  Self-attestations: 35 asked; 27 attested
70
72
  Live probes are readings Trooth took itself, from the company's public surface.
71
73
  Self-attestations are what the company attested about itself; Trooth records them
72
74
  and did not witness them. The two are reported apart and never added into one number.
73
- Badge rw_... Key ed25519-2026-01
75
+ Badge rw_... Key trooth-master-2026-09
74
76
 
75
77
  Latest ledger events, newest first
76
- • 2026-08-30 record published to the Trooth Network
77
- • 2026-08-30 reading completed
78
+ • 2026-09-26 live probes re-read
79
+ • 2026-09-26 reading completed
78
80
  • 2026-08-01 record published to the Trooth Network
79
81
  --json carries the whole ledger, with the feed's own wording for each event.
80
82
 
83
+ This command did not check the record's signature. The signature covers the
84
+ reading, not every fact on the company's profile. To check it yourself: https://trooth.co/docs/verifiable-evidence
81
85
  A dated, point-in-time record. Trooth issues no verdict and no single number.
82
86
  Full record: https://trooth.co/network/trooth.co · Signing keys: https://api.trooth.co/public/keys
83
87
  ```
84
88
 
85
- The line that begins `Listing state:` gives the listing state. For a listed company it reads `listed and witnessed`, then the date of the most recent published reading (`last witnessed`) and the date the record was first published. The two counts use the same form as the record page on trooth.co: live probes (how many were read at the last reading, and how many of those returned the expected result) and self-attestations (how many the company was asked for, and how many it attested). Live probes are readings Trooth took itself; self-attestations are the company's statements about itself, which Trooth records and does not witness.
89
+ The line that begins `Listing state:` gives the listing and evidence state, decided from the record's own fields and never from a matching name:
86
90
 
87
- The events section prints the three newest entries in the record's ledger, newest first, by timestamp; entries with the same timestamp keep the feed's order, the later entry first. Known event types get a plain label: `scan_completed` prints as "reading completed", `standing_published` as "record published to the Trooth Network" and `rewitnessed` as "live probes re-read". A type the CLI does not know prints with its underscores turned into spaces. The human view leaves out each event's detail text. `--json` carries every event the feed returns, with its type and detail exactly as the feed has them.
91
+ | `state` in `--json` | Printed | Exit | Meaning |
92
+ |---|---|---|---|
93
+ | `listed_witnessed` | listed; Trooth witnessed a reading | 0 | The record carries a dated reading with at least one live probe read. |
94
+ | `listed_not_witnessed` | listed; no reading witnessed | 5 | The record says it was not witnessed, or its reading read no probe. |
95
+ | `listed_evidence_unknown` | listed; the reading could not be read from this record | 5 | Listed, with no reading this CLI can interpret. |
96
+ | `revoked` | revoked | 1 | The record says it was revoked or withdrawn. |
97
+ | `not_listed` | not listed in the Trooth Network's public feed | 1 | The documented not-listed answer: a JSON 404 whose body says `listed: false`. |
98
+ | `service_error` | (an error on stderr) | 3 | Anything else. Never read as an answer about the company. |
99
+
100
+ The two counts use the same form as the record page on trooth.co: live probes (how many were read at the last reading, and how many of those returned the expected result) and self-attestations (how many the company was asked for, and how many it attested). Live probes are readings Trooth took itself; self-attestations are the company's statements about itself, which Trooth records and does not witness. The counts come from the directory record; they are the counts of the signed reading, and not any other total a page may show.
101
+
102
+ The events section prints the three newest entries in the record's ledger, newest first, by timestamp; entries with the same timestamp keep the feed's order, the later entry first. Known event types get a plain label: `scan_completed` prints as "reading completed", `standing_published` as "record published to the Trooth Network" and `rewitnessed` as "live probes re-read". A type the CLI does not know prints with its underscores turned into spaces. `--json` carries every event the feed returns, with its type and detail exactly as the feed has them.
103
+
104
+ **What `check` accepts.** A bare domain or a URL. It is parsed with the standard URL parser, so case, a trailing dot, the default port (80 or 443), a path and an internationalized name (converted to its ASCII form) normalize to one domain, and a leading `www.` is dropped: `https://Trooth.co:443/path` reads `trooth.co`. A URL with a user name or password, a non-default port, an IP address, a scheme other than http or https, or a name with no dot is a usage error (exit 2) rather than a guess.
105
+
106
+ **How `check` asks.** One `GET /directory/api/vendors/<domain>`, with a 15-second deadline (`TROOTH_TIMEOUT_MS` changes it), a 1 MiB limit on the body, redirects refused, a JSON content type required, and at most one retry, only after a connection failure or a 502, 503 or 504. Up to 0.4.4, a plain-text 404 made the CLI download the whole directory list and search it; the single-record route has been served since 2026-09-26, and 0.5.0 has no fallback, so an unexpected answer is a service error, never "not listed".
88
107
 
89
108
  For scripting:
90
109
 
@@ -96,6 +115,7 @@ trooth check trooth.co --json
96
115
  {
97
116
  "domain": "trooth.co",
98
117
  "listed": true,
118
+ "state": "listed_witnessed",
99
119
  "company_name": "Trooth, LLC",
100
120
  "witnessed_at": "2026-08-30T00:00:00Z",
101
121
  "first_published_at": "2026-08-01T00:00:00Z",
@@ -109,19 +129,21 @@ trooth check trooth.co --json
109
129
  { "type": "standing_published", "at": "2026-08-30T00:00:00Z", "detail": "point-in-time · published to the Trooth Network" }
110
130
  ],
111
131
  "receipt_signature": "...",
112
- "authority_key_id": "ed25519-2026-01",
132
+ "authority_key_id": "trooth-master-2026-09",
133
+ "signature_checked": false,
113
134
  "verify_keys": "https://api.trooth.co/public/keys",
135
+ "verify_how": "https://trooth.co/docs/verifiable-evidence",
114
136
  "record_url": "https://trooth.co/network/trooth.co"
115
137
  }
116
138
  ```
117
139
 
118
- In `probes`, `total` is how many live probes were read at the last reading and `passed` (the API's field name) is how many returned the expected result. In `attested`, `total` is how many declarations were asked for and `passed` is how many the company attested. `witnessed_at` is the date of the most recent published reading. `events` is the whole ledger, oldest first, in the feed's order. Each event's `detail` is written when the event is stored and is not rewritten afterward, so older and newer events of the same type can be worded differently.
140
+ In `probes`, `total` is how many live probes were read at the last reading and `passed` (the API's field name) is how many returned the expected result. In `attested`, `total` is how many declarations were asked for and `passed` is how many the company attested. `witnessed_at` is the date of the witnessed reading, and is `null` unless `state` is `listed_witnessed`; it is never a profile edit time. `events` is the whole ledger, oldest first, in the feed's order. Each event's `detail` is written when the event is stored and is not rewritten afterward, so older and newer events of the same type can be worded differently.
119
141
 
120
142
  `category` and `description` are added when the record carries them. Those are the only fields `check --json` emits: anything else the feed happens to carry is dropped on the way out, so a script written against this shape keeps working. A field whose name contains `score`, `tier`, `grade`, `rank`, `rating`, `level` or `percent`, or is `rate`, is dropped no matter what the feed sends.
121
143
 
122
- A company with no record exits 1 and emits `{"domain": "...", "listed": false, "record_url": "..."}`. That is not a judgment. It means the Network's public feed carries no record for that domain. A company gets a record at [trooth.co/get-started](https://trooth.co/get-started), free.
144
+ A company with no record exits 1 and emits `{"domain": "...", "listed": false, "state": "not_listed", "record_url": "..."}`. That is not a judgment. It means the Network's public feed carries no record for that domain. A company gets a record at [trooth.co/get-started](https://trooth.co/get-started), free.
123
145
 
124
- `receipt_signature` is Trooth's Ed25519 signature and `authority_key_id` names the key that made it; the public keys are listed at [trooth.co/verify/keys](https://trooth.co/verify/keys). You cannot re-run that signature check from this output yet: the exact bytes the signature covers are not published, and this JSON does not carry every field that goes into them. [`trooth-signatures`](https://github.com/troothllc/trooth-signatures) states that gap and what will close it.
146
+ `receipt_signature` is Trooth's Ed25519 signature over the directory receipt and `authority_key_id` names the key that made it; the public keys are listed at [trooth.co/verify/keys](https://trooth.co/verify/keys). `signature_checked` is always `false`: this CLI does not check any signature, and nothing it prints should be read as a checked signature. The object whose exact signed bytes Trooth publishes is the reading's witness statement, which is not part of this feed; [trooth.co/docs/verifiable-evidence](https://trooth.co/docs/verifiable-evidence) shows how to fetch and check it offline. That signature covers the reading. It does not cover the company's own declarations, which are not signed by anyone.
125
147
 
126
148
  ## `trooth lint`
127
149
 
@@ -131,7 +153,7 @@ A company with no record exits 1 and emits `{"domain": "...", "listed": false, "
131
153
  trooth lint ./infra
132
154
  ```
133
155
 
134
- Output for the small fixture in this repository (`trooth lint tests/fixtures/infra`, trooth 0.4.3):
156
+ Output for the small fixture in this repository (`trooth lint tests/fixtures/infra`, trooth 0.5.0):
135
157
 
136
158
  ```
137
159
  trooth lint // local · offline · declarations only //
@@ -141,7 +163,10 @@ terraform 1 · kubernetes 1
141
163
  Declared
142
164
  Regions and zones us-east-1
143
165
  Storage declarations 1
144
- of those declaring encryption 1
166
+ declaring encryption 1
167
+ declaring encryption off 0
168
+ declaring nothing about encryption 0
169
+ set by an unresolved expression 0
145
170
  Logging declarations 0
146
171
  Identity declarations 0
147
172
  Open to any address (0.0.0.0/0, ::/0) 1
@@ -152,17 +177,20 @@ Most declared resource types
152
177
  1 aws_s3_bucket
153
178
  1 aws_s3_bucket_server_side_encryption_configuration
154
179
  1 aws_security_group_rule
180
+ 1 Deployment
155
181
 
156
- Digest sha256:a40bf6cf27dfd0b9079ac48a326fb2d1423b076c96b405451dde1ed2d7848944
157
- A SHA-256 over the facts above, in canonical form, with the timestamp excluded.
158
- The same tree read by the same trooth version produces the same digest, so you can
159
- record it as evidence that a state was observed without publishing the tree.
182
+ Coverage complete
183
+ 2 selected: 2 read, 0 not declarations, 0 excluded, 0 skipped, 0 invalid, 0 unreadable.
184
+
185
+ Facts digest sha256:2ba1da28be9e97e1e8be1f7e41641288bc632192c062ba1fbd563350448fd5a8
186
+ A SHA-256 over the counts above, in canonical form. It is an aggregate: two different
187
+ trees with the same counts share it. It does not identify your files, your repository
188
+ or a deployment.
160
189
 
161
190
  How this was read
162
- A pattern reader, not a Terraform evaluator: variables and modules are not resolved.
163
- .tf by pattern, one resource block at a time. .tf.json and plan JSON parsed, one
164
- resource at a time. Kubernetes YAML by pattern, one document at a time, by kind.
165
- Dockerfiles for regions, open addresses, public markers and credential literals only.
191
+ Every file is parsed; a file that does not parse is reported as invalid, not read.
192
+ Comments count for nothing. Nothing is evaluated: a setting that depends on a variable,
193
+ a local, a module or a function is reported as unresolved. Dockerfiles: ENV and ARG only.
166
194
 
167
195
  Counts of what the files declare. Not a judgment: a public load balancer is
168
196
  supposed to be public. Trooth issues no verdict here and checks nothing against
@@ -172,11 +200,35 @@ Publish what you choose on your record at https://trooth.co/dashboard.
172
200
 
173
201
  The bucket's encryption configuration is a setting on the bucket, not a second store, so the fixture has one storage declaration, and that one declares encryption.
174
202
 
175
- It reads `.tf`, `.tf.json`, Kubernetes YAML (anything carrying both `apiVersion` and `kind`), `terraform show -json` plan files and Dockerfiles. It is a pattern reader, not a Terraform evaluator: variables, modules and `for_each` are never resolved, so a count can differ from what Terraform itself would plan. `.tf` files are read by pattern, one resource block at a time. `.tf.json` and plan files are parsed as JSON, one resource at a time. Kubernetes YAML is read by pattern, one document at a time, and classified by its `kind`. Dockerfiles are read for regions, open addresses, public markers and credential literals only. Storage, logging and identity are counted by a resource's type or a document's kind, never by the words around it. The output states this under "How this was read".
203
+ **How each source is read.** Every file is parsed, and a file that does not parse is reported as invalid, never as read.
204
+
205
+ | Source | Parsed with | Unit |
206
+ |---|---|---|
207
+ | `.tf` | The HCL reader in `bin/lib/hcl.mjs` (comments dropped, heredocs and templates understood) | One `resource` block, at any indentation |
208
+ | `.tf.json` | `JSON.parse` | One resource |
209
+ | `terraform show -json` plan | `JSON.parse` | One planned managed resource; data sources are skipped |
210
+ | Kubernetes YAML (`apiVersion` and `kind`) | The `yaml` package, strict mode | One document, classified by `kind` |
211
+ | Dockerfile | `ENV` and `ARG` instructions only | The file |
212
+
213
+ Nothing is evaluated. A setting that depends on a variable, a local, a module output or a function is reported as unresolved and is never counted as declared. Storage, logging and identity are counted by a resource's type or a document's kind, never by the words around it. Counts are of parsed values, not lines: two credential literals on one minified line are two.
214
+
215
+ **Encryption, per store.** Each storage declaration is counted in exactly one of five states:
176
216
 
177
- Nothing leaves the machine. No file name, no line, no code and no value is printed or transmitted, only the path you gave it, counts, resource type names and region strings. The credential count is a count: the literal it found is never shown.
217
+ | Field | State |
218
+ |---|---|
219
+ | `storage_declaring_encryption` | An explicit `true`, a named key (a literal or a reference to a key resource such as `aws_kms_key.main.arn`), or an encryption block, in the store or in a setting resource that refers to it |
220
+ | `storage_declaring_encryption_off` | An explicit `false`. It wins over any other signal |
221
+ | `storage_encryption_not_declared` | Nothing about encryption. A commented-out setting is nothing |
222
+ | `storage_encryption_unresolved` | Decided by a variable, a local or another expression lint does not evaluate |
223
+ | `storage_encryption_unsupported` | A value lint does not interpret, such as a number where a switch belongs |
224
+
225
+ A declaration is what a file says, not what a cloud account does: a provider default, an account-wide setting or a module can encrypt a store whose file declares nothing, and lint cannot see any of those.
226
+
227
+ **Coverage.** Every file the walk selects (`.tf`, `.tf.json`, `.json`, `.yaml`, `.yml`, Dockerfiles) ends in exactly one bucket: read; not applicable (a JSON or YAML file that is not a plan or a manifest); excluded by a stated rule (a templated manifest containing `{{ }}`, which has to be rendered first); skipped (over 4 MiB); invalid (did not parse); or unreadable (a permission or I/O error). The walk visits directories depth first with entries sorted by name, skips `node_modules`, `.git`, `.terraform` and the other build and dependency directories listed in the source, and stops at 5,000 selected files. A read with anything skipped, invalid or unreadable, or a truncated walk, is **incomplete**: the output says so, `--json` carries a `coverage` object with each count and a `coverage_details` object listing up to 50 paths per bucket with the reason (never file contents), and the exit code is 4 unless you pass `--allow-incomplete`.
228
+
229
+ Nothing leaves the machine. No line, no code and no value is printed or transmitted, only the path you gave it, counts, resource type names, region strings and, for files that could not be read, their paths and the reason. The credential count is a count: the literal it found is never shown.
178
230
 
179
- The digest is a SHA-256 over the `facts` object in canonical form. The timestamp, the path and the CLI version are outside it, so the same tree read by the same trooth version produces the same digest. A release that changes how something is counted changes the digest of the same tree: the fixture above digests differently under 0.4.2 and 0.4.3. Record the digest in CI, or on your own record, as evidence that a given state was observed, without publishing the tree it came from.
231
+ **The facts digest** (`facts_digest`, also emitted as `digest` until 0.6) is a SHA-256 over the `facts` object in canonical form. It is an aggregate: two different trees with the same counts produce the same digest, and a release that changes how something is counted changes the digest of the same tree. It does not identify file contents, a repository, a commit or a deployment, and it is not an attestation of any of them. It is useful for noticing that the counts changed between two runs of the same version.
180
232
 
181
233
  ```bash
182
234
  # Keep the fact document as a build artifact.
@@ -188,11 +240,13 @@ trooth lint --json > trooth-lint.json
188
240
  The same `lint`, as a step, is [`troothllc/trooth-action`](https://github.com/troothllc/trooth-action). It is advisory by default: it writes what your infrastructure declares to the job summary and does not fail your workflow over anything it read unless you opt in to one of two gates. It does fail the step when it could not read at all: a path that does not exist, or a CLI it could not install or start.
189
241
 
190
242
  ```yaml
191
- - uses: troothllc/trooth-action@v1
243
+ - uses: troothllc/trooth-action@<full commit SHA> # v1
192
244
  with:
193
245
  path: ./infra
194
246
  ```
195
247
 
248
+ Pin the action by its full commit SHA. A tag can be moved; a commit cannot. The action installs one exact `trooth` version with install scripts disabled and refuses a version input that is a range, a tag or a URL.
249
+
196
250
  That repository's README documents the inputs and outputs.
197
251
 
198
252
  ## Environment
@@ -200,6 +254,8 @@ That repository's README documents the inputs and outputs.
200
254
  | Variable | Effect |
201
255
  |---|---|
202
256
  | `TROOTH_API` | Base URL for `check`. Defaults to `https://api.trooth.co`. `lint` ignores it, because `lint` makes no requests. |
257
+ | `TROOTH_TIMEOUT_MS` | The deadline for each `check` request. Defaults to 15000; the minimum is 1000. |
258
+ | `TROOTH_LINT_MAX_FILES` | The number of selected files after which `lint` stops walking and reports the read as truncated. Defaults to 5000. |
203
259
  | `NO_COLOR` | Disables ANSI color. Color is already off when stdout is not a TTY. |
204
260
 
205
261
  There is no API key. The binary asks for no credential of any kind and has no write path.
@@ -214,9 +270,19 @@ https://api.trooth.co/public/mcp
214
270
 
215
271
  Four read-only tools, public data, no key. The pattern is written up at [trooth.co/docs/agents](https://trooth.co/docs/agents).
216
272
 
273
+ ## Changed in 0.5.0
274
+
275
+ Breaking where the old behavior overstated what was read.
276
+
277
+ - `lint` parses every format instead of matching patterns. `encrypted = false` on one line, `false` followed by a comment, a commented-out setting and a setting that depends on a variable no longer count as declaring encryption; each store is counted in one of five encryption states. Comments count for nothing, indentation no longer changes a count, and a minified JSON file counts the same as a pretty one.
278
+ - `lint` reports coverage and exits 4 on an incomplete read: a malformed file, a file over the size limit, an unreadable file or a truncated walk. 0.4.4 read a malformed file as a success and an oversized one as "nothing to read".
279
+ - The digest is renamed `facts_digest` and described as what it is, an aggregate of the counts. `digest` carries the same value until 0.6.
280
+ - `check` decides the evidence state from the record's fields: a listed record is no longer printed as witnessed unless it carries a witnessed reading, and exit 5 means listed but not witnessed. The full-list fallback is gone, so an unexpected answer is a service error, never "not listed". Requests have a deadline, a body limit, a content-type check and one bounded retry. Input is normalized with the URL parser.
281
+ - `check` says that it did not check the signature, and the help and this README say that `check` sends the domain you ask about.
282
+
217
283
  ## Changed in 0.4.4
218
284
 
219
- - `check` asks the Trooth Network for the one record it needs (`/directory/api/vendors/<domain>`) instead of downloading the whole directory list and searching it. When the API does not serve that path yet, it reads the list as 0.4.3 did. The printed output, `check --json` and the exit codes are unchanged.
285
+ - `check` asked the Trooth Network for the one record it needs (`/directory/api/vendors/<domain>`) instead of downloading the whole directory list, and fell back to the list when the API did not serve that path. 0.5.0 removes the fallback.
220
286
 
221
287
  ## Changed in 0.4.3
222
288
 
@@ -238,7 +304,7 @@ The Trooth Network is Trooth's only product. This CLI, the public API and the MC
238
304
 
239
305
  ## Security
240
306
 
241
- Report a vulnerability through the [Vulnerability Disclosure Policy](https://trooth.co/security/vulnerability-disclosure-policy). Nothing in this CLI takes a credential, so there is no key to leak from it.
307
+ Report a vulnerability through the [Vulnerability Disclosure Policy](https://trooth.co/security/vulnerability-disclosure-policy). Nothing in this CLI takes a credential, so there is no key to leak from it. Reproduce the published tarball with `npm pack` at the tagged commit and compare its SHA-512 with the `integrity` value `npm view trooth@<version> dist.integrity` prints.
242
308
 
243
309
  ## Links
244
310
 
@@ -0,0 +1,306 @@
1
+ // What a declaration file says, read from its parsed tree.
2
+ // Copyright 2025-2026 Trooth, LLC. Apache-2.0.
3
+ //
4
+ // Every supported format is parsed first (HCL by ./hcl.mjs, JSON by
5
+ // JSON.parse, YAML by the maintained `yaml` package) and then read here from
6
+ // the tree, so a comment can never count, indentation cannot change a count,
7
+ // a minified file counts the same as a pretty one, and a file that does not
8
+ // parse is reported as invalid instead of being read.
9
+ //
10
+ // An unresolved expression ({ $expr: "..." }) is never promoted to a value.
11
+ // Where it decides a fact, the fact is reported as UNRESOLVED.
12
+
13
+ import { parseHcl, bodyToTree, HclParseError } from './hcl.mjs';
14
+ import { parseAllDocuments } from 'yaml';
15
+
16
+ export { HclParseError };
17
+
18
+ export const isExpr = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$expr === 'string';
19
+
20
+ /* ------------------------------------------------------------ parsing ---- */
21
+
22
+ export class InvalidDeclaration extends Error {
23
+ constructor(message) { super(message); this.name = 'InvalidDeclaration'; }
24
+ }
25
+
26
+ /** A string in Terraform's JSON syntax that holds a template is an expression. */
27
+ function tfJsonValue(v) {
28
+ if (typeof v === 'string') {
29
+ if (/[$%]\{/.test(v)) return { $expr: v };
30
+ return v;
31
+ }
32
+ if (Array.isArray(v)) return v.map(tfJsonValue);
33
+ if (v && typeof v === 'object') {
34
+ const o = {};
35
+ for (const [k, x] of Object.entries(v)) o[k] = tfJsonValue(x);
36
+ return o;
37
+ }
38
+ return v;
39
+ }
40
+
41
+ const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
42
+
43
+ /**
44
+ * One file's units. Each unit: { type, name, address, tree, typed }.
45
+ * `type` is a resource type or Kubernetes kind (null for everything else in the
46
+ * file), `tree` is the parsed value, `address` is how other resources refer to
47
+ * it. Throws InvalidDeclaration when the file does not parse.
48
+ */
49
+ export function unitsOf(kind, text) {
50
+ if (kind === 'terraform') {
51
+ let items;
52
+ try { items = parseHcl(text); } catch (e) { throw new InvalidDeclaration(e.message); }
53
+ const units = [];
54
+ const rest = [];
55
+ for (const it of items) {
56
+ if (it.kind === 'block' && it.type === 'resource') {
57
+ if (it.labels.length !== 2) throw new InvalidDeclaration(`line ${it.line}: a resource block needs a type and a name`);
58
+ const [type, name] = it.labels;
59
+ units.push({ type, name, address: `${type}.${name}`, tree: bodyToTree(it.body), typed: true });
60
+ } else rest.push(it);
61
+ }
62
+ if (rest.length) units.push({ type: null, name: null, address: null, tree: bodyToTree(rest), typed: false });
63
+ return units;
64
+ }
65
+ if (kind === 'terraform-json') {
66
+ let doc;
67
+ try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
68
+ if (!doc || typeof doc !== 'object') throw new InvalidDeclaration('a .tf.json file must hold a JSON object');
69
+ const units = [];
70
+ each(doc, (top) => each(top.resource, (byType) => {
71
+ for (const [type, byName] of Object.entries(byType)) {
72
+ if (!/^[a-z0-9_]+$/.test(type)) continue;
73
+ each(byName, (names) => {
74
+ for (const [name, body] of Object.entries(names)) {
75
+ each(body, (b) => units.push({ type, name, address: `${type}.${name}`, tree: tfJsonValue(b), typed: true }));
76
+ }
77
+ });
78
+ }
79
+ }));
80
+ if (!Array.isArray(doc)) {
81
+ const rest = { ...doc }; delete rest.resource;
82
+ if (Object.keys(rest).length) units.push({ type: null, name: null, address: null, tree: tfJsonValue(rest), typed: false });
83
+ }
84
+ return units;
85
+ }
86
+ if (kind === 'terraform-plan') {
87
+ let doc;
88
+ try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
89
+ const units = [];
90
+ const walkModule = (m) => {
91
+ if (!m || typeof m !== 'object') return;
92
+ for (const r of Array.isArray(m.resources) ? m.resources : []) {
93
+ if (r && r.mode !== 'data' && typeof r.type === 'string') {
94
+ units.push({ type: r.type, name: String(r.name ?? ''), address: r.address || `${r.type}.${r.name}`, tree: r.values ?? {}, typed: true, plan: true });
95
+ }
96
+ }
97
+ for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
98
+ };
99
+ if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
100
+ else {
101
+ for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
102
+ const after = rc && rc.change ? rc.change.after : null;
103
+ if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) {
104
+ units.push({ type: rc.type, name: String(rc.name ?? ''), address: rc.address || `${rc.type}.${rc.name}`, tree: after, typed: true, plan: true });
105
+ }
106
+ }
107
+ }
108
+ return units;
109
+ }
110
+ if (kind === 'kubernetes') {
111
+ const docs = parseAllDocuments(text, { strict: true, uniqueKeys: true, prettyErrors: false });
112
+ const list = Array.isArray(docs) ? docs : [docs];
113
+ const units = [];
114
+ for (const d of list) {
115
+ if (d.errors && d.errors.length) throw new InvalidDeclaration(`not valid YAML: ${d.errors[0].message.split('\n')[0]}`);
116
+ const v = d.toJS({ maxAliasCount: 100 });
117
+ if (v === null || v === undefined) continue;
118
+ if (typeof v !== 'object' || Array.isArray(v)) throw new InvalidDeclaration('a Kubernetes document must be a mapping');
119
+ const k = typeof v.kind === 'string' && /^[A-Za-z][A-Za-z0-9]*$/.test(v.kind) ? v.kind : null;
120
+ if (!k || typeof v.apiVersion !== 'string') throw new InvalidDeclaration('a document in a Kubernetes file has no apiVersion and kind');
121
+ units.push({ type: k, name: v.metadata && v.metadata.name ? String(v.metadata.name) : null, address: null, tree: v, typed: true });
122
+ }
123
+ return units;
124
+ }
125
+ if (kind === 'container') {
126
+ return [{ type: null, name: null, address: null, tree: dockerfileTree(text), typed: false }];
127
+ }
128
+ throw new Error(`unknown kind ${kind}`);
129
+ }
130
+
131
+ /** A Dockerfile's ENV and ARG settings as a tree. Comment lines and line
132
+ * continuations are handled; every other instruction declares nothing lint
133
+ * counts. A value that uses $ is unresolved. */
134
+ function dockerfileTree(text) {
135
+ const tree = {};
136
+ const logical = [];
137
+ let cur = '';
138
+ for (const raw of text.split(/\r?\n/)) {
139
+ if (/^\s*#/.test(raw) && !cur) continue;
140
+ if (/\\\s*$/.test(raw)) { cur += raw.replace(/\\\s*$/, ' '); continue; }
141
+ logical.push(cur + raw); cur = '';
142
+ }
143
+ if (cur) logical.push(cur);
144
+ const val = (s) => {
145
+ let v = s;
146
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
147
+ return /\$/.test(v) ? { $expr: v } : v;
148
+ };
149
+ for (const l of logical) {
150
+ const m = /^\s*(ENV|ARG)\s+(.*)$/i.exec(l);
151
+ if (!m) continue;
152
+ const rest = m[2].trim();
153
+ const pairs = rest.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)/g);
154
+ if (pairs && pairs.join(' ').length >= rest.replace(/\s+/g, ' ').length - pairs.length) {
155
+ for (const p of pairs) { const i = p.indexOf('='); tree[p.slice(0, i)] = val(p.slice(i + 1)); }
156
+ } else {
157
+ const sp = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(.*))?$/.exec(rest);
158
+ if (sp) tree[sp[1]] = sp[2] !== undefined ? val(sp[2].trim()) : { $expr: `ARG ${sp[1]}` };
159
+ }
160
+ }
161
+ return tree;
162
+ }
163
+
164
+ /* ----------------------------------------------------------- reading ---- */
165
+
166
+ /** Every (key, value) pair in a tree, depth first. */
167
+ function* pairs(tree, parent = null) {
168
+ if (Array.isArray(tree)) { for (const x of tree) yield* pairs(x, parent); return; }
169
+ if (!tree || typeof tree !== 'object' || isExpr(tree)) return;
170
+ for (const [k, v] of Object.entries(tree)) {
171
+ yield [k, v, parent];
172
+ yield* pairs(v, k);
173
+ }
174
+ }
175
+
176
+ /** Every string (literal or expression source) in a tree. */
177
+ function* strings(tree) {
178
+ if (typeof tree === 'string') { yield tree; return; }
179
+ if (isExpr(tree)) { yield tree.$expr; return; }
180
+ if (Array.isArray(tree)) { for (const x of tree) yield* strings(x); return; }
181
+ if (tree && typeof tree === 'object') for (const v of Object.values(tree)) yield* strings(v);
182
+ }
183
+
184
+ const REGION_KEY = /^(region|location|availability_zone|aws_region|aws_default_region|zone)$/i;
185
+ const REGION_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{2,40}$/;
186
+ const SECRET_KEY = /(password|passwd|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)/i;
187
+ const OPEN_CIDRS = new Set(['0.0.0.0/0', '::/0']);
188
+
189
+ export function regionsIn(tree) {
190
+ const found = [];
191
+ for (const [k, v] of pairs(tree)) if (REGION_KEY.test(k) && typeof v === 'string' && REGION_VALUE.test(v)) found.push(v);
192
+ return found;
193
+ }
194
+
195
+ /** A credential literal: a secret-named key holding a literal string of at
196
+ * least eight characters. One count per key, wherever it sits in the file. */
197
+ export function credentialLiterals(tree) {
198
+ let n = 0;
199
+ for (const [k, v] of pairs(tree)) {
200
+ if (!SECRET_KEY.test(k)) continue;
201
+ if (typeof v === 'string' && v.length >= 8 && !/[$%]\{/.test(v)) n++;
202
+ }
203
+ return n;
204
+ }
205
+
206
+ export function opensToAnyAddress(tree) {
207
+ for (const s of strings(tree)) if (OPEN_CIDRS.has(s.trim())) return true;
208
+ return false;
209
+ }
210
+
211
+ export function markedPublic(tree) {
212
+ for (const [k, v] of pairs(tree)) {
213
+ if ((k === 'publicly_accessible' || k === 'public_network_access_enabled' || k === 'associate_public_ip_address') && (v === true || v === 'true')) return true;
214
+ if (k === 'acl' && typeof v === 'string' && /^public-read/.test(v)) return true;
215
+ if (k === 'type' && (v === 'LoadBalancer' || v === 'NodePort')) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /* -------------------------------------------------------- encryption ---- */
221
+
222
+ // Keys whose value is the encryption switch itself.
223
+ const BOOL_KEY = /^(encrypted|storage_encrypted|encrypt_at_rest|encryption_enabled|enable_encryption|encrypted_at_rest|at_rest_encryption_enabled|encryption_at_rest_enabled)$/i;
224
+ // Keys that name the key or algorithm a store is encrypted with.
225
+ const KEY_KEY = /^(kms_key_id|kms_key_arn|kms_key_name|kms_key|kms_master_key_id|kms_key_self_link|encryption_key|encryption_key_name|disk_encryption_set_id|key_vault_key_id|sse_algorithm|default_kms_key_name)$/i;
226
+ // Blocks whose presence configures encryption, unless they switch it off.
227
+ const BLOCK_KEY = /^(server_side_encryption_configuration|server_side_encryption|encryption_configuration|encryption_config|encryption|encryption_at_rest|apply_server_side_encryption_by_default|disk_encryption|customer_managed_key)$/i;
228
+ // An expression that refers to a managed resource (aws_kms_key.main.arn) or a
229
+ // data source names a key that exists in the configuration; a variable, a
230
+ // local or anything else is unresolved.
231
+ const RESOURCE_REF = /^\$?\{?\s*(?:data\.)?[a-z][a-z0-9]*_[a-z0-9_]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_]+\s*\}?$/;
232
+
233
+ export const ENCRYPTION = Object.freeze({
234
+ TRUE: 'declared',
235
+ FALSE: 'declared_off',
236
+ ABSENT: 'not_declared',
237
+ UNRESOLVED: 'unresolved',
238
+ UNSUPPORTED: 'unsupported',
239
+ });
240
+
241
+ function boolState(v) {
242
+ if (v === true || v === 'true') return ENCRYPTION.TRUE;
243
+ if (v === false || v === 'false') return ENCRYPTION.FALSE;
244
+ if (isExpr(v)) return ENCRYPTION.UNRESOLVED;
245
+ if (v === null) return ENCRYPTION.ABSENT;
246
+ return ENCRYPTION.UNSUPPORTED;
247
+ }
248
+
249
+ function keyState(v) {
250
+ if (v === null || v === '' || v === false) return ENCRYPTION.ABSENT;
251
+ if (typeof v === 'string') return ENCRYPTION.TRUE;
252
+ if (isExpr(v)) {
253
+ const s = v.$expr.replace(/^"\$\{|\}"$/g, '').trim();
254
+ return RESOURCE_REF.test(s) ? ENCRYPTION.TRUE : ENCRYPTION.UNRESOLVED;
255
+ }
256
+ return ENCRYPTION.UNSUPPORTED;
257
+ }
258
+
259
+ function blockState(v) {
260
+ const blocks = Array.isArray(v) ? v : [v];
261
+ let state = ENCRYPTION.ABSENT;
262
+ for (const b of blocks) {
263
+ if (!b || typeof b !== 'object' || isExpr(b)) { if (isExpr(b)) state = state === ENCRYPTION.TRUE ? state : ENCRYPTION.UNRESOLVED; continue; }
264
+ if ('enabled' in b) {
265
+ const s = boolState(b.enabled);
266
+ if (s === ENCRYPTION.FALSE) return ENCRYPTION.FALSE;
267
+ if (s === ENCRYPTION.UNRESOLVED) { state = ENCRYPTION.UNRESOLVED; continue; }
268
+ }
269
+ if (state !== ENCRYPTION.UNRESOLVED) state = ENCRYPTION.TRUE;
270
+ }
271
+ return state;
272
+ }
273
+
274
+ /**
275
+ * What one store's tree declares about encryption at rest:
276
+ * declared an explicit true, a key, or an encryption block
277
+ * declared_off an explicit false (it wins over any other signal)
278
+ * not_declared nothing about encryption (a comment is nothing)
279
+ * unresolved decided by a variable, local or other expression
280
+ * unsupported a value lint does not interpret (a number for a switch)
281
+ * An explicit switch decides; keys and blocks decide only when no switch is set.
282
+ */
283
+ export function encryptionState(tree) {
284
+ const sw = [], other = [];
285
+ for (const [k, v] of pairs(tree)) {
286
+ if (BOOL_KEY.test(k)) sw.push(boolState(v));
287
+ else if (KEY_KEY.test(k)) other.push(keyState(v));
288
+ else if (BLOCK_KEY.test(k) && v && typeof v === 'object') other.push(blockState(v));
289
+ }
290
+ const decide = (list) => {
291
+ if (list.includes(ENCRYPTION.FALSE)) return ENCRYPTION.FALSE;
292
+ if (list.includes(ENCRYPTION.UNRESOLVED)) return ENCRYPTION.UNRESOLVED;
293
+ if (list.includes(ENCRYPTION.UNSUPPORTED)) return ENCRYPTION.UNSUPPORTED;
294
+ if (list.includes(ENCRYPTION.TRUE)) return ENCRYPTION.TRUE;
295
+ return ENCRYPTION.ABSENT;
296
+ };
297
+ const s = decide(sw);
298
+ if (s !== ENCRYPTION.ABSENT) return s;
299
+ return decide(other);
300
+ }
301
+
302
+ /** Every string and expression source in a tree, joined, for finding a
303
+ * reference to another resource's address. */
304
+ export function referenceText(tree) {
305
+ return [...strings(tree)].join('\n');
306
+ }