@am_shork/attest 0.7.1 → 0.7.3
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/CHANGELOG.md +597 -129
- package/README.md +10 -1
- package/dist/cli/action.d.ts +48 -0
- package/dist/cli/action.js +100 -0
- package/dist/cli/index.js +11 -32
- package/dist/cli/json.d.ts +3 -2
- package/dist/cli/json.js +3 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/loader.js +63 -33
- package/dist/core/locate.d.ts +4 -3
- package/dist/core/locate.js +53 -8
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +100 -14
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,485 @@ input, removes/renames a public API or `--json` field, or changes a default
|
|
|
13
13
|
runtime behavior an existing invocation relies on — diagnostic message text is
|
|
14
14
|
not API.
|
|
15
15
|
|
|
16
|
+
## [0.7.3] - 2026-08-07
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **A loader that never opened left its scratch directory behind — one per
|
|
21
|
+
`attest` invocation, i.e. one per CI build.** `createLoader` creates the
|
|
22
|
+
directory and writes the stub, and the only `rm` of it lives inside the `close`
|
|
23
|
+
of the object it returns — so nothing between those two points can be cleaned
|
|
24
|
+
up by anyone, because until the caller holds that object no `close` exists to
|
|
25
|
+
call. It is the exact leak the comment on `close` records as already fixed,
|
|
26
|
+
surviving on the other side of the same function. The window is now closed by
|
|
27
|
+
the function that opened it.
|
|
28
|
+
*Stated over the window rather than over `createServer`, which is where it was
|
|
29
|
+
found.* `writeFileSync` is inside it too, and a full disk or a temp directory
|
|
30
|
+
that turns read-only between the two calls reaches it with no project
|
|
31
|
+
involved — so scoping the repair to the call it was noticed at would have left
|
|
32
|
+
half of it.
|
|
33
|
+
*The trigger this was filed on does not reproduce, and that is the useful
|
|
34
|
+
half.* The entry proposed a malformed `package.json` at the root, on the
|
|
35
|
+
reasoning that `configFile: false` still leaves Vite reading it for `type`.
|
|
36
|
+
Measured: it starts a server cleanly, as do eight other shapes — a
|
|
37
|
+
`package.json` holding an array, one that is a directory, an unknown `type`, a
|
|
38
|
+
`node_modules` occupied by a file, a `node_modules/.vite` occupied by a file, a
|
|
39
|
+
`.env` that is a directory, a deleted root, and a well-formed control. Nine
|
|
40
|
+
candidates, zero throws. **`configFile: false` is why**, and it is worth
|
|
41
|
+
recording as a property rather than an accident: the option set chosen to stop
|
|
42
|
+
the loader reading a project's Vite config, and to stop it opening a socket,
|
|
43
|
+
also leaves almost no project I/O that can fail hard. The throw surface was
|
|
44
|
+
closed as a side effect of decisions taken for other reasons.
|
|
45
|
+
*So the repair is justified by ownership rather than by a symptom*, which is a
|
|
46
|
+
weaker case than this file usually accepts and is why the alternative was to
|
|
47
|
+
reject the entry outright. What decides it is the asymmetry of cost: the fan-out
|
|
48
|
+
entry below stays open because bounding concurrency changes how every run
|
|
49
|
+
behaves, whereas a `try` around a window nothing currently reaches changes
|
|
50
|
+
nothing observable on any path that exists. A resource whose ownership transfer
|
|
51
|
+
is conditional on nothing throwing is a shape, and the shape is what recurs.
|
|
52
|
+
*A cleanup that fails must not replace the error that caused it*, so the `rm`
|
|
53
|
+
swallows its own failure and the original throw propagates unchanged. The
|
|
54
|
+
regression test asserts the identity of what comes out, not its wording.
|
|
55
|
+
**`close` had the same hazard and now makes the same choice, which is a change
|
|
56
|
+
to an existing path rather than a new one.** Its `rm` runs in a `finally`, so a
|
|
57
|
+
failed cleanup replaced whatever `close` was doing — and all five callers in
|
|
58
|
+
`pipeline.ts` call `close` from a `finally` of their own, where the damage is
|
|
59
|
+
larger than symmetry: a real error on the way out is lost, and a *successful*
|
|
60
|
+
command becomes a crash about a temp directory. What that reader is handed is a
|
|
61
|
+
misdiagnosis pointing at their own correct files, which this project already
|
|
62
|
+
treats as worse than the gap it fills — the argument `ATX-49` records against
|
|
63
|
+
reporting a load failure as a wall of skipped scenarios, in a much smaller
|
|
64
|
+
place. Given up with it: the one signal a failed cleanup could have raised,
|
|
65
|
+
which no caller could have acted on.
|
|
66
|
+
*The test goes red without the fix, which is the only reason it is worth
|
|
67
|
+
having.* Verified by reverting `src/` and re-running: `expected [
|
|
68
|
+
'attest-loader-z7jhoK' ] to deeply equal []`, the leaked directory named in the
|
|
69
|
+
failure. It injects the throw at the module boundary — no project input could
|
|
70
|
+
produce one — and mocks `tmpdir` to a directory of its own, because on the
|
|
71
|
+
throwing path there is no loader to ask for its `scratchDir` and counting
|
|
72
|
+
`attest-loader-*` in the shared temp directory is the flake the lifecycle test
|
|
73
|
+
beside it already documents.
|
|
74
|
+
*It is its own file*, which is a rule this repo already wrote down and which
|
|
75
|
+
the first draft broke: `tests/locate-fanout.spec.ts` is separate because
|
|
76
|
+
`vi.mock` is file-scoped and it mocks a Node builtin, and the four lifecycle
|
|
77
|
+
tests boot the *real* Vite loader — sharing a module registry with a
|
|
78
|
+
`createServer` that throws is exactly what that convention exists to prevent.
|
|
79
|
+
The first attempt scoped the mocks with `doMock` and `resetModules` inside the
|
|
80
|
+
shared file instead, which is machinery bought to work around a constraint the
|
|
81
|
+
repo had already decided how to solve. Following it deleted the machinery.
|
|
82
|
+
*No new requirement, and the precedent cuts the other way, so it is worth
|
|
83
|
+
saying why.* `ATX-36` is the sibling property — what a live loader leaves on the
|
|
84
|
+
user's machine, stated as a count, with a scenario in `self/` — and "leaves no
|
|
85
|
+
scratch directory" is the same shape. What decides against filing one here is
|
|
86
|
+
that the success-path half has never had a requirement either, so a new one
|
|
87
|
+
covering only the throwing path would state half a contract and cost a
|
|
88
|
+
permanent scenario for the half nothing reaches. If it is ever filed it should
|
|
89
|
+
be filed whole, and that is a larger change than this fix.
|
|
90
|
+
|
|
91
|
+
- **The `cmd.exe` guard in `tests/consumer.spec.ts` checked one character of a
|
|
92
|
+
class, and the comment above it claimed the quoting was doing work it cannot
|
|
93
|
+
do here.** `shellArg` asserted the argument held no `"` and then quoted only
|
|
94
|
+
when it held whitespace, so an argument with neither went to the shell bare
|
|
95
|
+
with `&`, `|`, `^`, `<`, `>`, `(` unexamined. Measured against the twelve
|
|
96
|
+
shapes: the old assertion let **11 of 12** through.
|
|
97
|
+
*What decided it, and it is the thing the entry this replaces did not have.*
|
|
98
|
+
That entry framed the choice as "widen the class, or fix the prose", and
|
|
99
|
+
argued that widening while leaving the prose is the change that looks like a
|
|
100
|
+
fix and settles nothing. Both halves turn on a fact neither named: **every
|
|
101
|
+
command this suite spawns is a `.cmd`**, which is the whole reason
|
|
102
|
+
`NEEDS_SHELL` exists, and a batch file expands `%*` into its own line *before*
|
|
103
|
+
anything parses it — so a metacharacter survives double quotes and is acted on
|
|
104
|
+
at the far end. That is the argument-injection hole Node closed in 22.x,
|
|
105
|
+
arrived at the long way round. So the quoting is not a defence that the
|
|
106
|
+
assertion merely backstops; **the assertion is the only defence**, and a
|
|
107
|
+
defence that covers one character of its class is the defect rather than the
|
|
108
|
+
prose being generous. Both are fixed together: the class is named, and the
|
|
109
|
+
comment now says the quotes do whitespace and nothing else.
|
|
110
|
+
*`%` and `!` are in the class for a different reason* — expansion rather than
|
|
111
|
+
syntax, and `!` only under delayed expansion, which the spawned shell does not
|
|
112
|
+
enable but a batch file can enable for itself. Neither costs anything to
|
|
113
|
+
refuse.
|
|
114
|
+
*The cost, which is real and is the right cost.* Windows permits `&`, `(` and
|
|
115
|
+
`%` in a user name, and `os.tmpdir()` sits under it — so on such a machine this
|
|
116
|
+
suite now stops with *"argument would need escaping, not quoting"* instead of
|
|
117
|
+
running. That is correct rather than a false red: through a batch target those
|
|
118
|
+
arguments genuinely are not safe, and the invariant the assertion guards is
|
|
119
|
+
that every argument is a literal flag or a path from `mkdtemp`. Stopping is
|
|
120
|
+
right even where quoting would have survived it, because the value is then not
|
|
121
|
+
the kind of value the helper accepts.
|
|
122
|
+
*What is measured, and what is not.* `pnpm test:consumer` was run on Windows,
|
|
123
|
+
where `NEEDS_SHELL` is true and the changed path actually executes: 11 tests
|
|
124
|
+
passed, none skipped — the distinction that matters, since the failure this
|
|
125
|
+
file's own comment records reported ten tests as *skipped* at zero run. CI is
|
|
126
|
+
Linux, where `shellArg` returns before the assertion, so the pipeline says
|
|
127
|
+
nothing about this either way. Not measured end to end: an argument carrying a
|
|
128
|
+
metacharacter actually reaching the guard, because no call site can produce
|
|
129
|
+
one — the class is verified as a predicate over the twelve shapes above and
|
|
130
|
+
the sixteen arguments the suite really passes, not by driving the suite red.
|
|
131
|
+
|
|
132
|
+
- **A statement or rationale can no longer become HTML, or open a section of
|
|
133
|
+
its own.** `sanitised` is the entry §9.1 names and it strips control
|
|
134
|
+
characters and nothing else, so `statement` and `rationale` reached the
|
|
135
|
+
Markdown by concatenation exactly as written. Measured: a `rationale`
|
|
136
|
+
containing a newline and `## ATX-999` rendered a whole section — heading,
|
|
137
|
+
statement, `**Why:**` — for a requirement no registry holds, sitting between
|
|
138
|
+
two real ones and worded identically, with the overview table above it listing
|
|
139
|
+
three ids and the body carrying four sections. `<script>` and `<img onerror>`
|
|
140
|
+
survived the same way into a file that is committed and served. `check` and
|
|
141
|
+
`cover` saw nothing, because the id is in no registry, and `render --check`
|
|
142
|
+
could not object, because that *was* what the registry rendered to.
|
|
143
|
+
*Which of the three is intended, which is the part the fix had to decide.*
|
|
144
|
+
Marking prose up stays — `plain` escapes a param value precisely because the
|
|
145
|
+
value is data and the sentence around it is not — and that decision had never
|
|
146
|
+
been separated from allowing raw HTML or a heading. It is now a **fifth
|
|
147
|
+
obligation in design §9.1** rather than a patch at an emitter: prose may be
|
|
148
|
+
marked up, it may not become a tag, and it may not open a section. Stated as a
|
|
149
|
+
count over the document — one heading per requirement id — because what a
|
|
150
|
+
reader trusts is that a section means a requirement (`ATX-67`).
|
|
151
|
+
*Escaped rather than refused, on an asymmetry.* A rule that misses something
|
|
152
|
+
ships the document it ships today; a rule that fires wrongly makes a
|
|
153
|
+
legitimate rationale a hard error in the one command whose value is that it
|
|
154
|
+
always produces the document — the `ATX-29` argument about `--check` seen from
|
|
155
|
+
the other side. So there is no new diagnostic and no exit code moves.
|
|
156
|
+
*And escaped for the position, which is what keeps it lossless.* A code span
|
|
157
|
+
is literal text in every renderer, so `<` written inside one is **visible**
|
|
158
|
+
— Markdown does not decode entities there, and this repository documents
|
|
159
|
+
`attest status <change>` in a statement. The scan therefore tracks spans and
|
|
160
|
+
leaves their contents alone, conservatively: a backtick run opens a span only
|
|
161
|
+
if a run of exactly that length closes it, and an unmatched run is text with
|
|
162
|
+
the defence still applied after it.
|
|
163
|
+
*One rendering change beyond the fix, and it is a repair.* A param
|
|
164
|
+
substituted inside the author's own code span was emitted as `**value**`,
|
|
165
|
+
which in a code span is two asterisks around the value; knowing the position
|
|
166
|
+
is what removes them. `SPEC.md` in this repository moves by 12 lines, all of
|
|
167
|
+
that shape — `` `**changes/<name>/first-run.json**` `` becomes
|
|
168
|
+
`` `changes/<name>/first-run.json` `` — and no `<` appears anywhere in it,
|
|
169
|
+
which is the measurement that the span exception works on real prose.
|
|
170
|
+
*The defence had the defect it was written next to, and `ATX-59` gained a
|
|
171
|
+
third shape because of it.* The first code-span scan searched forward from
|
|
172
|
+
each backtick run for a run of the same length, so a rationale whose runs are
|
|
173
|
+
all of *different* lengths paid a scan to the end of the string for every one
|
|
174
|
+
of them: 500 KB cost 1.6 s and 2 MB cost 15 s, from `attest render` with no
|
|
175
|
+
flag, on text the registry chooses — the same "a registry must not decide what
|
|
176
|
+
rendering costs" defect the padded statement and the backtick-run param
|
|
177
|
+
already stand for. The runs are now read once and indexed by length, each
|
|
178
|
+
length holding a forward-only cursor, which is linear: 40 ms at 2 MB. It is
|
|
179
|
+
filed under `ATX-59` rather than here because the obligation is that one, and
|
|
180
|
+
it is worth saying where the site came from — the *defence another requirement
|
|
181
|
+
added*, which is the direction the next one will arrive from too.
|
|
182
|
+
*This leaves `Planned`.* The forged section was reproduced first, then the
|
|
183
|
+
four scenarios were run against the pre-fix renderer: three went red, and the
|
|
184
|
+
fourth — the setext underline, which heads the line *above* it — went green,
|
|
185
|
+
because counting `##` lines cannot see a heading that has no `##` in it. That
|
|
186
|
+
scenario was rewritten to assert the neutralised underline instead. A scenario
|
|
187
|
+
that cannot fail is the defect `archive` exists to catch, and it was caught
|
|
188
|
+
here only by running it against the old code.
|
|
189
|
+
|
|
190
|
+
- **`attest init` no longer follows a symbolic link out of the project, and
|
|
191
|
+
refuses the whole run when a destination leads there.** `runInit` did
|
|
192
|
+
`join(root, target.file)`, `mkdir -p` and then the write, and nothing asked
|
|
193
|
+
whether the path was still under `root` once the filesystem had had its say.
|
|
194
|
+
A `.claude/skills/attest` planted in a repository as a link to anywhere the
|
|
195
|
+
user can write was followed by both: measured on Windows with a junction, a
|
|
196
|
+
file at the far end holding `USER OWNED FILE - DO NOT OVERWRITE` came back
|
|
197
|
+
holding the skill, and the report printed the relative path, so nothing on
|
|
198
|
+
screen said where it had gone. Every destination is now resolved segment by
|
|
199
|
+
segment before the first byte of any of them is written, and one that escapes
|
|
200
|
+
is an `unsafe-target-path` ERROR that writes nothing at all (`ATX-66`).
|
|
201
|
+
*What the exposure was, stated precisely, because two nearby things are not
|
|
202
|
+
it.* `writeAtomic`'s exclusive creation is not the gap and was never scoped to
|
|
203
|
+
be one: it defends the *temporary* path against a planted link, and it does
|
|
204
|
+
that perfectly in whichever directory the destination turns out to be. And
|
|
205
|
+
`workflowBody()` is a pure function of nothing, so no byte of the checked
|
|
206
|
+
project reaches the file — there is nothing to exfiltrate. What was exposed is
|
|
207
|
+
the **path**, which is `targets.ts`'s first scoping rule ("Attest owns the
|
|
208
|
+
path") having been an assumption rather than a check.
|
|
209
|
+
*Why the check is a resolution rather than a comparison.* `join` normalises
|
|
210
|
+
`..`, so a lexical containment test was already satisfied — the escape is a
|
|
211
|
+
segment that *is* a link, which only `realpath` can see. Containment itself
|
|
212
|
+
goes through `isInside` in `src/core/paths.ts`, asked as a relative path
|
|
213
|
+
rather than a string prefix: `/repo-backup` starts with `/repo`, and on
|
|
214
|
+
Windows a path on another drive has no relative spelling at all, so
|
|
215
|
+
`path.relative` answers with an absolute one instead of a chain of `..`. Both
|
|
216
|
+
read as contained under `startsWith`.
|
|
217
|
+
*The limit, recorded rather than papered over.* A link planted between the
|
|
218
|
+
resolution and the write is not caught, and no check outside the filesystem
|
|
219
|
+
can catch it. The exposure this closes is one committed to a repository, which
|
|
220
|
+
is the one a fresh clone and every merge-request checkout hands you.
|
|
221
|
+
*Behaviour change:* an `init` that used to write through such a link now
|
|
222
|
+
fails with exit 1. The only input that reaches it is a path that was already
|
|
223
|
+
leaving the project. The JSON surface is unchanged beyond one more `code`
|
|
224
|
+
value, which is additive — no `SCHEMA_VERSION` bump.
|
|
225
|
+
*This leaves `Planned`*, where it was filed with the measurement above; the
|
|
226
|
+
first act of building it was re-running that measurement, and it reproduced.
|
|
227
|
+
|
|
228
|
+
### Changed
|
|
229
|
+
|
|
230
|
+
- **The guidance now draws the line between a literal that is drift and a literal
|
|
231
|
+
that is a pin — the shipped text forbade the only defence against a shortened
|
|
232
|
+
domain.** `[0.7.2]` fixed the repo half: every param a scenario iterates gained
|
|
233
|
+
a literal pinning its extent, after three of that release's four green
|
|
234
|
+
mutations turned out to be a domain silently losing cases rather than the
|
|
235
|
+
tautology the experiment was looking for. The user-facing half was held back,
|
|
236
|
+
and it was not merely absent. The `init` skill's "Four things you must not do"
|
|
237
|
+
says **"Do not hardcode a value that lives in `params`"**, and the pin every one
|
|
238
|
+
of those fixes adds is, read literally, exactly that. An adopter who writes a
|
|
239
|
+
roster param — which the design actively pushes them toward, since a quantifier
|
|
240
|
+
a scenario can iterate is the shape `QUANTIFIED` recommends — was being handed
|
|
241
|
+
an instruction that forbids the only thing standing between them and a run that
|
|
242
|
+
covers less every time someone edits the list.
|
|
243
|
+
*The distinction is real and one sentence long*, and it is now drawn in all four
|
|
244
|
+
places the claim is made: the rule is about the **expectation** the system is
|
|
245
|
+
measured against, and the pin asserts what the **intent** is. A list a scenario
|
|
246
|
+
loops over is not a value under test — it is the set of cases the run covers,
|
|
247
|
+
so dropping a member removes a case while every assertion inside still passes,
|
|
248
|
+
with the expectation as independent as it ever was. That is why the `[0.5.0]`
|
|
249
|
+
rule (*the expectation must not be a function of the param the code under test
|
|
250
|
+
consumed*) does not reach it, and why a literal is the **only** independent term
|
|
251
|
+
available: anything derived from the list shrinks with it, so a length assertion
|
|
252
|
+
written as a function of the list is true at every length.
|
|
253
|
+
*Where it landed, and why it is four documents rather than one line appended.*
|
|
254
|
+
README §"killer move", the `init` skill (a sibling bullet in the model section
|
|
255
|
+
and a carve-out inside rule 3 itself, where the contradiction was), the
|
|
256
|
+
`possible-drift` troubleshooting section with a worked ✓/✗ pair, and design §11
|
|
257
|
+
as a residual weakness beside the tautology bullet it shares a gap with — both
|
|
258
|
+
languages. `[0.5.0]` records the reason this shape of change is done in one
|
|
259
|
+
deliberate pass rather than incrementally: it is the central claim of the tool
|
|
260
|
+
being reworded, and the last time it was split the halves disagreed.
|
|
261
|
+
*No engine change, and none is proposed here.* Nothing in this is detectable —
|
|
262
|
+
which surface a statement claims is prose judgement, the same judgement §11
|
|
263
|
+
already records as the reason obligation counting can never be an engine rule.
|
|
264
|
+
The counter-pressure that exists is a hand-judged table, and §11 now says so,
|
|
265
|
+
including what it cannot reach: a domain whose size is carried by a scalar.
|
|
266
|
+
*This leaves `Planned`*, where it was filed with the measurement above; the
|
|
267
|
+
measurement was already taken, and what it needed was the pass.
|
|
268
|
+
|
|
269
|
+
- **The release publishes through `pnpm` again, and the check that read the
|
|
270
|
+
attestation back is gone.** Two corrections to the provenance job shipped in
|
|
271
|
+
`[0.7.2]`, both from what the first real tag pipeline showed.
|
|
272
|
+
*`pnpm publish --provenance` works on the pinned 10.28.0; the claim that it
|
|
273
|
+
did not was read off `pnpm publish --help`, where the flag is absent.* The
|
|
274
|
+
parser knows it — `--provenanceX` is rejected with *"Did you mean
|
|
275
|
+
'provenance'?"*, and `--provenance --dry-run` runs clean. A help text is a
|
|
276
|
+
document, not the interface; checking a flag means asking the parser. So the
|
|
277
|
+
job is pnpm like every other job here, `--no-git-checks` comes back with it
|
|
278
|
+
(pnpm inspects the branch, CI builds tags in detached HEAD), and
|
|
279
|
+
`COREPACK_ENABLE_STRICT=0` goes away, since it existed only so a bare `npm`
|
|
280
|
+
could run under a `packageManager` that pins pnpm.
|
|
281
|
+
*The post-publish check failed the 0.7.2 release, and the publish it failed
|
|
282
|
+
was correctly signed.* Six attempts over a minute all got a 404 from the
|
|
283
|
+
registry's attestation endpoint; the same endpoint now serves
|
|
284
|
+
`slsa.dev/provenance/v0.2` for `0.7.2`. Lengthening the budget was the wrong
|
|
285
|
+
repair, and the entry it replaces already contains the reason without drawing
|
|
286
|
+
the conclusion: the check runs *after* an irreversible step, so its red cannot
|
|
287
|
+
be cleared — the republish that would rerun it fails `EPUBLISHCONFLICT` on a
|
|
288
|
+
version that now exists. Every failure it can reach is therefore either a
|
|
289
|
+
false one or an unfixable one, on a job nobody can make green again, which
|
|
290
|
+
makes it a machine for teaching that red is routine — the exact habit the
|
|
291
|
+
`audit` job's comment refuses. A gate has to sit where the answer can still
|
|
292
|
+
change what happens. Verification moves to where it can: `npm audit
|
|
293
|
+
signatures` in an installing project, or the package page's Provenance panel.
|
|
294
|
+
*What is unchanged:* the attestation itself, the three preconditions, and the
|
|
295
|
+
fact that npm rejects the publish rather than degrading to an unsigned one if
|
|
296
|
+
any is missing. `0.7.2` is signed, and shipped signed, despite the red job.
|
|
297
|
+
|
|
298
|
+
## [0.7.2] - 2026-08-07
|
|
299
|
+
|
|
300
|
+
### Added
|
|
301
|
+
|
|
302
|
+
- **Releases carry a provenance attestation, and the publish job proves it was
|
|
303
|
+
served rather than trusting its own exit code.** `npm publish --provenance`
|
|
304
|
+
exchanges a GitLab OIDC token for a Sigstore certificate and records, in the
|
|
305
|
+
public transparency log, that this tarball was built by this pipeline from
|
|
306
|
+
this commit — checkable by anyone with `npm audit signatures` or the package
|
|
307
|
+
page, without trusting us. Nothing about the package's *contents* changes:
|
|
308
|
+
same `files`, same tarball, same `prepack`/`prepublishOnly` gate.
|
|
309
|
+
*It is `npm` in a repository whose every other job is `pnpm`, and that is not
|
|
310
|
+
drift.* pnpm's publish has no provenance support at all — checked against the
|
|
311
|
+
pinned 10.28.0, there is no such flag — so the choice was npm for this one
|
|
312
|
+
command or no attestation. Two consequences worth writing down. `--no-git-checks`
|
|
313
|
+
disappears with it: that was a pnpm flag, needed only because pnpm inspects
|
|
314
|
+
the branch and CI builds tags in detached HEAD. And `COREPACK_ENABLE_STRICT=0`
|
|
315
|
+
is now required to call `npm` at all — `corepack enable` shims npm too, and
|
|
316
|
+
`packageManager` pins pnpm, so a bare `npm` aborts with "this project is
|
|
317
|
+
configured to use pnpm". Job-local rather than narrowing the shim in `.base`,
|
|
318
|
+
because every other job wants exactly that strictness.
|
|
319
|
+
*The check after the publish, and the one that looked right and was worthless.*
|
|
320
|
+
The obvious step is `npm audit signatures`, and here it would have been
|
|
321
|
+
decoration of precisely the kind the `audit` job's comment refuses: run in this
|
|
322
|
+
repository it audits *our own installed dependency tree*, which never contains
|
|
323
|
+
`@am_shork/attest` — green while attesting nothing. What runs instead reads the
|
|
324
|
+
registry's attestation endpoint for the version just published and fails unless
|
|
325
|
+
a `slsa.dev/provenance/` predicate is actually there. That is the consumer's
|
|
326
|
+
view, and it is what separates "npm accepted the tarball" from "the statement
|
|
327
|
+
is served". It retries for propagation, because it runs *after* an
|
|
328
|
+
irreversible step: the publish has happened, and a rerun cannot clear a red
|
|
329
|
+
since the republish fails `EPUBLISHCONFLICT` on a version that now exists — so
|
|
330
|
+
a registry that had not caught up would pin the release job red with nothing
|
|
331
|
+
wrong. A served response missing the predicate fails immediately; that answer
|
|
332
|
+
will not change. Both paths were run against the live registry — green on a
|
|
333
|
+
package known to carry provenance, red on `0.7.1`, which does not.
|
|
334
|
+
*Three preconditions, and npm rejects the publish rather than quietly shipping
|
|
335
|
+
unsigned if any is missing.* An `id_tokens` audience of exactly `sigstore`; a
|
|
336
|
+
`repository.url` matching `CI_PROJECT_URL`, so a repository rename fails here
|
|
337
|
+
first; and a public package on registry.npmjs.org, which
|
|
338
|
+
`publishConfig.access=public` already settles. The token is still an npm
|
|
339
|
+
automation token — the OIDC exchange signs the release, it does not authorise
|
|
340
|
+
it (see *Under consideration*).
|
|
341
|
+
*What is unmeasured, stated rather than implied.* There is no dry run for
|
|
342
|
+
provenance — `--dry-run` mints no token exchange — so none of this has executed
|
|
343
|
+
against a real tag pipeline. The next release is its first run. The commit SHA,
|
|
344
|
+
tag, pipeline URL and repository path become public record; all four are
|
|
345
|
+
already public here.
|
|
346
|
+
|
|
347
|
+
### Fixed
|
|
348
|
+
|
|
349
|
+
- **One unreadable file took the whole of `check` down with it.** A `params`
|
|
350
|
+
value nested 20,000 deep exhausts the call stack inside `ts.createSourceFile`
|
|
351
|
+
— TypeScript's own recursive-descent parser, so `literalValue`'s recursion is
|
|
352
|
+
never reached and hardening it would have changed nothing. Measured: `attest
|
|
353
|
+
check` answered a single `internal-error`, exit 1. It failed *closed*, which
|
|
354
|
+
is why this is a repair and not an emergency — but `check` is the command this
|
|
355
|
+
project tells people to run first, in a pipeline, on a merge request whose
|
|
356
|
+
contents they do not control, and one file an attacker owns could reduce the
|
|
357
|
+
entire report to that one line, permanently. Breadth is what `check` trades
|
|
358
|
+
for running no project code; a report of one crash is not a degraded `check`,
|
|
359
|
+
it is a different command.
|
|
360
|
+
Now a file that cannot be parsed is reported as **`unreadable-file`** and
|
|
361
|
+
skipped, and everything else in the run is still reported.
|
|
362
|
+
*Two sites, and a guard on one of them fixes nothing.* `static-registry.ts`
|
|
363
|
+
and `parser.ts` both open with the same call, so the spec parse takes it too —
|
|
364
|
+
measured separately, and a registry-only guard leaves `attest check` dying on
|
|
365
|
+
a hostile `*.spec.ts` exactly as before. The registry half sits at the loop
|
|
366
|
+
that reads the files rather than inside either reader, because what is being
|
|
367
|
+
kept is a property of the *run* — one file's failure is not the run's failure
|
|
368
|
+
— and both readers need it; `readRegistry` folds them with `Promise.all`,
|
|
369
|
+
where one rejection ends the command.
|
|
370
|
+
*Its own code, not `registry-invalid`.* A file that blew the parser's stack
|
|
371
|
+
did not fail schema validation, and a diagnostic saying it did sends the
|
|
372
|
+
reader to the wrong page. The archive gate refuses to run at all when a spec
|
|
373
|
+
is unreadable, rather than grading a change against a plan known to be short.
|
|
374
|
+
*A third read site turned up while reading the diff, and it was answering
|
|
375
|
+
wrongly rather than crashing.* An unreadable **proposed** spec declares no
|
|
376
|
+
scenarios, so the unclaimed check found it claimed by nothing and reported
|
|
377
|
+
`proposed-spec-unclaimed` — a true sentence about a file whose actual problem
|
|
378
|
+
is that it could not be read, and a fix hint pointing at work that must not be
|
|
379
|
+
done. That is the shape `[0.7.0]` fixed for `orphan-test` after a registry
|
|
380
|
+
fails to load, arriving again at a different site. It now reports
|
|
381
|
+
`unreadable-file` and stands down.
|
|
382
|
+
`parseSpecs` now returns its issues alongside the plan; `parseAllSpecFiles`
|
|
383
|
+
was deleted in passing, having had no callers.
|
|
384
|
+
**No `SCHEMA_VERSION` bump.** A new `code` value is additive to the `--json`
|
|
385
|
+
envelope, and a consumer that branches on codes it knows is unaffected — the
|
|
386
|
+
rule this project already applies to every new diagnostic.
|
|
387
|
+
|
|
388
|
+
- **`attest status` printed one line the sanitiser never saw.** The `Change:`
|
|
389
|
+
header was built and printed in the CLI shell, three lines above a closing
|
|
390
|
+
line that put the same value through `inline()` — so a change name holding
|
|
391
|
+
control characters repainted the terminal from the header while the footer was
|
|
392
|
+
clean. A change name is a directory name in the repository under test, and the
|
|
393
|
+
workflow `init` writes tells an agent to run `attest status <name>`.
|
|
394
|
+
The header moved into `formatStatus`, which is a fix rather than a tidy-up: a
|
|
395
|
+
write that lives in `cli/index.ts` cannot be attested at all, because that
|
|
396
|
+
module runs the CLI at import — so the site was not merely unsanitised, it was
|
|
397
|
+
unreachable by any scenario. `ATX-37`'s site list gained the kind that had
|
|
398
|
+
been missing from it, **a write in the shell**, and the two `--out` echoes are
|
|
399
|
+
the remainder: sanitised now, and still carried as unattested, because the
|
|
400
|
+
trigger needs a filename holding a control character — which NTFS refuses and
|
|
401
|
+
Linux permits, so that half is unmeasured here.
|
|
402
|
+
|
|
403
|
+
- **A param that is a scenario's *domain* could be shortened, and the run just
|
|
404
|
+
covered less.** Nothing in this repo executes here — it is the self-registry
|
|
405
|
+
and its measurement suite — but three of the holes were real, and one of them
|
|
406
|
+
was a security check. Found by re-measuring the tautology entry below after
|
|
407
|
+
`[0.7.1]`: twelve mutations of this registry, each followed by the whole gate.
|
|
408
|
+
Four went green, and three of those were **not** the tautology that experiment
|
|
409
|
+
was looking for. When a scenario loops over a list it read from `params`, that
|
|
410
|
+
list is the set of cases the run covers, so removing a member removes a case
|
|
411
|
+
and every assertion inside the loop still passes over what is left. The
|
|
412
|
+
expectation stays perfectly independent; what moved is the quantifier's
|
|
413
|
+
domain, which is why the rule shipped in `[0.5.0]` — *the expectation must not
|
|
414
|
+
be a function of the param the code under test consumed* — does not cover it.
|
|
415
|
+
What each one cost, measured: dropping `../../evil` from `ATX-13.rejectedNames`
|
|
416
|
+
stopped testing the change-name guard against path traversal; `ATX-40.languages: 1`
|
|
417
|
+
stopped checking `docs/zh/troubleshooting.md` **entirely**, which is the
|
|
418
|
+
mirror rule `CLAUDE.md` calls mandatory losing its only gate; and
|
|
419
|
+
`ATX-64.childRunCommands: ['verify']` stopped checking `archive` for the
|
|
420
|
+
stdout hole `ATX-64` exists to close — filed in the same release, three
|
|
421
|
+
scenarios deep, and silent.
|
|
422
|
+
*The repo had already invented the defence and not named it.* `ATX-16` asserts
|
|
423
|
+
its list against a literal and its comment says why — "so the spec cannot
|
|
424
|
+
quietly cover fewer of them than the intent promises" — and it was one of the
|
|
425
|
+
two lists that went red. A literal is the only independent term available when
|
|
426
|
+
what is at risk is the **size** of the set rather than a value in it, which is
|
|
427
|
+
also why this reads as a violation of "do not hardcode a value that lives in
|
|
428
|
+
`params`" and is not one: the pin asserts what the intent *is*, not what the
|
|
429
|
+
system does.
|
|
430
|
+
Every iterated list now carries that line. `ATX-40` needed both halves and
|
|
431
|
+
that is the instructive one: `toHaveLength(languages)` already caught the
|
|
432
|
+
count going *up* without a document, and could never catch it going down,
|
|
433
|
+
because the slice shrinks with it and the assertion stays true.
|
|
434
|
+
*The counter-pressure, and one thing it cannot reach.* A `DOMAINS` table in
|
|
435
|
+
`tests/intent-rule-candidates.spec.ts` now judges every list-valued param as
|
|
436
|
+
`iterated` or `inert`, with completeness swept from the registry so a new list
|
|
437
|
+
fails until someone decides, and each `iterated` row naming the pin that
|
|
438
|
+
guards it. The `QUANTIFIED` taxonomy gained the distinction this turned on:
|
|
439
|
+
a `roster` owned by the **code** grows with the code and is safe by
|
|
440
|
+
construction, a roster owned by the **registry** is data an author can shorten
|
|
441
|
+
— `ATX-64` was filed as the former and was the latter. What the sweep does not
|
|
442
|
+
reach is `ATX-40`, whose domain size is a *scalar*; it is judged by hand, and
|
|
443
|
+
a future scalar used that way would not be forced into the table by anything.
|
|
444
|
+
*The rot check needed a second attempt, which is worth recording.* Written
|
|
445
|
+
first as "the pin appears somewhere in `self/`", it passed with `ATX-64`'s pin
|
|
446
|
+
deleted — two requirements pin a list read into a local named `commands`, and
|
|
447
|
+
the grep matched the other one. It asserts **exactly one** match now. A check
|
|
448
|
+
satisfiable by an unrelated line is the failure it exists to prevent.
|
|
449
|
+
|
|
450
|
+
- **A project under test could put its own verdict on stdout ahead of Attest's,
|
|
451
|
+
under `--json`.** `runAndCollect` starts Vitest with the parent's fd 1 in
|
|
452
|
+
play, and `quiet: true` does not close it: `silent: true` suppresses Vitest's
|
|
453
|
+
*console interception*, and a bare `process.stdout.write` in a spec never
|
|
454
|
+
enters it — measured at collection time and inside a scenario body, both. So
|
|
455
|
+
a spec writing `{"ok":true,…}` put that document **first** on the stream: a
|
|
456
|
+
consumer calling `JSON.parse` on the whole of stdout got a parse error at
|
|
457
|
+
position 46, and one reading line-wise or taking the first object got the
|
|
458
|
+
attacker's verdict instead of the report. `check`, `cover`, `render` and
|
|
459
|
+
`status` were never exposed — no Attest code was on the path at all, which is
|
|
460
|
+
precisely why `core/terminal.ts` and `sanitisedLogger` did not help.
|
|
461
|
+
*Why this is worse than the terminal half of the same hole.* Repainting a
|
|
462
|
+
terminal — `ESC[2K CR`, a forged `ERROR` header at column 0, an `OSC 0` window
|
|
463
|
+
retitle, all of which also arrive intact — is not a capability `verify`
|
|
464
|
+
lacked, because it executes project code by design. Forging the *machine*
|
|
465
|
+
verdict is different in kind: it inverts what the report is for, since the
|
|
466
|
+
commands carrying the risk are exactly the ones that run the repository being
|
|
467
|
+
gated. §9.1 recorded the machine surface as never exposed because
|
|
468
|
+
`JSON.stringify` escapes every C0 character — true, and about the bytes Attest
|
|
469
|
+
writes; it says nothing about bytes Attest does not write.
|
|
470
|
+
Under `--json`, stdout is now diverted to stderr for the duration of the
|
|
471
|
+
command and released before the report is rendered. Diverted rather than
|
|
472
|
+
dropped: a human debugging a red pipeline still needs the run output, and
|
|
473
|
+
stderr is the stream no consumer parses. Without `--json` nothing changes —
|
|
474
|
+
the promise is the flag's, and diverting unconditionally would take the child
|
|
475
|
+
run's output away from the reader it exists for.
|
|
476
|
+
*The fix is `runAction`'s, not `runner.ts`'s* — the same argument that put
|
|
477
|
+
`formatCrash` in `cli/report.ts` rather than a copy in each `catch`.
|
|
478
|
+
`runAction` is already the single convergence point for one output and one
|
|
479
|
+
exit code; `runner.ts` would have been made to hold a contract it does not
|
|
480
|
+
own. It moved to **`src/cli/action.ts`** to be testable at all: `cli/index.ts`
|
|
481
|
+
calls `program.parseAsync()` at import time, so importing it to cross the seam
|
|
482
|
+
would run the CLI. No behaviour of `index.ts` moved with it.
|
|
483
|
+
*What the regression test had to be, because the first one was wrong.* The
|
|
484
|
+
scenarios under **`ATX-64`** spawn the real CLI and read its pipes. The
|
|
485
|
+
in-process version looks equivalent and is not: they run inside a Vitest
|
|
486
|
+
worker, which patches `console` and re-routes worker output, so intercepting
|
|
487
|
+
`process.stdout.write` there observed neither the report nor the pollution and
|
|
488
|
+
went green against the unfixed code for three of three scenarios. What the
|
|
489
|
+
requirement is about is the bytes on the pipe, and only a real pipe has them.
|
|
490
|
+
The statement names its commands in `params` rather than quantifying over
|
|
491
|
+
"every command that executes project code" — a `roster` row rather than a
|
|
492
|
+
`sites` one, so a third such command cannot join without joining the
|
|
493
|
+
assertion.
|
|
494
|
+
|
|
16
495
|
## [0.7.1] - 2026-08-05
|
|
17
496
|
|
|
18
497
|
### Added
|
|
@@ -3196,99 +3675,6 @@ no symptom is the definition of what a gate cannot see.
|
|
|
3196
3675
|
`[0.4.3]` — that the gate and the run proving the fixture still works live in
|
|
3197
3676
|
different suites — applies here identically.
|
|
3198
3677
|
|
|
3199
|
-
- **The child run's stdout is not Attest's, and `--json` promises it is.**
|
|
3200
|
-
`runAndCollect` starts Vitest with the parent's fd 1 inherited, so a spec file
|
|
3201
|
-
in the project under test writes straight to the same stream the report goes
|
|
3202
|
-
to. `quiet: true` does not close it: `silent: true` suppresses Vitest's
|
|
3203
|
-
*console interception*, and a bare `process.stdout.write` in a spec — at
|
|
3204
|
-
collection time or inside a scenario body, both measured — goes past it
|
|
3205
|
-
untouched. Neither `core/terminal.ts` nor `sanitisedLogger` is on that path at
|
|
3206
|
-
all, because no Attest code is.
|
|
3207
|
-
*The machine surface is the half that is actually new.* `cli/index.ts` opens
|
|
3208
|
-
by stating that under `--json` "the only thing written to stdout is one
|
|
3209
|
-
machine-readable report", and §9.1 records that this path "was never exposed —
|
|
3210
|
-
`JSON.stringify` escapes every C0 character". Both hold for `check`, `cover`,
|
|
3211
|
-
`render` and `status`, and neither holds for `verify` or `archive`: measured,
|
|
3212
|
-
a spec writing `{"ok":true,"POLLUTION":"…"}` puts that document **first** on
|
|
3213
|
-
stdout, ahead of the real report, so `JSON.parse` of the whole stream fails
|
|
3214
|
-
(position 46) while anything reading line-wise or taking the first object
|
|
3215
|
-
reads the attacker's verdict. The terminal half is the same hole and the
|
|
3216
|
-
smaller finding — `ESC[2K CR` plus a forged `ERROR` header at column 0 and an
|
|
3217
|
-
`OSC 0` window retitle all arrive intact — but `verify` executes project code
|
|
3218
|
-
by design, so repainting a terminal is not a capability that path lacked.
|
|
3219
|
-
*The fix belongs to `runAction`, not to `runner.ts`.* That function is already
|
|
3220
|
-
the single convergence point for "one output, one exit code", which is the
|
|
3221
|
-
same argument that put `formatCrash` in `cli/report.ts` rather than leaving a
|
|
3222
|
-
copy in each `catch`: `runner.ts` would be made to hold a contract it does not
|
|
3223
|
-
own, while `runAction` is the only place the promise is made.
|
|
3224
|
-
|
|
3225
|
-
- **`render`'s document can carry raw HTML and a forged requirement section.**
|
|
3226
|
-
`sanitised` is the entry §9.1 names and it strips control characters and
|
|
3227
|
-
nothing else, so `statement` and `rationale` reach the Markdown by
|
|
3228
|
-
concatenation exactly as written. Measured: a `rationale` containing newlines
|
|
3229
|
-
and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
|
|
3230
|
-
for a requirement no registry holds, sitting between two real ones and worded
|
|
3231
|
-
identically; `<script>` and `<img onerror>` survive the same way. `check` and
|
|
3232
|
-
`cover` see only the real id, and `render --check` cannot object, because that
|
|
3233
|
-
*is* what the registry renders to now.
|
|
3234
|
-
*Not an accepted risk, which is the part worth pinning.* §9.1 and the
|
|
3235
|
-
reference both scope their guarantee to control characters, and the
|
|
3236
|
-
reference's heading over that paragraph is "Safe to read, and safe to keep",
|
|
3237
|
-
with "a site generator" named in the threat it describes. What *is* a decision
|
|
3238
|
-
is that prose may be marked up — `plain()` escapes a param value precisely
|
|
3239
|
-
because it is data while the statement around it is not — and that decision
|
|
3240
|
-
has never been separated from allowing raw HTML or a heading. So the entry is
|
|
3241
|
-
as much about stating which of the three is intended as about the escaping,
|
|
3242
|
-
and if the answer is that Markdown stays and HTML and headings go, that is a
|
|
3243
|
-
fourth obligation in §9.1 rather than a patch at an emitter.
|
|
3244
|
-
|
|
3245
|
-
- **`attest init` follows a symlink out of the project root.** `runInit` does
|
|
3246
|
-
`mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
|
|
3247
|
-
neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
|
|
3248
|
-
planted in the repository as a link to anywhere the user can write is followed
|
|
3249
|
-
by both: measured on Windows with a junction, a file at the target holding
|
|
3250
|
-
`USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
|
|
3251
|
-
prints the relative path, so nothing on screen says where it went.
|
|
3252
|
-
*`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
|
|
3253
|
-
flag defends the *temporary* path against a planted link and does exactly
|
|
3254
|
-
that; the destination's directory was never in its scope. And `workflowBody()`
|
|
3255
|
-
is a pure function of nothing, so no byte of the checked project reaches the
|
|
3256
|
-
file — the exposure is the path, not the content, which is `targets.ts`'s
|
|
3257
|
-
first rule ("Attest owns the path") being an assumption rather than a check.
|
|
3258
|
-
The check belongs beside `resolveTargets`, which is already all-or-nothing for
|
|
3259
|
-
the same reason: a name known to be unwritable must not leave half a set
|
|
3260
|
-
behind.
|
|
3261
|
-
|
|
3262
|
-
- **A deeply nested literal takes `check` down before either reader sees it.**
|
|
3263
|
-
40 KB of `[[[…]]]` in a `params` value exhausts the stack inside
|
|
3264
|
-
`ts.createSourceFile` — TypeScript's own recursive-descent parser, so
|
|
3265
|
-
`literalValue`'s recursion is never reached and hardening it would change
|
|
3266
|
-
nothing. `parser.ts` opens with the same call, so `verify` takes it too, and
|
|
3267
|
-
`loadRegistry` reads with `Promise.all`, so one file ends the whole command.
|
|
3268
|
-
*It fails closed, which is why this is a repair and not an emergency.*
|
|
3269
|
-
Measured: `internal-error`, `ok: false`, exit 1, and a well-formed `--json`
|
|
3270
|
-
envelope. What it costs is that the command this project tells people to run
|
|
3271
|
-
first on an untrusted fork MR can be made to say nothing but that, forever.
|
|
3272
|
-
The shape of the fix is already in `locate.ts`: `unreadableFiles` exists to
|
|
3273
|
-
say "this file could not be read" without ending the run, and a `RangeError`
|
|
3274
|
-
out of a single `read` or `parseSpecFile` is that, so a hostile file scraps
|
|
3275
|
-
only itself and `check`'s breadth contract survives it.
|
|
3276
|
-
|
|
3277
|
-
- **`status` prints one line the sanitiser never sees.** `cli/index.ts` writes
|
|
3278
|
-
`Change: ${result.change}` bare, while `formatStatus` puts the same value
|
|
3279
|
-
through `inline` twice, three lines apart in the same output. Measured with a
|
|
3280
|
-
directory named with U+009B — the C1 CSI the class in `control` exists for,
|
|
3281
|
-
and one NTFS permits — the header carried `c2 9b` raw while the closing
|
|
3282
|
-
`Not a verdict:` line carried a space. A change name is a directory name in
|
|
3283
|
-
the repository under test, and the workflow `init` writes tells an agent to
|
|
3284
|
-
run `attest status <name>`, so the value is the project's to choose.
|
|
3285
|
-
*One `inline()` call, and the entry is here for the reason rather than the
|
|
3286
|
-
fix.* §9.1 says a stream has no entry, only writes, so the obligation sits on
|
|
3287
|
-
every write or not at all — and this is the second time a single missed write
|
|
3288
|
-
has been the whole defect, after the two `catch` blocks that printed a raw
|
|
3289
|
-
stack beside sanitised diagnostics. The sweep filed above under `ATX-37` is
|
|
3290
|
-
what would have found it; this is one more measurement in favour of it.
|
|
3291
|
-
|
|
3292
3678
|
## Under consideration
|
|
3293
3679
|
|
|
3294
3680
|
Candidates with **no decision yet**, between the two sections either side of it
|
|
@@ -3301,6 +3687,25 @@ deletion. What each entry has to carry is the evidence currently blocking the
|
|
|
3301
3687
|
decision, because that is what a re-proposal a year from now would otherwise
|
|
3302
3688
|
have to gather again.
|
|
3303
3689
|
|
|
3690
|
+
- **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
|
|
3691
|
+
The provenance work in `[0.7.2]` signs the release but does not change
|
|
3692
|
+
what authorises it: a long-lived automation token, masked and protected, that
|
|
3693
|
+
publishes as us to anyone holding it. npm's trusted publishing exchanges the
|
|
3694
|
+
same OIDC identity the attestation already uses for a short-lived credential,
|
|
3695
|
+
which would remove the stored secret entirely and grant provenance as a
|
|
3696
|
+
property of the flow rather than a flag on the command. The deletion test
|
|
3697
|
+
points the right way for once: what it removes is the only long-lived
|
|
3698
|
+
credential this repository has.
|
|
3699
|
+
*What blocks the decision, and it is evidence rather than principle.* Whether
|
|
3700
|
+
npm's trusted publishers accept a **gitlab.com-hosted** project on the plan
|
|
3701
|
+
this package publishes under has not been verified against this project — it
|
|
3702
|
+
was not checked when the provenance job was written, and an entry claiming it
|
|
3703
|
+
works would be exactly the unverified confidence the rest of this file avoids.
|
|
3704
|
+
It also cannot be tested except by publishing: as with provenance, there is no
|
|
3705
|
+
dry run, so a failed experiment is a release. The cheap next step is reading
|
|
3706
|
+
npm's current trusted-publisher documentation for GitLab support before
|
|
3707
|
+
writing any YAML.
|
|
3708
|
+
|
|
3304
3709
|
- **A static sweep for the two properties claimed over the whole of `src/`.**
|
|
3305
3710
|
The quantifier table's first strict read left three rows wanting the same
|
|
3306
3711
|
thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
|
|
@@ -3439,6 +3844,103 @@ have to gather again.
|
|
|
3439
3844
|
assertion is still green, still silent, and still reachable — so this stays
|
|
3440
3845
|
here rather than moving to `Considered and rejected`: documenting a trap is
|
|
3441
3846
|
not the same as deciding not to detect it.
|
|
3847
|
+
**Re-measured after `[0.7.1]` widened `params`, on this registry rather than
|
|
3848
|
+
the reporter's — the first time this experiment has been run here.** Twelve
|
|
3849
|
+
mutations, each followed by the whole gate. Two results matter, and they point
|
|
3850
|
+
opposite ways.
|
|
3851
|
+
*The shape is now reachable here, and the widening is why.* `self/render.spec.ts`
|
|
3852
|
+
asserts `expect(md).toContain(JSON.stringify(kinds))` over the same
|
|
3853
|
+
`payloadKinds` it feeds to the renderer — the expectation is a function of the
|
|
3854
|
+
value under test, verbatim row 3, and it could not have been written before
|
|
3855
|
+
the widening because the param could not be an object. The mechanism is
|
|
3856
|
+
sharper than a frequency count and is the strongest thing this entry has
|
|
3857
|
+
gained: **the prescribed remedy costs in proportion to the value's
|
|
3858
|
+
complexity.** Same repo, adjacent files, same value shape — `ATX-7` pins two
|
|
3859
|
+
numbers with literals and goes red; `ATX-63` would have had to hand-write a
|
|
3860
|
+
rendered JSON block, so it echoed. Depth raises the defect rate and the cost
|
|
3861
|
+
of avoiding it along one axis.
|
|
3862
|
+
*And its cost here is nil, which is the honest other half.* Both composite
|
|
3863
|
+
params are fixtures for the renderer and the validator; the numbers stand for
|
|
3864
|
+
nothing, so the green is arguably correct. Blocker **(1)** is therefore only
|
|
3865
|
+
half retired — the shape is reproduced in a second, mature repo, and the count
|
|
3866
|
+
of *harmful* instances is still zero. Blocker **(2)** is reinforced rather than
|
|
3867
|
+
weakened: all four green mutations were on params `src/` never reads, so the
|
|
3868
|
+
reporter's "read from both a spec and a non-spec file" detector would have
|
|
3869
|
+
flagged none of them. Blocker **(3)** is untouched.
|
|
3870
|
+
*The frequency is no longer unmeasured, and it points away from building
|
|
3871
|
+
anything.* Blocker **(1)** rested on 8 commits of one greenfield repo. Parsed
|
|
3872
|
+
at every one of the **38** revisions that have touched this registry — all 38
|
|
3873
|
+
readable by the current static reader, so nothing was skipped — the history is
|
|
3874
|
+
**57 param keys added, 0 removed, and exactly 1 value changed**: `ATX-16.staticCommands`
|
|
3875
|
+
gaining `status`. That single edit is a roster *widening*, the safe direction,
|
|
3876
|
+
and it is on the one list that was already pinned, so it could not have been
|
|
3877
|
+
made without updating the pin. Two independent codebases, one greenfield and
|
|
3878
|
+
one at 64 requirements, and the event this whole entry is about has happened
|
|
3879
|
+
zero times in either. What that argues is not that the hole is unreal — it is
|
|
3880
|
+
reachable, demonstrated above — but that it is **rare and silent**, which is
|
|
3881
|
+
the profile `divergent-param` was rejected on rather than the profile that
|
|
3882
|
+
justifies an engine rule.
|
|
3883
|
+
*The same number says the split-out fix below has also never been triggered.*
|
|
3884
|
+
0 removals ever means no domain has ever been shortened here. Those pins close
|
|
3885
|
+
a live *exposure*, not a live *loss*, and the case for them is that they cost
|
|
3886
|
+
one line each against a failure with no symptom — not that anything was
|
|
3887
|
+
currently broken.
|
|
3888
|
+
*Blocker **(3)** assumed mutation testing was the only separator. A cheaper
|
|
3889
|
+
candidate existed, has now been scored, and does not survive it.* The idea was
|
|
3890
|
+
a **syntactic** discriminator over one test body — flag an assertion whose
|
|
3891
|
+
expected value derives from a param that also reached the arrange/act part —
|
|
3892
|
+
which is exactly the difference between the two cases the widening produced:
|
|
3893
|
+
`renderMarkdown({params: {payloadKinds: kinds}})` … `toContain(JSON.stringify(kinds))`
|
|
3894
|
+
against `expect(kinds.json.maxBytes).toBe(1_048_576)`. Scored the way
|
|
3895
|
+
`divergent-param` should have been and was not: implemented in
|
|
3896
|
+
`tests/intent-rule-candidates.spec.ts`, run over the specs that attest this
|
|
3897
|
+
repo, and then **every param it fires on mutated with the whole gate run —
|
|
3898
|
+
fifteen of them, one gate run each. Four went green; eleven went red.**
|
|
3899
|
+
*Then the detector was found to be wrong, which is the more useful half.* Its
|
|
3900
|
+
first implementation counted an identifier in any position as a read of a
|
|
3901
|
+
local with that name, so `i.code` in `issues.map((i) => i.code)` looked like a
|
|
3902
|
+
use of a param named `code` — and `code` is this repo's commonest param name.
|
|
3903
|
+
Corrected to count reference positions only, it flags **8, not 15**, and the
|
|
3904
|
+
seven it drops are exactly seven the mutations had already shown to be pinned
|
|
3905
|
+
by `src/`. The honest score is therefore **4 of 8, not 4 of 15** — precision
|
|
3906
|
+
50%, not 27%. The conclusion does not move, because it never rested on the
|
|
3907
|
+
precision figure; what moves is how much of the original number was the
|
|
3908
|
+
candidate failing and how much was the measurement being wrong, and that is
|
|
3909
|
+
worth writing down rather than quietly restating.
|
|
3910
|
+
The remaining false positives are not near-misses. They are independent terms
|
|
3911
|
+
the detector cannot see: **the runtime is the term** — `ATX-14`'s
|
|
3912
|
+
`inheritedKey` is `'toString'`, and a mutated spelling is not a prototype key
|
|
3913
|
+
at all, so the behaviour under test changes; **`src/` is the term** —
|
|
3914
|
+
`ATX-51`'s `code` is a second copy of a string the engine emits as a literal;
|
|
3915
|
+
and **arithmetic is the term** — `threshold + 3` builds a derived expectation
|
|
3916
|
+
that mutation breaks.
|
|
3917
|
+
And the four true positives fail the second test, which is the one that
|
|
3918
|
+
decides it: **none is harmful.** `ATX-10` and `ATX-3` assert that a value was
|
|
3919
|
+
substituted or applied, where the identity of the number is not the
|
|
3920
|
+
obligation; `ATX-51`'s is a path prefix and `ATX-63`'s a rendering fixture.
|
|
3921
|
+
Telling those from a mine-density band that must not move is a judgement about
|
|
3922
|
+
whether the value *means* anything — the ground truth §0 refuses to compute
|
|
3923
|
+
for someone else's registry. **50% precision on the shape, 0% on the harm.**
|
|
3924
|
+
Two limits on that number, stated because a score with an unstated method is
|
|
3925
|
+
what this file exists to prevent. The mutation has **one direction per kind**
|
|
3926
|
+
— numbers +1, lists drop-last, strings suffixed — and direction can decide the
|
|
3927
|
+
verdict: `ATX-40.languages` is red at 2→3 and was green at 2→1 before it was
|
|
3928
|
+
pinned, so the true-positive count is a floor. And **recall is unmeasured**:
|
|
3929
|
+
the eight params mutated earlier that this detector does not flag all went
|
|
3930
|
+
red, which is eight correct negatives and not a recall figure.
|
|
3931
|
+
The detector and its verdict table are kept in the measurement suite, with the
|
|
3932
|
+
reach recomputed on every run and the table asserted to cover exactly what
|
|
3933
|
+
fires — because the reach figure quoted for `compound-requirement` went stale
|
|
3934
|
+
in this very file for want of that.
|
|
3935
|
+
*A scoping limit on all of the above.* This repo cannot settle blocker **(2)**.
|
|
3936
|
+
`src/` reads no param at all — every `reqs[…].params` in it is a comment or a
|
|
3937
|
+
doc string — so params here are test vocabulary, while in the reporter's repo
|
|
3938
|
+
they are application constants the app itself consumes. The reporter's
|
|
3939
|
+
detector keys on exactly that difference, so it is inert here and this corpus
|
|
3940
|
+
is evidence neither for nor against it.
|
|
3941
|
+
*What the re-measurement did find is a different defect, and it has been split
|
|
3942
|
+
out below* — the entry is kept whole here because the two were measured in one
|
|
3943
|
+
pass and separating them is the finding.
|
|
3442
3944
|
|
|
3443
3945
|
- **Whether `check` and `verify` should refuse a half-loaded registry the way
|
|
3444
3946
|
`cover` and `render` do.** Those two return early on `hasError(loadIssues)`,
|
|
@@ -3560,41 +4062,6 @@ have to gather again.
|
|
|
3560
4062
|
because a project whose requirement count only rises should have decided in
|
|
3561
4063
|
advance what would make one leave.
|
|
3562
4064
|
|
|
3563
|
-
- **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
|
|
3564
|
-
several.** `shellArg` asserts the argument holds no `"` and then quotes only
|
|
3565
|
-
when it holds whitespace, so an argument with none goes to the shell bare —
|
|
3566
|
-
and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
|
|
3567
|
-
does not name. The fix is one character class, which is why the entry is not
|
|
3568
|
-
about the fix.
|
|
3569
|
-
*What blocks the decision is that no input reaches it.* Both arguments are
|
|
3570
|
-
either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
|
|
3571
|
-
and CI is Linux, and the one path an attacker could plausibly influence — the
|
|
3572
|
-
checkout directory on a fork MR pipeline — comes from a GitLab project path,
|
|
3573
|
-
whose character set excludes every metacharacter above. So this is an
|
|
3574
|
-
observation with no attack path, and the honest question is not whether to
|
|
3575
|
-
widen the class but whether the comment above it should stop claiming more
|
|
3576
|
-
than the assertion covers: it says "the assertion keeps that true" about
|
|
3577
|
-
arguments generally, where what it checks is one character. Widening the class
|
|
3578
|
-
and leaving the prose is the change that would look like a fix and settle
|
|
3579
|
-
nothing.
|
|
3580
|
-
|
|
3581
|
-
- **A loader that fails to start leaves its scratch directory behind.**
|
|
3582
|
-
`createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
|
|
3583
|
-
and the only `rm` of that directory is inside the `close` of the object
|
|
3584
|
-
`createServer` returns — so a throw from it leaks one `attest-loader-*` per
|
|
3585
|
-
invocation, which on a CI runner is one per build. That is the exact failure
|
|
3586
|
-
shape the comment on `close` records as already fixed, surviving on the other
|
|
3587
|
-
path.
|
|
3588
|
-
*What blocks it is that nothing has been made to throw there.* With
|
|
3589
|
-
`configFile: false` Vite still reads the `package.json` at the root to decide
|
|
3590
|
-
`type`, so a malformed one in the checked project is the obvious candidate and
|
|
3591
|
-
it is the project's to write — but it was not tried, and an entry claiming a
|
|
3592
|
-
trigger it has not seen is the thing this section exists to not accumulate.
|
|
3593
|
-
The repair is three lines (a `try` around the two calls that `rm`s and
|
|
3594
|
-
rethrows) and is not what the decision turns on; measuring whether the throw
|
|
3595
|
-
is reachable at all is, because if it is not, this is a leak with no input
|
|
3596
|
-
that produces it.
|
|
3597
|
-
|
|
3598
4065
|
## Considered and rejected
|
|
3599
4066
|
|
|
3600
4067
|
Decisions **not** to build something, kept where they can be found before the
|
|
@@ -3919,7 +4386,8 @@ requirement, and human review at propose is still the whole answer. What is
|
|
|
3919
4386
|
rejected is grouping as a way to assist it, and this reopens only on a
|
|
3920
4387
|
contradiction that grouping would have caught.
|
|
3921
4388
|
|
|
3922
|
-
[
|
|
4389
|
+
[0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
|
|
4390
|
+
[0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
|
|
3923
4391
|
[0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
|
|
3924
4392
|
[0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0
|
|
3925
4393
|
[0.6.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.6.0
|