@jjanczur/tyran 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/.claude-plugin/marketplace.json +22 -0
- package/.claude-plugin/plugin.json +22 -0
- package/CHANGELOG.md +245 -0
- package/LICENSE +201 -0
- package/README.md +468 -0
- package/agents/.gitkeep +0 -0
- package/agents/implementer.md +60 -0
- package/agents/retro.md +88 -0
- package/agents/reviewer.md +55 -0
- package/agents/scout.md +60 -0
- package/bin/tyran.mjs +172 -0
- package/hooks/HOOK-CONTRACT-MEASURED.md +377 -0
- package/hooks/hooks.json +83 -0
- package/hooks/scripts/.gitkeep +0 -0
- package/hooks/scripts/evidence-gate.mjs +705 -0
- package/hooks/scripts/hook-io.mjs +813 -0
- package/hooks/scripts/policy-gate.mjs +1402 -0
- package/hooks/scripts/pre-compact.mjs +211 -0
- package/hooks/scripts/retro-gate.mjs +191 -0
- package/hooks/scripts/secrets-gate.mjs +1683 -0
- package/hooks/scripts/session-start.mjs +358 -0
- package/hooks/scripts/write-guard.mjs +475 -0
- package/package.json +52 -0
- package/scripts/desc-budget.mjs +139 -0
- package/scripts/doctor.mjs +1267 -0
- package/scripts/hooks-check.mjs +1312 -0
- package/scripts/invisible.mjs +346 -0
- package/scripts/journal.mjs +747 -0
- package/scripts/project.mjs +981 -0
- package/scripts/scan-control-chars.mjs +547 -0
- package/scripts/scan-repo.mjs +287 -0
- package/scripts/schema.mjs +467 -0
- package/scripts/stop-check.mjs +89 -0
- package/scripts/tiers.mjs +324 -0
- package/scripts/yaml-lite.mjs +383 -0
- package/skills/browser-check/SKILL.md +118 -0
- package/skills/code-review/SKILL.md +83 -0
- package/skills/deslop/SKILL.md +97 -0
- package/skills/doctor/SKILL.md +35 -0
- package/skills/fidelity-gate/SKILL.md +132 -0
- package/skills/hello/SKILL.md +16 -0
- package/skills/pr-feedback/SKILL.md +85 -0
- package/skills/prompt-tuning/SKILL.md +102 -0
- package/skills/retro/SKILL.md +69 -0
- package/skills/root-cause/SKILL.md +89 -0
- package/skills/run/SKILL.md +285 -0
- package/skills/setup/SKILL.md +79 -0
- package/skills/skill-writing/SKILL.md +99 -0
- package/skills/status/SKILL.md +31 -0
- package/templates/.gitkeep +0 -0
- package/templates/config.yaml +44 -0
- package/templates/knowledge.yaml +23 -0
- package/templates/policies/autonomy.yaml +61 -0
- package/templates/project-command/SKILL.md +16 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scan-control-chars — refuse raw control and bidi characters in tracked files.
|
|
4
|
+
*
|
|
5
|
+
* Why this is a gate and not a lint rule (ADR-19): writing and editing tools
|
|
6
|
+
* routinely turn the TEXT of an escape (backslash-u-2-0-2-E) into the actual
|
|
7
|
+
* character. Seven independent occurrences landed in a single working session
|
|
8
|
+
* of this repo, twice by agents who knew about the problem. The damage is
|
|
9
|
+
* silent, which is what makes it expensive:
|
|
10
|
+
* - one NUL byte in a documentation file made `grep` return zero matches
|
|
11
|
+
* with exit 0 — the file looked empty to every search that followed;
|
|
12
|
+
* - an unterminated bidi override in a generated STATE.md mirrored every
|
|
13
|
+
* later column of the table row, so the rendered document disagreed with
|
|
14
|
+
* its own bytes (Trojan Source).
|
|
15
|
+
*
|
|
16
|
+
* The scan is byte-honest: it reports the file, the line, the column, the byte
|
|
17
|
+
* offset and the codepoint, because "there is an invisible character somewhere
|
|
18
|
+
* in this file" is not a fixable finding — it is a gate people switch off.
|
|
19
|
+
*
|
|
20
|
+
* Binaries are exempted only where .gitattributes DECLARES them binary, never
|
|
21
|
+
* by an extension whitelist and never by a property of their contents — see
|
|
22
|
+
* partitionTrackedFiles for why every content-derived exemption is defeatable
|
|
23
|
+
* by editing the content, and why no file may leave the scan silently.
|
|
24
|
+
*
|
|
25
|
+
* CLI:
|
|
26
|
+
* node scan-control-chars.mjs [repo-root]
|
|
27
|
+
* Exit: 0 clean · 1 forbidden characters found, or a tracked file that is
|
|
28
|
+
* neither decodable nor declared binary · 2 usage/IO error, or a scan
|
|
29
|
+
* that covered zero files
|
|
30
|
+
*/
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
33
|
+
import { resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import {
|
|
36
|
+
DELIBERATELY_ALLOWED,
|
|
37
|
+
FORBIDDEN,
|
|
38
|
+
formatCodePoint,
|
|
39
|
+
identifierProblem,
|
|
40
|
+
invisibleProblem,
|
|
41
|
+
} from './invisible.mjs';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The rule itself now lives in scripts/invisible.mjs and is re-exported here
|
|
45
|
+
* UNCHANGED in shape, because this scanner was where it was written down first
|
|
46
|
+
* and other code already imports it from this path.
|
|
47
|
+
*
|
|
48
|
+
* Why it moved (ADR-19 correction 1 point 4, ADR-21): the same rule had three
|
|
49
|
+
* spellings — this list, `journal.agentNameProblem`'s `\p{Cc}\p{Cf}` and a
|
|
50
|
+
* hand-maintained character class inside `project.inline()` — and measured
|
|
51
|
+
* over the whole of Unicode they disagreed on 456 codepoints in 37 ranges.
|
|
52
|
+
* The layering was inverted: this file, which protects OUR repository, caught
|
|
53
|
+
* the entire TAG block, while `inline()`, which protects the STATE.md an agent
|
|
54
|
+
* reads, passed all 128 of them straight through.
|
|
55
|
+
*
|
|
56
|
+
* The list is no longer the boundary of the rule — `invisibleProblem` is, and
|
|
57
|
+
* it consults a Unicode property so the boundary stops being one revision
|
|
58
|
+
* behind. The list is the VOCABULARY: it is what turns a finding from
|
|
59
|
+
* "default-ignorable" into a sentence a reader can act on.
|
|
60
|
+
*/
|
|
61
|
+
export { FORBIDDEN, DELIBERATELY_ALLOWED };
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/** Names worth spelling out; everything else falls back to its range label. */
|
|
65
|
+
const NAMES = Object.freeze({
|
|
66
|
+
0x00: 'NUL',
|
|
67
|
+
0x07: 'BEL',
|
|
68
|
+
0x08: 'BACKSPACE',
|
|
69
|
+
0x0b: 'VERTICAL TAB',
|
|
70
|
+
0x0c: 'FORM FEED',
|
|
71
|
+
0x0d: 'CARRIAGE RETURN',
|
|
72
|
+
0x09: 'TAB',
|
|
73
|
+
0x1b: 'ESC',
|
|
74
|
+
0x0a: 'LF',
|
|
75
|
+
0x7f: 'DELETE',
|
|
76
|
+
0x061c: 'ARABIC LETTER MARK',
|
|
77
|
+
0x200b: 'ZERO WIDTH SPACE',
|
|
78
|
+
0x200c: 'ZERO WIDTH NON-JOINER',
|
|
79
|
+
0x200d: 'ZERO WIDTH JOINER',
|
|
80
|
+
0x200e: 'LEFT-TO-RIGHT MARK',
|
|
81
|
+
0x200f: 'RIGHT-TO-LEFT MARK',
|
|
82
|
+
0x202a: 'LEFT-TO-RIGHT EMBEDDING',
|
|
83
|
+
0x202b: 'RIGHT-TO-LEFT EMBEDDING',
|
|
84
|
+
0x202c: 'POP DIRECTIONAL FORMATTING',
|
|
85
|
+
0x202d: 'LEFT-TO-RIGHT OVERRIDE',
|
|
86
|
+
0x202e: 'RIGHT-TO-LEFT OVERRIDE',
|
|
87
|
+
0x2066: 'LEFT-TO-RIGHT ISOLATE',
|
|
88
|
+
0x2067: 'RIGHT-TO-LEFT ISOLATE',
|
|
89
|
+
0x2068: 'FIRST STRONG ISOLATE',
|
|
90
|
+
0x2069: 'POP DIRECTIONAL ISOLATE',
|
|
91
|
+
0x00ad: 'SOFT HYPHEN',
|
|
92
|
+
0x115f: 'HANGUL CHOSEONG FILLER',
|
|
93
|
+
0x1160: 'HANGUL JUNGSEONG FILLER',
|
|
94
|
+
0x180e: 'MONGOLIAN VOWEL SEPARATOR',
|
|
95
|
+
0x2060: 'WORD JOINER',
|
|
96
|
+
0x2061: 'FUNCTION APPLICATION',
|
|
97
|
+
0x2062: 'INVISIBLE TIMES',
|
|
98
|
+
0x2063: 'INVISIBLE SEPARATOR',
|
|
99
|
+
0x2064: 'INVISIBLE PLUS',
|
|
100
|
+
0x3164: 'HANGUL FILLER',
|
|
101
|
+
0xffa0: 'HALFWIDTH HANGUL FILLER',
|
|
102
|
+
0xfeff: 'BYTE ORDER MARK',
|
|
103
|
+
0xfff9: 'INTERLINEAR ANNOTATION ANCHOR',
|
|
104
|
+
0xfffa: 'INTERLINEAR ANNOTATION SEPARATOR',
|
|
105
|
+
0xfffb: 'INTERLINEAR ANNOTATION TERMINATOR',
|
|
106
|
+
0xe0001: 'LANGUAGE TAG',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The name to print for a codepoint, or null when the range label says enough.
|
|
111
|
+
*
|
|
112
|
+
* TAG characters get their ASCII equivalent spelled out, because U+E0041 is
|
|
113
|
+
* not a fact anyone can act on while `TAG for ASCII "A"` tells the reader what
|
|
114
|
+
* the invisible text actually SAID. A gate that reports an unfixable finding
|
|
115
|
+
* is an obstacle (ADR-19).
|
|
116
|
+
*/
|
|
117
|
+
function nameOf(cp) {
|
|
118
|
+
if (NAMES[cp] !== undefined) return NAMES[cp];
|
|
119
|
+
if (cp >= 0xe0020 && cp <= 0xe007e) {
|
|
120
|
+
return `TAG for ASCII ${JSON.stringify(String.fromCharCode(cp - 0xe0000))}`;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* One call, no options. Every layer of this repo asks this same question of
|
|
127
|
+
* the same function, so "is this codepoint invisible" has exactly one answer
|
|
128
|
+
* (ADR-21: one ANSWER, not one function).
|
|
129
|
+
*/
|
|
130
|
+
const classify = (cp) => invisibleProblem(cp);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The invisibility rule PLUS the disjoint identifier rule, for text that is a
|
|
134
|
+
* PATH rather than file contents. The asymmetry is deliberate and it is not a
|
|
135
|
+
* second configuration of the invisibility answer: a tab is ordinary, visible
|
|
136
|
+
* text inside a file and a catastrophe in a filename, where it makes one path
|
|
137
|
+
* print as two columns in every tool that lists it — and a newline in a path
|
|
138
|
+
* breaks the line-oriented output of all of them. The two rules never overlap,
|
|
139
|
+
* and a test asserts that over the whole of Unicode.
|
|
140
|
+
*/
|
|
141
|
+
const classifyInPath = (cp) => identifierProblem(cp);
|
|
142
|
+
|
|
143
|
+
/** UTF-8 width of a codepoint — lets us report byte offsets without re-encoding. */
|
|
144
|
+
function utf8Len(cp) {
|
|
145
|
+
if (cp < 0x80) return 1;
|
|
146
|
+
if (cp < 0x800) return 2;
|
|
147
|
+
if (cp < 0x10000) return 3;
|
|
148
|
+
return 4;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `U+00A0` style, re-exported unchanged from the module that owns the rule.
|
|
153
|
+
*
|
|
154
|
+
* It moved because the escaper that USES it moved: `escapeInvisible` renders
|
|
155
|
+
* `<U+202E>`, and a second copy of the formatter next to the first copy of the
|
|
156
|
+
* escaper is how the notation drifts into two dialects. Same reasoning as
|
|
157
|
+
* FORBIDDEN above — one rule, one home — and the export shape here is
|
|
158
|
+
* untouched, so every existing importer of this path keeps working.
|
|
159
|
+
*/
|
|
160
|
+
export { formatCodePoint };
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Every forbidden codepoint in `text`, each with line, column (in codepoints),
|
|
164
|
+
* byte offset and identity. Pure and file-free, which is what makes the rule
|
|
165
|
+
* itself testable rather than only its command-line wrapper.
|
|
166
|
+
*/
|
|
167
|
+
export function scanText(text, classifier = classify) {
|
|
168
|
+
const findings = [];
|
|
169
|
+
let line = 1;
|
|
170
|
+
let column = 1;
|
|
171
|
+
let byteOffset = 0;
|
|
172
|
+
for (const ch of text) {
|
|
173
|
+
const cp = ch.codePointAt(0);
|
|
174
|
+
const what = classifier(cp);
|
|
175
|
+
if (what !== null) {
|
|
176
|
+
findings.push({
|
|
177
|
+
line,
|
|
178
|
+
column,
|
|
179
|
+
byteOffset,
|
|
180
|
+
codePoint: cp,
|
|
181
|
+
name: nameOf(cp),
|
|
182
|
+
what,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
byteOffset += utf8Len(cp);
|
|
186
|
+
if (cp === 0x0a) {
|
|
187
|
+
line++;
|
|
188
|
+
column = 1;
|
|
189
|
+
} else {
|
|
190
|
+
column++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every forbidden codepoint in a PATH — a file's name or a symlink's target.
|
|
198
|
+
* Both reach the reader through tool output and through the projections an
|
|
199
|
+
* agent reads, so both are part of the repository's text even though neither
|
|
200
|
+
* is inside a file.
|
|
201
|
+
*/
|
|
202
|
+
export function scanPath(path) {
|
|
203
|
+
return scanText(path, classifyInPath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* One finding as an operator-readable line: file, position, identity.
|
|
208
|
+
*
|
|
209
|
+
* `where` says which text the position refers to. Without it a hit at 1:5 in a
|
|
210
|
+
* NAME reads as a hit at 1:5 in the contents, and the reader edits the wrong
|
|
211
|
+
* thing — the same "unfixable finding" failure the byte offsets exist to avoid.
|
|
212
|
+
*/
|
|
213
|
+
export function formatFinding(file, f, where = 'content') {
|
|
214
|
+
const name = f.name ? ` ${f.name}` : '';
|
|
215
|
+
const site = where === 'content' ? '' : ` [in the ${where === 'name' ? 'file NAME' : 'symlink TARGET'}]`;
|
|
216
|
+
return `${file}:${f.line}:${f.column} (byte ${f.byteOffset}): ${formatCodePoint(f.codePoint)}${name} — ${f.what}${site}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function git(args, cwd) {
|
|
220
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether a file's bytes decode as UTF-8. Never used as a silent exemption. */
|
|
224
|
+
function isValidUtf8(buffer) {
|
|
225
|
+
try {
|
|
226
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
227
|
+
return true;
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Partition tracked files into what gets scanned, what is exempt, and what is
|
|
235
|
+
* refused. Never an extension whitelist: a whitelist says nothing about the
|
|
236
|
+
* next file type someone adds.
|
|
237
|
+
*
|
|
238
|
+
* The shape of this function matters more than its criteria, because of a
|
|
239
|
+
* lesson this gate learned twice.
|
|
240
|
+
*
|
|
241
|
+
* First attempt: skip what GIT calls binary. That is circular. Git calls a
|
|
242
|
+
* file binary when it finds a NUL in the first 8000 bytes, so a source file
|
|
243
|
+
* poisoned with one stray NUL becomes "binary" the instant it is damaged and
|
|
244
|
+
* leaves the scan for exactly the reason it should have been flagged. It
|
|
245
|
+
* happened here: this repo's own fuzz test picked up a raw NUL, git
|
|
246
|
+
* reclassified it, and the scan reported the tree clean over a poisoned file.
|
|
247
|
+
*
|
|
248
|
+
* Second attempt: skip what is not valid UTF-8. Weaker loop, same shape —
|
|
249
|
+
* appending ONE 0xFF byte to a poisoned README makes it "not text" and the
|
|
250
|
+
* bidi override inside it sails through under a green tick. Measured, not
|
|
251
|
+
* feared.
|
|
252
|
+
*
|
|
253
|
+
* So the conclusion is not "find a better content test". ANY exemption
|
|
254
|
+
* derived from a file's content can be manufactured by editing that content.
|
|
255
|
+
* The fix is structural: **no file may leave the scan silently.**
|
|
256
|
+
*
|
|
257
|
+
* - `exempt` — declared `binary` in .gitattributes. A real escape hatch, and
|
|
258
|
+
* it must stay one, or an undecodable asset would jam CI forever. It is
|
|
259
|
+
* listed in the output on EVERY run, including clean ones, so using it is
|
|
260
|
+
* a visible act in a diff and in a log rather than a quiet edit.
|
|
261
|
+
* - `refused` — not valid UTF-8 and NOT declared. This is an ERROR, not a
|
|
262
|
+
* skip: it is the shape of both attacks above, and the remedy is one line
|
|
263
|
+
* of .gitattributes that a reviewer can see.
|
|
264
|
+
* - everything else is scanned.
|
|
265
|
+
*
|
|
266
|
+
* Note what is deliberately NOT an exemption: `-text`. This repo sets
|
|
267
|
+
* `tests/fixtures/** -text` to protect byte-exact goldens, and those goldens
|
|
268
|
+
* are generated STATE.md files — precisely where the bidi bug behind ADR-19
|
|
269
|
+
* landed. Excusing them would aim the gate away from its own motivating case.
|
|
270
|
+
*/
|
|
271
|
+
const LINK_REMEDY =
|
|
272
|
+
'Git records the mode of every entry. Inspect it with:\n' +
|
|
273
|
+
' git ls-files -s -- <path>\n' +
|
|
274
|
+
'A 120000 entry whose target cannot be read is either a damaged working tree\n' +
|
|
275
|
+
'(re-checkout the path) or a target this scanner must not guess at.';
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The target of an entry git records as a symlink (mode 120000), as
|
|
279
|
+
* `{target}` | `{exempt}` | `{refused}` — never a throw.
|
|
280
|
+
*
|
|
281
|
+
* Three outcomes, and the reason each exists:
|
|
282
|
+
*
|
|
283
|
+
* - **ENOENT** — the working tree is dirty and the entry is simply absent.
|
|
284
|
+
* An exemption, reported like every other one.
|
|
285
|
+
* - **EINVAL** — readlink was handed something that is not a link. This is
|
|
286
|
+
* NOT exotic: `core.symlinks=false` is git's DEFAULT on Windows, and under
|
|
287
|
+
* it a clone materializes mode 120000 as an ordinary file whose CONTENTS
|
|
288
|
+
* are the target path, while `ls-files -s` still says 120000. Git has
|
|
289
|
+
* already told us this is a link, so the target is read from those bytes.
|
|
290
|
+
* Failing here would be a regression: the previous scanner read that file
|
|
291
|
+
* with readFileSync and caught the payload inside it.
|
|
292
|
+
* - **anything else** — a refusal with an actionable message, not a bare
|
|
293
|
+
* errno escaping as an exception. A gate that dies with a stack trace has
|
|
294
|
+
* not made a finding; it has broken, and exit 2 tells the reader nothing
|
|
295
|
+
* about what to do next.
|
|
296
|
+
*
|
|
297
|
+
* The target is read as BYTES in both branches. `readlinkSync` with no
|
|
298
|
+
* encoding replaces undecodable bytes with U+FFFD, which would quietly sand
|
|
299
|
+
* the poison off a target — while the same bytes inside a file's CONTENTS are
|
|
300
|
+
* refused outright. One criterion cannot hold on one side and not the other,
|
|
301
|
+
* or the weaker side is the real rule.
|
|
302
|
+
*/
|
|
303
|
+
function readLinkTarget(abs) {
|
|
304
|
+
let bytes;
|
|
305
|
+
try {
|
|
306
|
+
bytes = readlinkSync(abs, 'buffer');
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (err.code === 'ENOENT') return { exempt: 'tracked but missing from the working tree' };
|
|
309
|
+
if (err.code !== 'EINVAL') {
|
|
310
|
+
return { refused: `git records this as a symlink (mode 120000) but its target could not be read (${err.code})` };
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
bytes = readFileSync(abs);
|
|
314
|
+
} catch (readErr) {
|
|
315
|
+
return {
|
|
316
|
+
refused:
|
|
317
|
+
'git records this as a symlink (mode 120000), it is not one on disk, ' +
|
|
318
|
+
`and its contents could not be read either (${readErr.code})`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!isValidUtf8(bytes)) {
|
|
323
|
+
return { refused: 'the symlink target is not valid UTF-8, so it cannot be scanned without mangling it' };
|
|
324
|
+
}
|
|
325
|
+
return { target: bytes.toString('utf8') };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function partitionTrackedFiles(cwd = process.cwd()) {
|
|
329
|
+
const empty = { paths: [], scan: [], links: [], exempt: [], refused: [] };
|
|
330
|
+
// `-s` for the mode, because a symlink must never be read with readFileSync:
|
|
331
|
+
// that FOLLOWS the link, so a link pointing outside the repo would have some
|
|
332
|
+
// other project's contents scanned in its place, and a dangling one would be
|
|
333
|
+
// filed as "missing" and skipped — target unread either way. Git's own
|
|
334
|
+
// record (mode 120000) is the authority here, not a filesystem stat.
|
|
335
|
+
const listing = git(['ls-files', '-s', '-z'], cwd);
|
|
336
|
+
const modes = new Map();
|
|
337
|
+
for (const entry of listing.split('\0')) {
|
|
338
|
+
if (entry === '') continue;
|
|
339
|
+
const tab = entry.indexOf('\t');
|
|
340
|
+
if (tab === -1) continue;
|
|
341
|
+
modes.set(entry.slice(tab + 1), entry.slice(0, entry.indexOf(' ')));
|
|
342
|
+
}
|
|
343
|
+
const candidates = [...modes.keys()];
|
|
344
|
+
if (candidates.length === 0) return empty;
|
|
345
|
+
|
|
346
|
+
const attr = execFileSync('git', ['check-attr', '-z', '--stdin', 'binary'], {
|
|
347
|
+
cwd,
|
|
348
|
+
encoding: 'utf8',
|
|
349
|
+
input: candidates.join('\0') + '\0',
|
|
350
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
351
|
+
});
|
|
352
|
+
// NUL-separated triples: path, attribute name, value.
|
|
353
|
+
const declaredBinary = new Set();
|
|
354
|
+
const fields = attr.split('\0');
|
|
355
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
356
|
+
if (fields[i + 2] === 'set') declaredBinary.add(fields[i]);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const out = { paths: candidates, scan: [], links: [], exempt: [], refused: [] };
|
|
360
|
+
for (const path of candidates) {
|
|
361
|
+
if (modes.get(path) === '120000') {
|
|
362
|
+
const outcome = readLinkTarget(resolve(cwd, path));
|
|
363
|
+
if (outcome.target !== undefined) out.links.push({ file: path, target: outcome.target });
|
|
364
|
+
else if (outcome.exempt !== undefined) out.exempt.push({ file: path, reason: outcome.exempt });
|
|
365
|
+
else out.refused.push({ file: path, reason: outcome.refused, remedy: LINK_REMEDY });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (declaredBinary.has(path)) {
|
|
369
|
+
out.exempt.push({ file: path, reason: 'declared `binary` in .gitattributes' });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
let bytes;
|
|
373
|
+
try {
|
|
374
|
+
bytes = readFileSync(resolve(cwd, path));
|
|
375
|
+
} catch (err) {
|
|
376
|
+
// Tracked but absent is a dirty working tree, not an attack. Still
|
|
377
|
+
// reported, because "the gate looked at fewer files than you think" is
|
|
378
|
+
// exactly the class of fact this scanner must never keep to itself.
|
|
379
|
+
if (err.code === 'ENOENT') {
|
|
380
|
+
out.exempt.push({ file: path, reason: 'tracked but missing from the working tree' });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
if (!isValidUtf8(bytes)) {
|
|
386
|
+
out.refused.push({
|
|
387
|
+
file: path,
|
|
388
|
+
reason: 'not valid UTF-8 and not declared `binary` in .gitattributes',
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
out.scan.push(path);
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Scan every tracked file that is neither exempt nor refused.
|
|
399
|
+
* Returns findings plus the full accounting of what was NOT scanned.
|
|
400
|
+
*/
|
|
401
|
+
export function scanRepo(cwd = process.cwd()) {
|
|
402
|
+
const { paths, scan, links, exempt, refused } = partitionTrackedFiles(cwd);
|
|
403
|
+
const results = [];
|
|
404
|
+
// NAMES first, and for EVERY tracked path — including exempt and refused
|
|
405
|
+
// ones. `binary` exempts a file's bytes; nothing exempts its name, which
|
|
406
|
+
// still reaches `git log --stat`, a PR diff header and the projections an
|
|
407
|
+
// agent reads. An override in a name mirrors the rest of the line exactly
|
|
408
|
+
// as one inside a table row does.
|
|
409
|
+
for (const file of paths) {
|
|
410
|
+
const findings = scanPath(file);
|
|
411
|
+
if (findings.length > 0) results.push({ file, findings, where: 'name' });
|
|
412
|
+
}
|
|
413
|
+
for (const { file, target } of links) {
|
|
414
|
+
const findings = scanPath(target);
|
|
415
|
+
if (findings.length > 0) results.push({ file, findings, where: 'target' });
|
|
416
|
+
}
|
|
417
|
+
for (const file of scan) {
|
|
418
|
+
const findings = scanText(readFileSync(resolve(cwd, file), 'utf8'));
|
|
419
|
+
if (findings.length > 0) results.push({ file, findings, where: 'content' });
|
|
420
|
+
}
|
|
421
|
+
return { scanned: scan.length + links.length, exempt, refused, results };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ------------------------------------------------------------------- CLI
|
|
425
|
+
|
|
426
|
+
/** Findings printed per file before we stop repeating ourselves. */
|
|
427
|
+
const MAX_PER_FILE = 20;
|
|
428
|
+
|
|
429
|
+
function main() {
|
|
430
|
+
const args = process.argv.slice(2);
|
|
431
|
+
if (args.length > 1 || args.some((a) => a.startsWith('-'))) {
|
|
432
|
+
console.error('usage: scan-control-chars.mjs [repo-root]');
|
|
433
|
+
process.exit(2);
|
|
434
|
+
}
|
|
435
|
+
const cwd = resolve(args[0] ?? process.cwd());
|
|
436
|
+
let report;
|
|
437
|
+
try {
|
|
438
|
+
report = scanRepo(cwd);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error(`scan-control-chars: ${err.message}`);
|
|
441
|
+
process.exit(2);
|
|
442
|
+
}
|
|
443
|
+
// Announced BEFORE any verdict, on every run, clean or not — the same rule
|
|
444
|
+
// the file exemptions below follow, for the same reason. A gap that lives
|
|
445
|
+
// only in a source comment is invisible exactly where it is load-bearing:
|
|
446
|
+
// fifty variation selectors carry twenty-five bytes of ASCII past all three
|
|
447
|
+
// layers of this repo's defences, and without this line the gate would print
|
|
448
|
+
// "clean" over them without a word. Derived from the export, never typed
|
|
449
|
+
// twice, so emptying the export cannot leave the promise standing.
|
|
450
|
+
for (const gap of DELIBERATELY_ALLOWED) {
|
|
451
|
+
console.log(
|
|
452
|
+
`scan-control-chars: deliberate gap: ${formatCodePoint(gap.lo)}..${formatCodePoint(gap.hi)} — ${gap.why}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Announced BEFORE any verdict, on every run, clean or not. An exemption
|
|
457
|
+
// that only shows up when something else already failed is not visible —
|
|
458
|
+
// and the whole point is that leaving the scan can never be quiet.
|
|
459
|
+
for (const { file, reason } of report.exempt) {
|
|
460
|
+
console.log(`scan-control-chars: not scanned: ${file} — ${reason}`);
|
|
461
|
+
}
|
|
462
|
+
if (report.exempt.length > 0) {
|
|
463
|
+
console.log(
|
|
464
|
+
`scan-control-chars: ${report.exempt.length} file(s) exempt, ${report.scanned} scanned.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Refusals are diagnosed BEFORE the empty-scan check, so the specific,
|
|
469
|
+
// actionable message wins over the generic one when a repo's only file is
|
|
470
|
+
// the undecodable one.
|
|
471
|
+
if (report.refused.length > 0) {
|
|
472
|
+
for (const { file, reason } of report.refused) {
|
|
473
|
+
console.error(`scan-control-chars: ${file}: ${reason}`);
|
|
474
|
+
}
|
|
475
|
+
// Two classes of refusal, two remedies. Printing the `binary` advice for a
|
|
476
|
+
// broken symlink would send the reader to declare a file that is not the
|
|
477
|
+
// problem — an unactionable message is how a gate becomes an obstacle.
|
|
478
|
+
const undecodable = report.refused.filter((r) => r.remedy === undefined);
|
|
479
|
+
if (undecodable.length > 0) {
|
|
480
|
+
console.error(
|
|
481
|
+
`\nscan-control-chars: ${undecodable.length} tracked file(s) could not be read as\n` +
|
|
482
|
+
'text and are not declared binary. This is refused rather than skipped: one\n' +
|
|
483
|
+
'stray byte is enough to make a poisoned text file undecodable, which would\n' +
|
|
484
|
+
'otherwise carry it out of the scan under a green tick.\n' +
|
|
485
|
+
'If the file really is binary, declare it in .gitattributes:\n' +
|
|
486
|
+
` ${undecodable[0].file} binary`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
for (const remedy of new Set(report.refused.map((r) => r.remedy).filter((r) => r !== undefined))) {
|
|
490
|
+
console.error(`\n${remedy}`);
|
|
491
|
+
}
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Nothing scanned at all is never success. `* binary` in .gitattributes
|
|
496
|
+
// silences this gate completely and would otherwise exit 0 over an empty
|
|
497
|
+
// scan — a green tick meaning "we looked at nothing".
|
|
498
|
+
if (report.scanned === 0) {
|
|
499
|
+
console.error(
|
|
500
|
+
'scan-control-chars: refusing to pass — 0 files were scanned.\n' +
|
|
501
|
+
` ${report.exempt.length} exempt, ${report.refused.length} refused. A gate that\n` +
|
|
502
|
+
' looked at nothing must not report success. Check .gitattributes for a\n' +
|
|
503
|
+
' rule that marks everything `binary`.',
|
|
504
|
+
);
|
|
505
|
+
process.exit(2);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (report.results.length === 0) {
|
|
509
|
+
console.log(`scan-control-chars: clean (${report.scanned} tracked text files)`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
let total = 0;
|
|
513
|
+
for (const { file, findings, where } of report.results) {
|
|
514
|
+
total += findings.length;
|
|
515
|
+
for (const f of findings.slice(0, MAX_PER_FILE)) {
|
|
516
|
+
console.error(formatFinding(file, f, where));
|
|
517
|
+
}
|
|
518
|
+
if (findings.length > MAX_PER_FILE) {
|
|
519
|
+
console.error(`${file}: ... and ${findings.length - MAX_PER_FILE} more in this file`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
console.error(
|
|
523
|
+
`\nscan-control-chars: ${total} forbidden character(s) in ${report.results.length} of ` +
|
|
524
|
+
`${report.scanned} tracked text files.\n` +
|
|
525
|
+
'These are RAW control/bidi characters, not escape sequences. A tool almost\n' +
|
|
526
|
+
'certainly turned the text of an escape into the character itself. Write the\n' +
|
|
527
|
+
'escape notation instead, or build the character with String.fromCodePoint().',
|
|
528
|
+
);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** See journal.mjs — both sides canonicalized, or a symlinked path no-ops silently. */
|
|
533
|
+
function canonicalPath(path) {
|
|
534
|
+
const abs = resolve(path);
|
|
535
|
+
try {
|
|
536
|
+
return realpathSync(abs);
|
|
537
|
+
} catch {
|
|
538
|
+
return abs;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function isMainModule(moduleUrl) {
|
|
543
|
+
if (!process.argv[1]) return false;
|
|
544
|
+
return canonicalPath(process.argv[1]) === canonicalPath(fileURLToPath(moduleUrl));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (isMainModule(import.meta.url)) main();
|