Skip to content

← The log

Two checks that passed when they should not have

A flag can survive its own removal, and a stray carriage return could turn a line nobody had measured into a value that looked fine. Both checks tested a proxy instead of the thing.

FixKonstantin Tarkus

Two checks reported success while proving nothing, and both failed in the direction that lets work proceed. That is the expensive direction, and it is not close. Accept a skill Claude will not run and the request is spent: the run is claimed, the agent does nothing useful, and GitHub does not resend the event that started it. Refuse one that would have worked and the run is held instead — it stays queued, the runner logs why every cycle, and it proceeds the moment the skill is fixed. One loses the work. The other postpones it.

A flag that survives its own removal

engwire doctor checks that --setting-sources is still there, because the trust boundary rests on it. It used to grep claude --help, which is a check on a help page rather than on behaviour. Replacing that with the obvious thing — run Claude with the flag and see whether it complains — turned out to be no better, because --version short-circuits unknown-flag validation: a flag that does not exist is tolerated, not rejected.

So doctor now asks two questions and reads only the exit codes. claude --setting-sources user --version has to succeed, and the same command with a source name that cannot exist has to fail. Neither answer means anything alone — the pair does. A binary that accepts both has stopped parsing the flag; one that rejects both is not running at all. The four probes, and why neither of the first two is enough on its own, are written up with the measurements.

Front matter that stopped at a carriage return

The preflight reads a skill’s front matter before a run is claimed. It cannot promise Claude will invoke the skill — a skillOverrides: "off" in Claude’s own settings disables one that passes, and Engwire deliberately does not read those. What it can catch is a skill that declares itself un-invocable in the file already on disk, which is worth catching because Claude declining a skill by name fails silently.

It matched the declaration with . — and in a JavaScript regular expression, . stops at \r, \n, U+2028 and U+2029, while a multiline $ matches before every one of them.

So a line carrying a real carriage return in the middle of it, written escaped here:

user-invocable: true\rgarbage

read as a tidy true. The capture stopped at the carriage return, $ was satisfied there, and the rest of the author’s line was discarded unseen. What Claude does with that line, nobody knows — and that is the whole problem. The check quietly normalised syntax nobody had measured into a value it had, then answered with confidence. If the answer is wrong, the run is claimed, spent, and reported as done.

Why the pattern has to be looser than the rule

It is tempting to fix this by matching only the spellings Claude was measured to honour. That is backwards, and the reason is the part worth taking away.

No declaration means invocable. A skill with no user-invocable line at all is one Claude will run. So a pattern that recognises only valid spellings reads user-invocable: banana split as no declaration — and no declaration is a pass. Tighten the recogniser and malformed input stops looking malformed; it starts looking absent, which is the one thing that fails open.

Recognition is therefore deliberately broader than acceptance. The pattern captures the whole rest of the line, unbalanced quotes around the key and all, so that whatever is wrong with it arrives in front of the code that refuses it:

/^[ \t]*(["']?)user-invocable(["']?)[ \t]*:([^\n]*)$/gm;

Against that line, the two patterns differ by everything that matters:

old  /…:(.*)$/gm        captures " true"
new  /…:([^\n]*)$/gm    captures " true\rgarbage"

[^\n] matches a carriage return, so the value no longer ends at one. The anchors still treat all four characters as line terminators — that is the m flag and not ours to change — but the value is now everything up to the newline, where it is refused rather than trimmed away.

One carriage return is a line ending. The rest are content.

Stopping there would refuse every skill written on Windows, since a CRLF line hands the value a trailing \r of its own. So exactly one is removed, and only from the end.

That rule then has to hold in both places the front matter is parsed, which is why the block’s closing pattern is \n--- and not \r?\n---. A \r? there would take the carriage return off the last declaration in the block — and then normalisation would take a second, so two would read as one line ending, for that line only. Left alone, the rule is the same everywhere: one \r is the line ending, any others stay in the value.

Nobody needs to establish how a stray carriage return got into a file — a generator that concatenated wrong, a mixed-ending merge, or someone trying it on. The check cannot tell those apart, and does not have to. It only has to never be more permissive than Claude.

Stricter is fine, and Engwire is: Claude honours more spellings than true, 1, yes and on, and Engwire refuses the rest rather than generalise from a measurement to a rule. A skill turned away says why, in a line you can act on. A skill wrongly accepted says nothing until the request is gone.

That the two defects arrived in one pull request is a coincidence. That they were the same defect is not. Each checked a proxy — a help page, a value after normalisation — and a proxy reports success without establishing anything. Both now test the thing itself: doctor makes Claude reject something to prove it is still reading the flag, and the skill check hands the author’s line to the code that refuses it, punctuation and all.

engwire/engwire#14 →

Engwire reviews the pull requests that ask for you, on your own machine.

MIT · macOS and Linux · no account to make

curl -fsSL https://engwire.com/install.sh | sh

What Engwire does →