@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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invisible — the ONE answer to "is this codepoint invisible?".
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists (ADR-19 correction 1 point 4, ADR-21): the rule had
|
|
5
|
+
* THREE spellings, and they were measurably inconsistent. Measured over all
|
|
6
|
+
* 1 112 064 codepoints at ca06c67, the three layers disagreed on 456 of them
|
|
7
|
+
* in 37 contiguous ranges — and the layering was INVERTED. The strictest
|
|
8
|
+
* spelling guarded our own repository in CI; the weakest guarded `STATE.md`,
|
|
9
|
+
* the document an agent reads to decide what to do next, whose content
|
|
10
|
+
* travels from subagent reports about FOREIGN repositories. The whole TAG
|
|
11
|
+
* block passed the layer closest to the victim while the layer closest to us
|
|
12
|
+
* blocked it.
|
|
13
|
+
*
|
|
14
|
+
* So the point of this module is not code reuse. It is that there must be one
|
|
15
|
+
* ANSWER, not one function: every consumer asks `invisibleProblem` and none of
|
|
16
|
+
* them passes options, because an option here would be a second semantics
|
|
17
|
+
* wearing the word "additive" (ADR-21).
|
|
18
|
+
*
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* SHAPE: why a Unicode PROPERTY, and not a longer hand-written list
|
|
21
|
+
* ---------------------------------------------------------------------------
|
|
22
|
+
* ADR-19 correction 1 concluded that any enumeration will be incomplete
|
|
23
|
+
* because Unicode grows and we do not, and it named an ALLOWLIST as the
|
|
24
|
+
* candidate direction. That hypothesis was measured before this module was
|
|
25
|
+
* written, and the measurement REFUTED it for this rule:
|
|
26
|
+
*
|
|
27
|
+
* a letters/numbers/punctuation/symbols/marks/spaces allowlist — the widest
|
|
28
|
+
* repertoire a Markdown table cell could plausibly need — still admits 267
|
|
29
|
+
* DEFAULT-IGNORABLE codepoints, among them the whole of U+E0100..U+E01EF,
|
|
30
|
+
* the same smuggling channel the denylist already had to name by hand.
|
|
31
|
+
*
|
|
32
|
+
* An allowlist would therefore still need a subtractive carve-out for exactly
|
|
33
|
+
* the characters that motivated the work: it moves the problem, it does not
|
|
34
|
+
* change its shape. What DOES change the shape is asking Unicode itself.
|
|
35
|
+
* `\p{Default_Ignorable_Code_Point}` is the property whose definition is
|
|
36
|
+
* literally "renders as nothing", it ships inside V8, it costs zero
|
|
37
|
+
* dependencies, and it is maintained by the people who add the characters.
|
|
38
|
+
* Measured: it is a strict SUPERSET of the hand-written list — 0 codepoints
|
|
39
|
+
* of the old list fall outside the property rule — and it closes every gap
|
|
40
|
+
* the previous measurements had found the hard way (Arabic `Cf` U+0600..0605,
|
|
41
|
+
* Egyptian controls U+13430..1343F, U+034F, Mongolian free variation
|
|
42
|
+
* selectors, and 3 844 codepoints in total).
|
|
43
|
+
*
|
|
44
|
+
* The enumerated list stays, and is consulted FIRST, for one reason: a range
|
|
45
|
+
* in it carries a sentence a human can act on. "TAG character (invisible
|
|
46
|
+
* ASCII)" tells a reader what happened; "default-ignorable" does not. The
|
|
47
|
+
* list is the vocabulary, the property is the boundary.
|
|
48
|
+
*
|
|
49
|
+
* Cost accepted deliberately: the property set follows the Unicode version
|
|
50
|
+
* bundled with Node, so a Node upgrade may widen it. That is the intended
|
|
51
|
+
* direction of drift — wider, never narrower — and the pinning test asserts
|
|
52
|
+
* shape and specific members rather than an exact cardinality, so an upgrade
|
|
53
|
+
* cannot silently NARROW the rule without turning a test red.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Named forbidden ranges, as numbers rather than string escapes — on purpose.
|
|
58
|
+
* A regex literal written with escape notation is one careless "helpful"
|
|
59
|
+
* rewrite away from becoming the very character it bans, and the file would
|
|
60
|
+
* then fail its own scan. Numbers cannot be mangled that way, and they double
|
|
61
|
+
* as the reporting vocabulary.
|
|
62
|
+
*/
|
|
63
|
+
export const FORBIDDEN = Object.freeze([
|
|
64
|
+
// C0 controls, except TAB (0x09) and LF (0x0A) which are legal text.
|
|
65
|
+
// CR (0x0D) is deliberately IN: this repo normalizes to LF (.gitattributes).
|
|
66
|
+
Object.freeze({ lo: 0x00, hi: 0x08, what: 'C0 control character' }),
|
|
67
|
+
Object.freeze({ lo: 0x0b, hi: 0x1f, what: 'C0 control character' }),
|
|
68
|
+
Object.freeze({ lo: 0x7f, hi: 0x9f, what: 'DEL / C1 control character' }),
|
|
69
|
+
Object.freeze({ lo: 0x00ad, hi: 0x00ad, what: 'invisible formatting character (SOFT HYPHEN)' }),
|
|
70
|
+
Object.freeze({ lo: 0x061c, hi: 0x061c, what: 'bidi mark (ARABIC LETTER MARK)' }),
|
|
71
|
+
Object.freeze({ lo: 0x115f, hi: 0x1160, what: 'invisible filler that renders as nothing' }),
|
|
72
|
+
Object.freeze({ lo: 0x180e, hi: 0x180e, what: 'invisible separator (MONGOLIAN VOWEL SEPARATOR)' }),
|
|
73
|
+
Object.freeze({ lo: 0x200b, hi: 0x200f, what: 'zero-width or directional mark' }),
|
|
74
|
+
Object.freeze({ lo: 0x202a, hi: 0x202e, what: 'bidi embedding or override' }),
|
|
75
|
+
Object.freeze({ lo: 0x2060, hi: 0x2064, what: 'word joiner or invisible operator' }),
|
|
76
|
+
Object.freeze({ lo: 0x2066, hi: 0x2069, what: 'bidi isolate' }),
|
|
77
|
+
Object.freeze({ lo: 0x206a, hi: 0x206f, what: 'deprecated formatting character' }),
|
|
78
|
+
Object.freeze({ lo: 0x3164, hi: 0x3164, what: 'invisible filler that renders as nothing' }),
|
|
79
|
+
Object.freeze({ lo: 0xffa0, hi: 0xffa0, what: 'invisible filler that renders as nothing' }),
|
|
80
|
+
Object.freeze({ lo: 0xfeff, hi: 0xfeff, what: 'byte order mark / zero-width no-break space' }),
|
|
81
|
+
Object.freeze({ lo: 0xfff9, hi: 0xfffb, what: 'interlinear annotation character' }),
|
|
82
|
+
Object.freeze({ lo: 0x1d173, hi: 0x1d17a, what: 'invisible musical formatting character' }),
|
|
83
|
+
// The one that matters most, and the one the old test could not even see:
|
|
84
|
+
// U+E0001..U+E007E map ONE-TO-ONE onto ASCII and render as nothing at all.
|
|
85
|
+
// Projections (STATE.md, PROGRESS.md) are read by AGENTS, and their content
|
|
86
|
+
// travels from subagent reports about foreign repositories — so invisible
|
|
87
|
+
// text in a projection is prompt injection aimed at our own team, not an
|
|
88
|
+
// aesthetic complaint. The block is astral, which is why the pinning test
|
|
89
|
+
// stopping at U+FFFF hid it (ADR-19 correction 1).
|
|
90
|
+
Object.freeze({ lo: 0xe0000, hi: 0xe007f, what: 'TAG character (invisible ASCII)' }),
|
|
91
|
+
// Variation Selectors Supplement. Same smuggling channel as the TAG block —
|
|
92
|
+
// a sequence of them encodes arbitrary bytes onto a visible carrier — and
|
|
93
|
+
// zero occurrences in this repo, so banning them costs nothing here. Their
|
|
94
|
+
// BMP counterparts are deliberately NOT banned; see below.
|
|
95
|
+
Object.freeze({ lo: 0xe0100, hi: 0xe01ef, what: 'variation selector (supplement)' }),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* DELIBERATE GAP: U+FE00..U+FE0F (variation selectors 1-16) are NOT banned.
|
|
100
|
+
*
|
|
101
|
+
* U+FE0F is the emoji presentation selector and occurs 24 times in this repo's
|
|
102
|
+
* README today; U+FE0E is its text-presentation twin. Banning the range would
|
|
103
|
+
* turn CI red on a file nobody touched, and ADR-19 is explicit that a gate
|
|
104
|
+
* which cries wolf gets switched off and never restored — which costs more
|
|
105
|
+
* than the gap.
|
|
106
|
+
*
|
|
107
|
+
* The gap is real and stated rather than hidden: 16 codepoints still carry
|
|
108
|
+
* four bits each, so a determined smuggler can encode data with them. It is
|
|
109
|
+
* announced on every run of the scanner, clean runs included, and it is the
|
|
110
|
+
* ONE place where this module says "allowed" to something Unicode calls
|
|
111
|
+
* default-ignorable — which is why it is a list, checked first, and not a
|
|
112
|
+
* condition buried inside the predicate.
|
|
113
|
+
*/
|
|
114
|
+
export const DELIBERATELY_ALLOWED = Object.freeze([
|
|
115
|
+
Object.freeze({ lo: 0xfe00, hi: 0xfe0f, why: 'variation selectors: U+FE0F is legal emoji presentation' }),
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* TAB and LF are legal text and must never be answered as "invisible". They
|
|
120
|
+
* are visible whitespace with a layout meaning, and files are full of them.
|
|
121
|
+
* Consumers for which they are nonetheless illegal — a file NAME, an agent
|
|
122
|
+
* name — ask `whitespaceProblem`, a DISJOINT rule; see below.
|
|
123
|
+
*/
|
|
124
|
+
const LEGAL_TEXT_CONTROLS = Object.freeze([0x09, 0x0a]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The boundary of the rule, as ONE character class — the union of the four
|
|
128
|
+
* properties, which is exactly what four separate tests computed.
|
|
129
|
+
*
|
|
130
|
+
* It began as four regexes and was collapsed after measuring: a codepoint that
|
|
131
|
+
* is ORDINARY has to fail every test before the answer is "no", and "no" is the
|
|
132
|
+
* answer for 99.6% of Unicode and for essentially all real text. Four misses
|
|
133
|
+
* per character made `inline()` 15x slower than the old hand-written class on
|
|
134
|
+
* astral input (5 000 emoji: 1.19 ms vs 0.08 ms). One class is one miss.
|
|
135
|
+
*
|
|
136
|
+
* The `^...$` anchors are defensive habit, not a load-bearing guarantee: the
|
|
137
|
+
* only caller builds the subject with `String.fromCodePoint`, so it is always
|
|
138
|
+
* exactly one character and the anchors cannot change the verdict. Removing
|
|
139
|
+
* them is an EQUIVALENT mutant here, and the sentence says so rather than
|
|
140
|
+
* claiming a protection no test in this repo can demonstrate. They earn their
|
|
141
|
+
* keep only if someone later passes a longer string — which the type of the
|
|
142
|
+
* parameter already forbids.
|
|
143
|
+
*/
|
|
144
|
+
const IS_INVISIBLE =
|
|
145
|
+
/^[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}\p{Noncharacter_Code_Point}]$/u;
|
|
146
|
+
|
|
147
|
+
const inRange = (cp, ranges) => {
|
|
148
|
+
for (const r of ranges) if (cp >= r.lo && cp <= r.hi) return r;
|
|
149
|
+
return null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A 64 KiB lookup for the BMP, filled lazily: 0 unknown, 1 allowed, 2 banned.
|
|
154
|
+
* `inline()` runs on every cell of every projection, so the property test must
|
|
155
|
+
* not be paid per character per render. Above the BMP the properties are
|
|
156
|
+
* evaluated directly — those codepoints are rare in real text and the
|
|
157
|
+
* exhaustive test that walks all of them is not a hot path.
|
|
158
|
+
*/
|
|
159
|
+
const bmp = new Uint8Array(0x10000);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same idea above the BMP, where a flat array would cost 1 MiB and a
|
|
163
|
+
* regex test costs ~13x what the old hand-written character class did.
|
|
164
|
+
* Measured on 5 000 astral codepoints: 0.08 ms before this module, 1.08 ms
|
|
165
|
+
* with the property test, 0.09 ms with this cache.
|
|
166
|
+
*
|
|
167
|
+
* Bounded, and cleared rather than evicted: an exhaustive sweep over Unicode
|
|
168
|
+
* (this repo has three of them, in tests) would otherwise grow it to a million
|
|
169
|
+
* entries. Clearing is correct because the map is a pure memo — losing it
|
|
170
|
+
* costs time, never accuracy — and a cheap clear beats an LRU whose only
|
|
171
|
+
* purpose is to be more elegant about the same guarantee.
|
|
172
|
+
*/
|
|
173
|
+
const astral = new Map();
|
|
174
|
+
const ASTRAL_CACHE_LIMIT = 1 << 16;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How many entries the astral memo currently holds, and its ceiling.
|
|
178
|
+
*
|
|
179
|
+
* Exported ONLY so the boundedness guarantee can be tested. Without it the
|
|
180
|
+
* test could assert that answers survive a recycle but not that a recycle ever
|
|
181
|
+
* happens — and a test whose NAME promises boundedness while its body checks
|
|
182
|
+
* only correctness is the "documented guarantee with no guard" shape this
|
|
183
|
+
* story exists to remove. Removing the `clear()` used to leave the whole suite
|
|
184
|
+
* green; now it does not.
|
|
185
|
+
*/
|
|
186
|
+
export function astralMemoStats() {
|
|
187
|
+
return { size: astral.size, limit: ASTRAL_CACHE_LIMIT };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The label a codepoint gets when only the property rule caught it. */
|
|
191
|
+
const PROPERTY_LABEL = 'invisible or non-printing character (Unicode default-ignorable / control / format)';
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The problem with this codepoint, or null when it is ordinary text.
|
|
195
|
+
*
|
|
196
|
+
* This is the single answer. It takes no options on purpose: an option would
|
|
197
|
+
* let two callers ask the same question and be told different things, which is
|
|
198
|
+
* the defect this module was created to remove (ADR-21 — one ANSWER, not one
|
|
199
|
+
* function).
|
|
200
|
+
*/
|
|
201
|
+
export function invisibleProblem(cp) {
|
|
202
|
+
// Not `return null`. This is a security predicate, and "null" is its word for
|
|
203
|
+
// CLEAN — so answering it for a value that is not a codepoint at all is
|
|
204
|
+
// fail-open, the shape ADR-19 correction 1 catalogues as the way a gate gets
|
|
205
|
+
// walked past. Today every caller passes `ch.codePointAt(0)` and the branch
|
|
206
|
+
// is unreachable, which is exactly why it must not sit here quietly deciding
|
|
207
|
+
// that garbage is fine: the next caller is the one that makes it reachable.
|
|
208
|
+
if (!Number.isInteger(cp) || cp < 0 || cp > 0x10ffff) {
|
|
209
|
+
throw new TypeError(`invisibleProblem expects a Unicode code point, got ${JSON.stringify(cp)}`);
|
|
210
|
+
}
|
|
211
|
+
if (cp < 0x10000) {
|
|
212
|
+
const cached = bmp[cp];
|
|
213
|
+
if (cached !== 0) return cached === 1 ? null : labelFor(cp);
|
|
214
|
+
const problem = compute(cp);
|
|
215
|
+
bmp[cp] = problem === null ? 1 : 2;
|
|
216
|
+
return problem;
|
|
217
|
+
}
|
|
218
|
+
const memo = astral.get(cp);
|
|
219
|
+
if (memo !== undefined) return memo;
|
|
220
|
+
const problem = compute(cp);
|
|
221
|
+
if (astral.size >= ASTRAL_CACHE_LIMIT) astral.clear();
|
|
222
|
+
astral.set(cp, problem);
|
|
223
|
+
return problem;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Named ranges win, so the reader gets a sentence instead of a category. */
|
|
227
|
+
function labelFor(cp) {
|
|
228
|
+
const named = inRange(cp, FORBIDDEN);
|
|
229
|
+
return named ? named.what : PROPERTY_LABEL;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function compute(cp) {
|
|
233
|
+
if (LEGAL_TEXT_CONTROLS.includes(cp)) return null;
|
|
234
|
+
if (inRange(cp, DELIBERATELY_ALLOWED)) return null;
|
|
235
|
+
const named = inRange(cp, FORBIDDEN);
|
|
236
|
+
if (named) return named.what;
|
|
237
|
+
return IS_INVISIBLE.test(String.fromCodePoint(cp)) ? PROPERTY_LABEL : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The SEPARATE rule for identifiers — a file name, a symlink target, an agent
|
|
242
|
+
* name. TAB and LF are perfectly visible characters, so they are not an answer
|
|
243
|
+
* to "is this invisible"; they are a catastrophe in a NAME, where a tab makes
|
|
244
|
+
* one path print as two columns in every tool that lists it and a newline
|
|
245
|
+
* breaks the line-oriented output of all of them.
|
|
246
|
+
*
|
|
247
|
+
* Kept as its own function, with its own name, precisely so that it cannot be
|
|
248
|
+
* mistaken for a second configuration of the invisibility rule. The two rules
|
|
249
|
+
* are DISJOINT — `invisibleProblem` never answers for U+0009 or U+000A, and
|
|
250
|
+
* this one answers for nothing else — and a test asserts that disjointness
|
|
251
|
+
* over the whole of Unicode, so the boundary cannot rot into an option.
|
|
252
|
+
*/
|
|
253
|
+
export function whitespaceProblem(cp) {
|
|
254
|
+
return LEGAL_TEXT_CONTROLS.includes(cp) ? 'control character in a name or path' : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Either rule, for the consumers that hold identifiers. */
|
|
258
|
+
export function identifierProblem(cp) {
|
|
259
|
+
return invisibleProblem(cp) ?? whitespaceProblem(cp);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `U+00A0` style, always at least four hex digits. */
|
|
263
|
+
export function formatCodePoint(cp) {
|
|
264
|
+
return `U+${cp.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every invisible codepoint in `text` replaced by its VISIBLE escape notation.
|
|
269
|
+
*
|
|
270
|
+
* ONE VISIBILITY POLICY, and this is it: an invisible character is shown, never
|
|
271
|
+
* silently dropped.
|
|
272
|
+
*
|
|
273
|
+
* The repo had two policies and they contradicted each other. `inline()`
|
|
274
|
+
* deleted invisible characters, so a value made entirely of TAG characters
|
|
275
|
+
* rendered identically to an empty one — `inline("deploy ok" + TAG("IGNORE ALL
|
|
276
|
+
* PRIOR INSTRUCTIONS"))` returned exactly `"deploy ok"`, with nothing anywhere
|
|
277
|
+
* saying that 29 characters had been removed. Meanwhile the hook runtime
|
|
278
|
+
* escaped them and its own comment called silent removal "the same class of
|
|
279
|
+
* defect as a silent exemption in the scanner". Two layers, two answers to
|
|
280
|
+
* "does the reader get told" — the same shape as the three spellings of
|
|
281
|
+
* membership this module exists to have removed, one level up.
|
|
282
|
+
*
|
|
283
|
+
* Escaping wins on the repo's own stated principle: ADR-19 correction 1 says an
|
|
284
|
+
* exclusion must never be silent, that a skipped item is to be counted and
|
|
285
|
+
* named even on a clean run. It also matches how `inline()` already signals its
|
|
286
|
+
* OTHER losses — truncation prints an ellipsis — so silent character removal
|
|
287
|
+
* was the single lossy step that left no trace.
|
|
288
|
+
*
|
|
289
|
+
* The cost is accepted and stated: a hostile value becomes long and ugly, and
|
|
290
|
+
* legitimate formatting characters of Arabic, Syriac, Kaithi and Egyptian
|
|
291
|
+
* become visible noise in text that uses them (see docs/projections.md). Ugly
|
|
292
|
+
* and honest beats tidy and misleading in a document an agent reads to decide
|
|
293
|
+
* what to do next.
|
|
294
|
+
*
|
|
295
|
+
* Iterates codepoints, not UTF-16 units: a TAG character is a surrogate PAIR,
|
|
296
|
+
* and a unit-wise walk sees 0xDB40/0xDC01 — neither of which is invisible on
|
|
297
|
+
* its own — and lets the character through while reporting success.
|
|
298
|
+
*/
|
|
299
|
+
export function escapeInvisible(text) {
|
|
300
|
+
let out = '';
|
|
301
|
+
let changed = false;
|
|
302
|
+
for (const ch of text) {
|
|
303
|
+
const cp = ch.codePointAt(0);
|
|
304
|
+
if (invisibleProblem(cp) === null) out += ch;
|
|
305
|
+
else {
|
|
306
|
+
out += `<${formatCodePoint(cp)}>`;
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return changed ? out : text;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The same rule applied to a JSON document, as JSON's own `\uXXXX` escapes.
|
|
315
|
+
*
|
|
316
|
+
* `JSON.stringify` escapes C0 controls and nothing else: bidi overrides, TAG
|
|
317
|
+
* characters and zero-width marks come out RAW. Every `journal.mjs` subcommand
|
|
318
|
+
* prints stringified events to a terminal, so `query`, `tail`, `validate`,
|
|
319
|
+
* `open-spawns`, `append` and `close-spawn` were all handing an attacker the
|
|
320
|
+
* operator's screen.
|
|
321
|
+
*
|
|
322
|
+
* `<U+202E>` would have been wrong here: that output is machine-readable, and
|
|
323
|
+
* this repo's own tooling parses it back. JSON escapes are SAFE ON A TERMINAL
|
|
324
|
+
* AND LOSSLESS — `JSON.parse` of the result is deep-equal to the original, and
|
|
325
|
+
* a test asserts exactly that. Fidelity and safety were not in tension; they
|
|
326
|
+
* only looked like it while the escape notation was the wrong one.
|
|
327
|
+
*/
|
|
328
|
+
export function jsonEscapeInvisible(json) {
|
|
329
|
+
let out = '';
|
|
330
|
+
for (const ch of json) {
|
|
331
|
+
const cp = ch.codePointAt(0);
|
|
332
|
+
if (invisibleProblem(cp) === null) {
|
|
333
|
+
out += ch;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// Astral codepoints need their surrogate PAIR spelled out: JSON has no
|
|
337
|
+
// \u{...} form, so each UTF-16 unit is escaped separately.
|
|
338
|
+
for (let i = 0; i < ch.length; i++) {
|
|
339
|
+
out += BS_LITERAL + 'u' + ch.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0');
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A single backslash, built from its code point so no tool can rewrite it. */
|
|
346
|
+
const BS_LITERAL = String.fromCharCode(92);
|