snifftest 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Willoughby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,372 @@
1
+ <p align="center">
2
+ <img src="assets/nose/concepts/e-sketch.svg" alt="A pencil sketch of a nose in profile" width="160">
3
+ </p>
4
+
5
+ # Sniff Test
6
+
7
+ Does the draft pass the sniff test?
8
+
9
+ Sniff Test reads Markdown and plain text and checks it against your house
10
+ rules. It prints one line per flag with the file, the line, the rule that
11
+ tripped and how sure it is.
12
+
13
+ There are two kinds of rule. **Countable rules** are regular expressions.
14
+ Long dashes, a paragraph with three colons, a banned word, every sentence the
15
+ same length. They run on your machine, cost nothing and send nothing anywhere.
16
+ **Judgment rules** need something read rather than counted. A closing sentence
17
+ that only restates the paragraph, a claim hedged three times, a line that talks
18
+ the reader out of the offer. For those it sends one paragraph at a time to a
19
+ small hosted judgment model and gets back one probability per rule. It only
20
+ does that after you have said yes.
21
+
22
+ Two things it is not. There is no general model in the loop; the judgment
23
+ model returns one probability per question and nothing else. And it will not
24
+ rewrite your text. A flag is a sentence for you to fix.
25
+
26
+ ```sh
27
+ npm install --global snifftest
28
+ snifftest check --dry-run draft.md
29
+ ```
30
+
31
+ That runs the countable rules and nothing leaves your machine. To run the
32
+ judgment rules too, put a TypeSafe key in `TYPESAFE_API_KEY` and drop
33
+ `--dry-run`. The first time, it prints exactly what it is about to send and
34
+ where, and waits for your answer.
35
+
36
+ It exits 0 when nothing tripped, 1 when at least one rule did, 2 when the tool
37
+ could not do its job, and 3 when the judgment rules needed a yes and did not
38
+ get one.
39
+
40
+ The nose is the mascot. It is one hand-drawn SVG with six expressions, in
41
+ `assets/nose/`, and `snifftest serve` opens a local page where you type and the
42
+ nose reacts.
43
+
44
+ ## Reading a flag
45
+
46
+ ```
47
+ draft.md:14 dash_present 1.00 A long dash. Give the sentence a full stop instead.
48
+ draft.md:31 restating_closer 0.84 The last sentence says the paragraph again. End one sentence earlier.
49
+ ```
50
+
51
+ The number is how sure the checker is, between 0 and 1, and it says nothing
52
+ about how bad the problem is. A countable rule always scores 1.00. A judgment
53
+ rule counts as a flag at or above the threshold, which is 0.7 unless the
54
+ ruleset or `--threshold` says otherwise. A reading between 0.4 and 0.6 is
55
+ reported as no judgment and never becomes a flag or a pass. That band exists
56
+ because the judgment model answers about 0.5 on text it cannot read, and a
57
+ flat middle number is the one failure that looks like a clean draft.
58
+
59
+ `--format json` prints the same flags for a machine, with every reading
60
+ including the ones under the threshold. `snifftest rules` prints the ruleset in
61
+ force and which file each rule came from, which is the quickest answer to "why
62
+ did that flag".
63
+
64
+ ## The rules
65
+
66
+ The default ruleset ships in `rules/default.yaml`. Fifteen rules, and the file
67
+ itself is the documentation. Ten apply to most prose and five are conventions
68
+ of copy meant to sell.
69
+
70
+ | Rule | Kind | What it catches |
71
+ | --- | --- | --- |
72
+ | `dash_present` | countable | An en dash or an em dash. A hyphen is fine, and so is an en dash between numerals. |
73
+ | `colon_heavy` | countable | Three or more colons in one paragraph. URLs and clock times do not count. |
74
+ | `sentence_rhythm` | countable | Every sentence the same length, in paragraphs of four sentences and sixty words or more. |
75
+ | `slop_vocab` | countable | Twelve words that turn up far more often in generated text than in prose a person wrote. |
76
+ | `banned_words` | countable | Your own list. Empty until you fill it. |
77
+ | `not_x_but_y` | judgment | A sentence whose whole job is to swap one label for another. |
78
+ | `tricolon` | judgment | Three items for the cadence. |
79
+ | `stacked_hedging` | judgment | Two or more softeners on one claim. |
80
+ | `rhetorical_opener` | judgment | A paragraph that opens on a question nobody asked. |
81
+ | `restating_closer` | judgment | A last sentence that says the paragraph again. |
82
+ | `self_undercutting` | judgment, marketing | Copy that talks the reader out of the thing on offer. |
83
+ | `first_x_that` | judgment, marketing | A claim to be the first. |
84
+ | `naked_cost_figure` | judgment, marketing | A cost with no customer price and no alternative beside it. |
85
+ | `jobs_claim` | judgment, marketing | A claim about employment. |
86
+ | `pullquote_fragment` | judgment, marketing | A display quote with no verb. |
87
+
88
+ The five rules tagged `marketing` sit out an ordinary run. Ask for them on a
89
+ landing page with `--only marketing`, which runs those five and nothing else.
90
+ To run everything, write a project file that extends the default and sets
91
+ `off_by_default: []`.
92
+
93
+ Fenced code is dropped before either kind of rule sees it. Headings, tables,
94
+ front matter, link definitions and HTML comments are never sent to the
95
+ judgment rules, and the countable rules only read the ones a rule names.
96
+
97
+ ### Writing your own
98
+
99
+ A `.snifftest.yaml` in your project root is picked up on every run. It can
100
+ extend the default ruleset and change the rules it disagrees with, matched by
101
+ id, so a house that wants five colons instead of three writes one rule and
102
+ inherits the other fourteen.
103
+
104
+ ```yaml
105
+ version: 1
106
+ extends: default
107
+ threshold: 0.7
108
+
109
+ rules:
110
+ # A countable rule of your own is a pattern and a message.
111
+ - id: no_utilize
112
+ kind: regex
113
+ pattern: "\\butili[sz]e"
114
+ flags: i
115
+ message: "Use. The word is use."
116
+
117
+ # Override a shipped rule by reusing its id.
118
+ - id: colon_heavy
119
+ kind: regex
120
+ builtin: colon_count
121
+ min: 5
122
+ message: "Five colons in one paragraph."
123
+
124
+ # A judgment rule is a description, its near misses, and two criteria.
125
+ - id: passive_apology
126
+ kind: judgment
127
+ what: |
128
+ A sentence that apologises for the document itself: for its length,
129
+ its lateness, or the writer's lack of expertise.
130
+ not_for: |
131
+ An apology to a person for a thing that happened.
132
+ A stated limit of scope.
133
+ examples:
134
+ - "Sorry this is so long."
135
+ - "I am no expert, but here goes."
136
+ criteria:
137
+ true: "At least one sentence apologises for the document or the writer."
138
+ false: "No sentence apologises for the document or the writer."
139
+ message: "The draft is apologising for itself. Cut the line."
140
+ ```
141
+
142
+ A countable rule names either a `builtin` or a `pattern`, never both. The
143
+ built-ins are `dash_present`, `colon_count`, `sentence_rhythm`, `slop_vocab`
144
+ and `banned_words`, and each takes the tuning keys the default file shows. A
145
+ rule may carry `tags`, and a countable rule may name the kinds of block it
146
+ applies to with `chunks`. A judgment rule's `not_for` is what keeps it honest.
147
+ A rule with a `what` and no `not_for` will flag things you did not mean.
148
+
149
+ A pattern with a nested quantifier is refused when the ruleset loads, and
150
+ every pattern runs against a capped slice of text. Neither is a proof that a
151
+ pattern cannot run for a long time. See `SECURITY.md`.
152
+
153
+ To propose a rule for the shipped set, open a rule proposal issue.
154
+ `CONTRIBUTING.md` says what one needs.
155
+
156
+ ## Consent, and what leaves the machine
157
+
158
+ Nothing is sent before the answer to one question is yes. The countable rules
159
+ run first, on your machine, whatever happens next. Then, if the ruleset has
160
+ judgment rules and you did not pass `--dry-run`, the checker looks for a key in
161
+ `TYPESAFE_API_KEY`. Without one it prints the countable verdict, says the
162
+ judgment rules could not run, and exits 2. With one, it prints this and waits:
163
+
164
+ ```
165
+ Sniff Test is about to use the judgment rules, which run on a model.
166
+
167
+ What leaves this machine: one paragraph of your text at a time, from 3 files,
168
+ together with the wording of these rules: not_x_but_y, tricolon, stacked_hedging, rhetorical_opener, restating_closer.
169
+ Where it goes: https://api.typesafe.ai/v1/systemone (TypeSafe), over HTTPS, with your TYPESAFE_API_KEY.
170
+ What comes back: one probability per rule per paragraph.
171
+ What is never sent: file names, file paths, anything outside the text you pointed at.
172
+ There is no telemetry, no analytics, and nothing is stored by this tool.
173
+
174
+ Countable rules never leave the machine. Run with --dry-run to use only those.
175
+
176
+ Send paragraphs to TypeSafe? [y/N]
177
+ ```
178
+
179
+ A yes is remembered under `~/.config/snifftest/consent.json`, or under
180
+ `$XDG_CONFIG_HOME` when that is set. It is never written into the directory
181
+ being checked, because a consent file committed to a repository would answer
182
+ for everyone who clones it. `--yes` answers and remembers without the prompt.
183
+ `SNIFFTEST_SEND=1` answers for one run in CI and writes nothing down. With no
184
+ terminal to ask in and no answer in the environment, the checker exits 3 and
185
+ sends nothing.
186
+
187
+ Each request carries one paragraph and every judgment rule's wording. It goes
188
+ over HTTPS with your key as a bearer token, and the reply is a probability per
189
+ rule. The wire format and the guards around it are in `src/jev.ts`, which is
190
+ the only file in the tool that opens a network connection for `check`.
191
+
192
+ Answers are cached on disk so that a run interrupted by an outage does not pay
193
+ for every paragraph again. The cache lives under `~/.cache/snifftest`, or
194
+ `$XDG_CACHE_HOME`, or `SNIFFTEST_CACHE_DIR`. Each entry is named by a hash of
195
+ the paragraph, the exact wording of the questions asked about it and the model
196
+ they were sent to, and it holds rule ids, probabilities, the model name and a
197
+ date. The paragraph itself is never
198
+ written to disk. Entries expire after a fortnight. `--no-cache` skips the cache
199
+ for a run, and `SNIFFTEST_CACHE_DIR=off` turns it off for good.
200
+
201
+ `SECURITY.md` has the full account, including the limits.
202
+
203
+ ## Before a commit
204
+
205
+ One command, from the root of your repository:
206
+
207
+ ```sh
208
+ curl -fsSL https://raw.githubusercontent.com/DanRWilloughby/snifftest/v0.1.0/hooks/pre-commit \
209
+ -o .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
210
+ ```
211
+
212
+ Read it before you trust it. The hook checks the Markdown and text files you
213
+ staged, as you staged them, with the countable rules only. It sends nothing
214
+ unless `SNIFFTEST_SEND=1` and `TYPESAFE_API_KEY` are both in your environment.
215
+ It blocks a commit on a flag. When the checker itself is missing, broken or
216
+ could not be fetched, it says so and steps aside, because a failed download is
217
+ not evidence about your prose. `SNIFFTEST_STRICT=1` reverses that.
218
+
219
+ Skip it once with `SNIFFTEST_SKIP=1 git commit` or `git commit --no-verify`.
220
+
221
+ The same script works under husky, and there are two entries for the
222
+ pre-commit framework, `snifftest` for the free rules and `snifftest-send` under
223
+ the manual stage for the rest. `docs/husky.md` has all three.
224
+
225
+ ## In CI
226
+
227
+ ```yaml
228
+ name: Prose
229
+ on: pull_request
230
+
231
+ permissions: {}
232
+
233
+ jobs:
234
+ snifftest:
235
+ runs-on: ubuntu-latest
236
+ steps:
237
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.0.0
238
+ - uses: DanRWilloughby/snifftest@v0.1.0
239
+ with:
240
+ paths: docs
241
+ ```
242
+
243
+ The Action runs the countable rules and nothing else unless you set `send` and
244
+ pass a key in as `api-key`. A pull request from a fork always gets the
245
+ countable rules only, whatever the workflow says. The Action refuses to run on
246
+ any event whose name ends in `_target`, refuses a floating `version`, and
247
+ fetches the exact version named from the npm registry into a scratch directory
248
+ outside the checkout, so the repository being checked cannot hand it a checker
249
+ by committing one. It needs no token of its own. With `comment: true` and a `github-token` it posts
250
+ the checker's output on the pull request, and no line of the draft is copied
251
+ into that comment.
252
+
253
+ Pin the Action by commit SHA if you want the stronger guarantee. The inputs and
254
+ the exit codes are tabled in `docs/husky.md`.
255
+
256
+ ## In Claude Code
257
+
258
+ ```
259
+ /plugin marketplace add DanRWilloughby/snifftest
260
+ /plugin install snifftest@snifftest
261
+ ```
262
+
263
+ Then `/snifftest draft.md`, or hand Claude a draft and ask whether it passes.
264
+ The skill runs the countable rules by default. The judgment pass is the user's
265
+ call and the skill never answers the sending question on their behalf: it adds
266
+ no flag that skips the prompt and never touches the key. `docs/claude-code.md`
267
+ has the two other ways to install it.
268
+
269
+ ## Measuring it
270
+
271
+ `snifftest eval` plants one known fault per rule into copies of your own clean
272
+ paragraphs, runs the corpus three ways, and reports what each way caught and
273
+ what it flagged on the clean originals. Arm A is no tool. Arm B is the
274
+ countable rules. Arm C is the countable rules plus the judgment rules, and it
275
+ is the only arm that costs anything. Each run writes a Markdown report and a
276
+ `scores.json`, and it writes the clean and seeded paragraphs beside them with
277
+ a `.gitignore`, because they carry your prose in full.
278
+
279
+ `snifftest bench` asks a panel of general models the same questions over the
280
+ same corpus, through OpenRouter and one direct Anthropic call, and puts cost,
281
+ speed and accuracy side by side. An OpenAI row can be called directly too, on
282
+ OpenAI's own API with `OPENAI_API_KEY`, so a model can be measured without an
283
+ OpenRouter account and its latency is the provider's rather than a proxy's.
284
+ `bench/panel-direct.yaml` is a panel of nothing but direct rows. It asks for
285
+ consent per destination, and a yes given for TypeSafe is never a yes for anyone
286
+ else.
287
+
288
+ The run committed under `bench/results/2026-09-17/` is the eval over the
289
+ packaged corpus with eight seeds per rule, and beside it the bench over the
290
+ same 166 paragraphs with `bench/panel-direct.yaml`: four general models, each
291
+ on its own provider's API, on the same day.
292
+
293
+ <!-- numbers:start -->
294
+ | Arm | Judgment faults caught, of 80 | Clean paragraphs flagged, of 54 | Cost per 100 paragraphs | Median per paragraph |
295
+ | --- | --- | --- | --- | --- |
296
+ | Sniff Test, judgment model (Jev, measured) | 63 | 1 | $0.0129 | 182 ms |
297
+ | Claude Haiku 4.5 (measured) | 66 | 37 | $0.43 | 1,971 ms |
298
+ | Claude Sonnet 5 (measured) | 72 | 2 | $1.34 | 6,083 ms |
299
+ | Claude Opus 5 (measured) | 77 | 0 | $3.08 | 6,532 ms |
300
+ | OpenAI gpt-5.6-sol (measured) | 73 | 0 | $1.64 | 4,428 ms |
301
+ <!-- numbers:end -->
302
+
303
+ Every row is one paragraph per request. The Sniff Test row is the eval's arm
304
+ C, flags counted at 0.7, cost and latency from provider-reported usage. The
305
+ model rows are `snifftest bench` on 2026-09-17, one repeat, cache off, and
306
+ they count each model's own yes, which is the decision the model made; their
307
+ cost is the returned token usage at the list prices recorded under
308
+ `bench/prices/` with no prompt caching, and their latency is the wall clock at
309
+ this end. Haiku, Sonnet and Opus were given 2,000 completion tokens, because
310
+ at the default 900 Sonnet 5 ran out of room on 20 of its 166 replies on an
311
+ earlier pass of the same run and a cut reply counts as unanswered; gpt-5.6-sol
312
+ was given 4,000 at low reasoning effort. `bench-tables.md` in the results
313
+ folder carries p95, the per-rule detail, every failure and the budget each row
314
+ was sent. Jev ran in the same rotation as the models, where it read 59 of 80,
315
+ 2 of 54 and 198 ms, with one rule missing from its reply on 13 paragraphs;
316
+ across the runs of this corpus on 2026-09-17 the judgment model read between
317
+ 59 and 64 of 80, and that swing is the size to read every row against.
318
+
319
+ What that supports. On this corpus the judgment model catches about as many
320
+ faults as the cheapest general model and fewer than the mid and top tiers,
321
+ raises far fewer false alarms than the cheapest one, and does it at about a
322
+ thirtieth of that model's cost and a tenth of its latency, with no general
323
+ model in the loop. The top models catch more, at twenty to thirty-five times
324
+ the latency and a hundred to two hundred and forty times the cost.
325
+
326
+ Three things to read beside the table. Eight seeds per rule is a small sample,
327
+ so the per-rule figures are direction rather than measurement. The eval
328
+ measures every rule in the file, including the five marketing rules an
329
+ ordinary check sits out. And two of the ten judgment rules were reworded after
330
+ a first run on these seeds and measured again on the same seeds;
331
+ `docs/eval-notes.md` carries both numbers and the reasoning.
332
+
333
+ `snifftest eval --twins examples/adversarial` measures what a sentence written
334
+ to the checker does to the readings around it. `examples/CORPUS.md` describes
335
+ the corpus and where every paragraph came from.
336
+
337
+ ## Configuration
338
+
339
+ | Option | What it does |
340
+ | --- | --- |
341
+ | `--rules <path>` | Use this ruleset instead of `.snifftest.yaml` or the built-in one. |
342
+ | `--root <dir>` | The tree being checked, when it is not the directory you are in. |
343
+ | `--threshold <0-1>` | The probability at or above which a judgment counts as a flag. |
344
+ | `--format text\|json` | How to print the flags. |
345
+ | `--only <tags>` | Run only the rules carrying one of these tags. |
346
+ | `--skip <tags>` | Never run a rule carrying one of these tags. |
347
+ | `--dry-run` | Make no network request of any kind. For `check`, the countable rules and nothing else. |
348
+ | `--no-cache` | Ask about every paragraph again instead of reusing an answer from the last fortnight. |
349
+ | `--yes`, `-y` | Answer the send question for this run and remember the answer. |
350
+
351
+ | Variable | What it does |
352
+ | --- | --- |
353
+ | `TYPESAFE_API_KEY` | The key the judgment rules are sent with. Read from the environment and nowhere else. |
354
+ | `SNIFFTEST_SEND` | Answer the send question in CI without remembering it. `1` means TypeSafe, which is where `check` sends and nowhere else. |
355
+ | `SNIFFTEST_CACHE_DIR` | Where cached answers live. `off` for none. |
356
+ | `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | Used by `bench` only. Each one is the key its own rows are called with, and a panel row whose key is missing says so rather than running. |
357
+
358
+ `snifftest --help` lists the options for `eval` and `bench` as well.
359
+
360
+ The package has no runtime dependencies and needs Node 20 or newer. Installing
361
+ from the npm registry needs nothing else. Installing from a git URL needs Bun
362
+ on the machine, because the executable is built on install.
363
+
364
+ ## Contributing
365
+
366
+ `CONTRIBUTING.md` has the ground rules, the test setup and what a rule proposal
367
+ needs. Security problems go to a private advisory, never to a public issue.
368
+ `SECURITY.md` says how.
369
+
370
+ ## Licence
371
+
372
+ MIT. See `LICENSE`.
package/SECURITY.md ADDED
@@ -0,0 +1,165 @@
1
+ # Security
2
+
3
+ What leaves your machine, when, and what to do if you find a problem.
4
+
5
+ ## Supported versions
6
+
7
+ | Version | Supported |
8
+ | --- | --- |
9
+ | 0.1.x | Yes |
10
+
11
+ ## What leaves the machine
12
+
13
+ The countable rules are regular expressions. They run on your machine and
14
+ never open a network connection.
15
+
16
+ The judgment rules send text. For each paragraph, one request goes to
17
+ `https://api.typesafe.ai/v1/systemone` over HTTPS with your `TYPESAFE_API_KEY`
18
+ as a bearer token. The request carries that one paragraph and the wording of
19
+ every judgment rule in the ruleset in force. The reply is one probability per
20
+ rule. That endpoint is the only place `check` sends anything, and the address
21
+ cannot be changed by a ruleset, a flag or an environment variable.
22
+
23
+ What is never sent: file names, file paths, and anything outside the text you
24
+ pointed the tool at. Fenced code is dropped before either kind of rule reads
25
+ it. Headings, tables, front matter, link definitions and HTML comments are
26
+ never sent. A file that is not UTF-8 text is skipped and reported as skipped.
27
+ Text that carries a data URI or a long unbroken run of encoded characters is
28
+ refused before a request is built, because the judgment model answers HTTP 200
29
+ with meaningless probabilities on such input. A paragraph over 24,000
30
+ characters is split before it is sent.
31
+
32
+ There is no telemetry and no analytics.
33
+
34
+ `bench` is different. It sends the same paragraphs to general models at other
35
+ companies. Each destination is its own key and its own endpoint, and a row runs
36
+ only when its key is set:
37
+
38
+ - OpenRouter, which routes most of the panel: the paragraphs and the rule
39
+ wording go to `https://openrouter.ai/api/v1/chat/completions` with
40
+ `OPENROUTER_API_KEY`.
41
+ - Anthropic, called directly: the same text to
42
+ `https://api.anthropic.com/v1/messages` with `ANTHROPIC_API_KEY`.
43
+ - OpenAI, called directly: the same text to
44
+ `https://api.openai.com/v1/chat/completions` with `OPENAI_API_KEY`.
45
+
46
+ It asks for consent naming each destination, and a yes given for TypeSafe never
47
+ covers any of them. Before the first model call each provider's model list is
48
+ read, which is an authenticated request with that key, so it sits behind the
49
+ same question.
50
+
51
+ ## Consent
52
+
53
+ Nothing is sent before the answer to the send question is yes.
54
+
55
+ - The key is checked first. Without `TYPESAFE_API_KEY` the judgment rules
56
+ cannot run, the countable verdict is printed, and the run exits 2. The
57
+ question is never asked for a request the tool could not make.
58
+ - With a key, the first run prints what will be sent, where, with which key,
59
+ and what comes back, then waits for `y`. Any other answer sends nothing.
60
+ - A yes is stored in `$XDG_CONFIG_HOME/snifftest/consent.json`, or
61
+ `~/.config/snifftest/consent.json`, with mode 0600 in a 0700 directory. It is
62
+ never stored in the directory being checked. The stored answer names the
63
+ destinations it covers, and a new destination asks again.
64
+ - `--yes` answers without the prompt and stores the answer.
65
+ - `SNIFFTEST_SEND` answers for one run and stores nothing. Its value names the
66
+ destinations it answers for, comma separated. `1` is shorthand for TypeSafe,
67
+ the only place `check` sends, so a CI job that sets it for `check` has not
68
+ answered for `bench`.
69
+ - With no terminal to ask in and no answer in the environment, the run exits 3
70
+ and sends nothing. Exit 3 is the question, not an error to work around.
71
+ - `--dry-run` makes no network request of any kind, on every command.
72
+
73
+ The key is read from `TYPESAFE_API_KEY` and from nowhere else. There is no
74
+ config file for it and no flag. Every error message that could quote a
75
+ provider's reply is passed through a scrubber that removes the key, whole or
76
+ cut at either end, before it is printed.
77
+
78
+ ## The hook and the Action
79
+
80
+ The pre-commit hook in `hooks/pre-commit` runs `check --dry-run` on the staged
81
+ Markdown and text files. It sends nothing unless `SNIFFTEST_SEND` is set and
82
+ `TYPESAFE_API_KEY` is present, both at once. It refuses a `snifftest` on `PATH`
83
+ that resolves inside the repository being committed to, and it fetches the
84
+ pinned version from a scratch directory rather than from inside the checkout,
85
+ so the repository cannot supply the checker by committing one. A tool failure
86
+ never blocks a commit unless `SNIFFTEST_STRICT=1`, but a countable flag that
87
+ was printed before the failure still does.
88
+
89
+ The two entries in `.pre-commit-hooks.yaml` behave the same way. `snifftest`
90
+ never sends. `snifftest-send` sits under the manual stage and needs both the
91
+ key and `SNIFFTEST_SEND=1`.
92
+
93
+ The GitHub Action in `action.yml`:
94
+
95
+ - Runs the countable rules only, unless `send` is `true` and a key is passed in
96
+ as `api-key`. It reads no secret of its own.
97
+ - Forces the countable rules on a pull request from a fork, whatever the
98
+ workflow asked for, and on any event it has no fork guard for.
99
+ - Refuses to run on any event whose name ends in `_target`.
100
+ - Refuses a floating `version`. It fetches the exact version named from the npm
101
+ registry, from a scratch directory, on every run.
102
+ - Puts the key in the environment of the step that runs the checker only when
103
+ sending is on, and never in the step that decides.
104
+ - Posts a pull request comment only when `comment` is `true` and a token is
105
+ passed in as `github-token`. The comment carries the checker's output, which
106
+ is file, line, rule, probability and the rule's message. No line of the draft
107
+ is copied into it.
108
+
109
+ The Claude Code skill runs the countable rules by default, adds no flag that
110
+ skips the consent prompt, and never reads or prints the key.
111
+
112
+ ## The answer cache
113
+
114
+ Judgment answers are cached on disk so a run interrupted by an outage does not
115
+ pay for every paragraph twice. The cache is under `SNIFFTEST_CACHE_DIR`, else
116
+ `$XDG_CACHE_HOME/snifftest`, else `~/.cache/snifftest`. Each file is named by a
117
+ SHA-256 of the paragraph, the exact wording of the questions and the model
118
+ alias, and it holds rule ids, probabilities, the served model name and a date.
119
+ The paragraph is never written to disk. Files are written 0600 inside a 0700
120
+ directory, and a relative `SNIFFTEST_CACHE_DIR` is resolved against the home
121
+ directory so the cache can never land inside the tree being checked. Entries
122
+ expire after fourteen days.
123
+ `--no-cache` skips the cache for a run and `SNIFFTEST_CACHE_DIR=off` disables
124
+ it. The cache is opened only after the key check and the consent gate, and
125
+ never on `--dry-run`.
126
+
127
+ ## Known limits
128
+
129
+ - A hash is not the text, but anyone who can read the cache and already has a
130
+ paragraph can confirm from it that the paragraph was checked.
131
+ - Cache entries are keyed on the model alias `jev-latest`. A run's first live
132
+ answer names the model actually served, and entries from an older model are
133
+ dropped from then on. A run answered entirely from the cache has no live
134
+ answer to compare against, so for up to fourteen days after the service
135
+ moves the alias such a run can report the previous version's answers.
136
+ - A cache directory that other users or other jobs can write to is a cache they
137
+ can poison. Keep it private. A poisoned entry can change a probability and
138
+ nothing else, because the reader drops any entry that is not a well-formed
139
+ set of numbers between 0 and 1.
140
+ - A pattern rule is refused when it applies a quantifier to a group that
141
+ already contains one, or when two branches of an alternation can start on
142
+ the same character. Behind those shape rules sits a timing probe with a
143
+ small budget. The probe is a measurement, not a proof: a shape nobody has
144
+ written down yet can pass it on a fast machine and run long on a slow one.
145
+ - The hook and the Action fetch the checker from the npm registry at the
146
+ pinned version. What arrives is whatever the registry serves for that name
147
+ and version, so a tag protection rule on this repository and a commit pin on
148
+ the Action are the readers' controls, and owning the name on the registry is
149
+ the maintainer's.
150
+ - The scrubber removes the key's value, and prefixes and suffixes of it. It
151
+ cannot remove a re-encoding of the value, such as base64 or a hash, and does
152
+ not try.
153
+ - A `.snifftest.yaml` found in the working directory may extend only files
154
+ under its own directory tree, or the shipped default, and is refused when it
155
+ is a symbolic link. A file named on `--rules` is followed wherever it points,
156
+ because naming it is the decision.
157
+
158
+ ## Reporting a vulnerability
159
+
160
+ Open a private security advisory on the repository:
161
+
162
+ https://github.com/DanRWilloughby/snifftest/security/advisories/new
163
+
164
+ Do not open a public issue. Say what you found, how to reproduce it, and which
165
+ version. You will get a reply on the advisory.
@@ -0,0 +1,97 @@
1
+ # The direct panel: every row on its provider's own API.
2
+ #
3
+ # `bench/panel.yaml` routes most of its rows through OpenRouter, which buys one
4
+ # key for the whole bench at the cost of a proxy hop in every latency number.
5
+ # This file is the other trade. Each row here is called on the API of the
6
+ # company that serves it, with that company's own key, so the latencies are the
7
+ # providers' own rather than a proxy's, and a row's cost is the published price
8
+ # of the model rather than a reseller's.
9
+ #
10
+ # What it costs to read this way: more keys, and rows that cannot be compared
11
+ # with the ones in the routed panel on latency, because they did not make the
12
+ # same journey. Rows within this file are comparable with each other.
13
+ #
14
+ # Everything else is the same contract. Nothing here claims a model exists: each
15
+ # row carries a pattern and a preference, every row is matched against the
16
+ # provider's own model list on the day of the run, and a model the provider does
17
+ # not list becomes a row that says "not available on <date>" rather than being
18
+ # swapped for a near neighbour.
19
+
20
+ version: 1
21
+
22
+ prices:
23
+ # Neither the Messages API nor the OpenAI models endpoint publishes a price,
24
+ # so both direct providers are priced from a dated file and every table that
25
+ # uses those numbers cites the file, its source and the date it was checked.
26
+ anthropic: prices/anthropic-2026-09-17.yaml
27
+ openai: prices/openai-2026-09-17.yaml
28
+ # No dated published price has been recorded for the judgment service, so no
29
+ # file is named here and its row prints its cost as unknown. A constant in
30
+ # this repo's source is not a source.
31
+
32
+ models:
33
+ # The three Claude rows share one completion budget, larger than the default.
34
+ # On the first direct run, at the default 900 tokens, Sonnet 5 ran out of room
35
+ # on 20 of its 152 replies and Opus 5 on 2: the JSON was cut mid-object and
36
+ # the paragraph counted as unanswered. A cut reply is a budget failure, not a
37
+ # model verdict, so every Claude row is given room to finish, and the same
38
+ # room, so their cost columns stay a fair comparison.
39
+ - id: haiku
40
+ label: Claude Haiku 4.5
41
+ tier: fast
42
+ provider: anthropic
43
+ match: "^claude-haiku-4-5"
44
+ prefer:
45
+ - claude-haiku-4-5-20251001
46
+ max_tokens: 2000
47
+
48
+ - id: sonnet
49
+ label: Claude Sonnet 5
50
+ tier: mid
51
+ provider: anthropic
52
+ match: "^claude-sonnet-5"
53
+ prefer:
54
+ - claude-sonnet-5
55
+ max_tokens: 2000
56
+
57
+ - id: opus
58
+ label: Claude Opus 5
59
+ tier: deep
60
+ provider: anthropic
61
+ match: "^claude-opus-5"
62
+ prefer:
63
+ - claude-opus-5
64
+ max_tokens: 2000
65
+
66
+ - id: sol
67
+ label: gpt-5.6-sol
68
+ tier: mid
69
+ provider: openai
70
+ match: "^gpt-5\\.6-sol$"
71
+ prefer:
72
+ - gpt-5.6-sol
73
+ # This row reasons, and on this API the reasoning tokens come out of the
74
+ # same completion budget as the answer. On the default budget it can spend
75
+ # the whole of it thinking and stop before it writes the JSON object, which
76
+ # lands in the table as a parse failure and reads as a verdict on the model
77
+ # rather than on the request. So the budget is raised and the effort is
78
+ # declared: low keeps the row affordable, and the footnote prints both, so
79
+ # nobody reads this row as the model's best.
80
+ max_tokens: 4000
81
+ reasoning:
82
+ effort: low
83
+
84
+ - id: jev
85
+ label: Jev, the judgment arm
86
+ tier: judgment
87
+ provider: jev
88
+ match: "^jev-"
89
+ prefer:
90
+ - jev-latest
91
+ note: >
92
+ Asked its own way, one request per paragraph carrying every rule as a
93
+ separate question, which is the shape the service documents and the shape
94
+ arm C uses. The request count matches the other rows; the request shape
95
+ does not. It returns a probability and no boolean, so its flag column is
96
+ that probability against the shipped threshold rather than a decision the
97
+ service made.