promptdock 1.2.0 → 1.2.1

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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Terminal-output hygiene - the ONE place this package decides what an untrusted
3
+ * string may write to a user's terminal.
4
+ *
5
+ * WARNING: THIS FILE EXISTS BECAUSE A DOC COMMENT IS NOT A CONTRACT. Its predecessor
6
+ * was a private `sanitizeForTerminal` inside api.ts whose comment stated a GENERAL
7
+ * rule ("strip control chars before a server-supplied string reaches the terminal")
8
+ * while the code was applied at exactly ONE call site - the redirect Location. Five
9
+ * sibling sites in the same function shipped raw for the life of the file, and the
10
+ * comment is what stopped anyone looking. So: the rule now lives in its own module,
11
+ * is exported, is unit-tested against a character table, and every guarantee below is
12
+ * one a test pins. Do not restate a guarantee here that no test holds.
13
+ *
14
+ * THE THREAT. A hostile or MITM'd API response (a 401 needs no valid token to
15
+ * produce, and a captive portal can mint any status) can put terminal control
16
+ * sequences into a string this CLI prints. What that buys an attacker, worst first:
17
+ * 1. NEWLINE injection - needs no escape at all, so it survives any strip-ESC
18
+ * filter. One newline in a server message forges a whole line of CLI output: a
19
+ * green "installed" tick, a fake footer URL, a fake prompt.
20
+ * 2. CSI erase/cursor-move (ESC, or the 8-bit C1 introducer U+009B) - repaints or
21
+ * ERASES legitimate output already on screen, including an install summary the
22
+ * user just approved.
23
+ * 3. OSC 52 - writes the system clipboard, so the developer's next paste is
24
+ * attacker-chosen. The only class here with an effect outside the terminal.
25
+ * 4. OSC 8 hyperlinks - displayed text is not the link target, aimed at the DX3
26
+ * footer URL a stuck user is told to click.
27
+ * 5. SGR conceal/reverse - hides text and forges this CLI's own red/green
28
+ * semantics.
29
+ * 6. Bidi overrides and isolates (Trojan Source) and zero-width characters - NO
30
+ * control byte and NO escape at all, so they defeat a strip-ESC filter AND every
31
+ * terminal-agnostic argument about which emulator honours which sequence.
32
+ *
33
+ * WHY C1 MATTERS EVEN THOUGH JSON HIDES ESC. Measured, not assumed: `JSON.stringify`
34
+ * escapes every C0 byte (ESC becomes the six inert characters backslash-u-0-0-1-b),
35
+ * but passes DEL, the whole C1 range - including U+009B, the 8-bit CSI introducer, as
36
+ * bytes c2 9b - and every bidi/zero-width character straight through. So the `--json`
37
+ * document a developer reads in their terminal is a partial sink, not a safe one.
38
+ *
39
+ * PORTED FROM `lib/seo/meta-text.ts`, which solved the same problem for HTML meta
40
+ * attributes and paid for two lessons this file inherits. It cannot be imported:
41
+ * packages/cli ships to npm on its own and must not reach into the app (the same
42
+ * constraint that makes test/footer-codes.test.ts a text-scanning parity test).
43
+ *
44
+ * WHERE TO APPLY IT, AND WHY THE ANSWER DIFFERS BY CHANNEL.
45
+ *
46
+ * For an ERROR, the right place is the trust BOUNDARY — `Api.envelope()` sanitizes
47
+ * `error.message` once and every CliError built from it is safe, including the ones in
48
+ * files that never touch `mapEnvelopeError`.
49
+ *
50
+ * For everything else it is the DISPLAY SITE, and that is not inconsistency. A server
51
+ * string like a manifest `path` is PRINTED and then USED: `path` names the file the
52
+ * installer writes and the sha256 it verifies against. Sanitizing it at the boundary
53
+ * would silently change the byte the integrity check runs on — the fix would become a
54
+ * supply-chain bug. The same is true of a `slug` that builds a URL, a `version_id` sent
55
+ * back to the server, and a `device_code`. So the rule is:
56
+ *
57
+ * sanitize the FRAGMENT, at the moment it is interpolated for a human to read,
58
+ * and never the value the program goes on to use.
59
+ *
60
+ * WARNING: SANITIZE THE FRAGMENT, NOT THE COMPOSED LINE. `terminalLine` collapses
61
+ * whitespace runs, so running it over an already-built line destroys deliberate
62
+ * alignment — install.ts's summary block pads its labels into columns
63
+ * (" version: v3"), and one pass over the finished string would flatten every one of
64
+ * them to a single space.
65
+ *
66
+ * WARNING: A `CliError` FRAGMENT STILL NEEDS `terminalLine`. The constructor backstop
67
+ * strips control characters, but it deliberately preserves newlines and applies no cap
68
+ * — so a hostile skill title inside an error message can still forge a second output
69
+ * line or run to a megabyte. The backstop makes an error repaint-safe; only the
70
+ * fragment treatment makes it BOUNDED.
71
+ *
72
+ * WARNING: EVERY INVISIBLE OR CONTROL CHARACTER IN THIS FILE IS A \uXXXX ESCAPE, AND
73
+ * MUST STAY ONE. A literal one is invisible in a diff, is the exact Trojan-Source
74
+ * shape semgrep's `contains-bidirectional-characters` rule fires on (docs/memory.md
75
+ * records that firing as a TRUE positive on a guard that embedded a raw U+202E), and
76
+ * cannot be pasted into a shell safely. Name characters by code point in prose.
77
+ */
78
+ /**
79
+ * Trim to at most `max` CODE POINTS, marking the cut with an ellipsis.
80
+ *
81
+ * WARNING: Code points, not UTF-16 units. The predecessor iterated code points and
82
+ * then did `out.slice(0, 200)` on the result, so a string ending in an astral
83
+ * character was cut mid-pair: measured, it returned a lone high surrogate U+D83C, and
84
+ * `Buffer.from(out,"utf8").toString("utf8") !== out` - invalid UTF-8, emitted straight
85
+ * at the terminal. Emoji in a server error message is not hypothetical.
86
+ *
87
+ * The ellipsis is not decoration. A silently truncated remedy is a WRONG command the
88
+ * user pastes; a visibly truncated one is a command they can see is incomplete.
89
+ *
90
+ * THE CUT LANDS ON A GRAPHEME BOUNDARY ({@link graphemePrefix}). The budget is still
91
+ * counted in code points - the name and every caller's arithmetic stay true - but the
92
+ * cut backs off to the last whole grapheme that fits. WARNING: KEEPING THE JOINER
93
+ * CREATED A NEW WAY TO BREAK IT. Once {@link LONE_ZWJ} preserves a joiner inside an
94
+ * emoji, a plain code-point slice can cut that emoji in half and leave the joiner
95
+ * DANGLING in front of the ellipsis - the exact lone-joiner shape the rule exists to
96
+ * remove - or leave a couple where a family was. Grapheme segmentation handles every such
97
+ * case by construction (ZWJ sequences, skin tones, flag pairs, combining marks).
98
+ *
99
+ * The repaint invariant in ui.ts (`clampLine`: logical lines == physical rows) is
100
+ * untouched: a grapheme cut only ever lands EARLIER than the code-point cut would, so a
101
+ * clamped line can never get wider.
102
+ */
103
+ export declare function clampCodePoints(s: string, max: number): string;
104
+ /**
105
+ * Collapse untrusted text to ONE safe, bounded line. Apply this at the trust
106
+ * BOUNDARY - the moment a server string enters this package - never at the terminal,
107
+ * where it is already mixed with the CLI's own escapes.
108
+ *
109
+ * Guarantees, each pinned by test/terminal-text.test.ts:
110
+ * - no C0, DEL or C1 byte survives (so no escape introducer, 8-bit forms included)
111
+ * - EVERY Unicode Bidi_Control code point is removed (all 12, swept by property in
112
+ * the test — not by example), plus the named zero-width and format characters
113
+ * listed on INVISIBLE. Other Default_Ignorable code points (variation selectors,
114
+ * Hangul fillers, Mongolian FVS) are NOT stripped and are not claimed to be:
115
+ * U+FE0F is load-bearing in legitimate emoji, so removing that whole property
116
+ * would corrupt real text to close a channel that cannot reorder anything.
117
+ * - U+200D is removed EXCEPT where it joins two emoji elements ({@link LONE_ZWJ}):
118
+ * every lone joiner goes, and every RGI emoji ZWJ sequence survives intact - the
119
+ * test sweeps an engine-validated corpus of 863, not a handful of examples.
120
+ * - the result contains no newline: an untrusted fragment is ONE line and cannot
121
+ * forge a second one. Line structure is the CLI's to compose, never the server's.
122
+ * - the result is at most `max` code points, and the clamp never splits a GRAPHEME -
123
+ * so never a surrogate pair, never an emoji sequence, and never a joiner left
124
+ * dangling in front of the ellipsis. (It cannot make an already ill-formed INPUT
125
+ * well-formed — a lone
126
+ * surrogate in the server's JSON stays one, though Node's UTF-8 encoder writes it
127
+ * out as U+FFFD, so no invalid byte reaches the terminal.)
128
+ *
129
+ * Control characters become a SPACE, never nothing: "line one\nline two" must not
130
+ * collapse to "line oneline two". The predecessor deleted them and did exactly that -
131
+ * measured. The whitespace pass then folds the runs it creates.
132
+ */
133
+ export declare function terminalLine(raw: string | null | undefined, max: number): string;
134
+ /**
135
+ * Make already-composed text safe WITHOUT touching its line structure - the
136
+ * defence-in-depth backstop applied in the `CliError` constructor.
137
+ *
138
+ * Two deliberate differences from {@link terminalLine}, both load-bearing:
139
+ *
140
+ * 1. NEWLINE SURVIVES. Exactly two repo-authored CliError messages are multi-line and
141
+ * both are legitimate: api.ts's 426 joins message and remedy so the remedy keeps
142
+ * its own copy-pasteable line, and lifecycle.ts's batch roll-up is one indented
143
+ * line per failed item. A newline-stripping backstop would jam the remedy onto the
144
+ * sentence - and integration.test.ts's two separate `toContain`s would still pass
145
+ * while it did.
146
+ *
147
+ * 2. NO LENGTH CAP. The `--json` batch roll-up is legitimately around 435 characters
148
+ * with real temp paths; a 200-cap drops the second failure and batch-exit.test.ts
149
+ * asserts two. Bounding length is the boundary's job, where the untrusted fragment
150
+ * is still identifiable as untrusted.
151
+ *
152
+ * This is a BACKSTOP, not the primary control: it cannot tell an untrusted fragment
153
+ * from the CLI's own prose, so it can only strip what is never legitimate anywhere.
154
+ * A future call site that reads a new server field still owes it a `terminalLine`.
155
+ *
156
+ * THE JOINER RULE IS THE SAME HERE AS IN {@link terminalLine}, DELIBERATELY - it is NOT a
157
+ * third difference. Two reasons, and the first is sufficient on its own:
158
+ * - This backstop runs over COMPOSED messages that already contain line-sanitised
159
+ * fragments: verdicts.ts builds `${title} needs promptdock CLI ...` from a
160
+ * `terminalLine`'d skill title and hands it to `new CliError`, whose constructor
161
+ * calls this. A block pass that still deleted every U+200D would re-break, in every
162
+ * error message, the emoji title that line mode had just preserved - the fix would
163
+ * reach the install summary and silently miss every refusal.
164
+ * - "Strip only what is never legitimate anywhere" IS the contextual rule. A joiner
165
+ * inside an emoji sequence is legitimate text; a lone one never is.
166
+ */
167
+ export declare function terminalBlock(raw: string): string;
168
+ /**
169
+ * A redirect `Location`, which is a URL - the original 200, and the one call site the
170
+ * predecessor actually covered. Also the right cap for the device-flow
171
+ * `verification_url`, which is the same kind of thing.
172
+ */
173
+ export declare const LOCATION_MAX = 200;
174
+ /**
175
+ * A short identifying LABEL a server sends for display: a skill title or license, a
176
+ * handle, a slug, a role, a version string, a pairing code.
177
+ *
178
+ * 120 is the largest of those bounds, not a round number — `skills.title` is capped at
179
+ * 120 server-side, and everything else is smaller by construction (the slug regex
180
+ * `^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$` tops out at 64, the handle regex at 63, a role is a
181
+ * closed set, a `user_code` is the 9-character `XXXX-XXXX`). So a legitimate value is
182
+ * never clamped and the cap only ever bounds a hostile one.
183
+ */
184
+ export declare const SERVER_LABEL_MAX = 120;
185
+ /**
186
+ * A manifest file path, printed in the download list and in the refusal
187
+ * `assertSafeManifest` throws.
188
+ *
189
+ * 256 EXACTLY MIRRORS `validateSkillPaths` in paths.ts, which rejects any path longer
190
+ * than that — so this cap can never truncate a path the CLI would go on to accept, and a
191
+ * path being clamped here is one that was already refused.
192
+ */
193
+ export declare const SERVER_PATH_MAX = 256;
194
+ /**
195
+ * Server prose (`error.message`). Generous, because the point is to bound a hostile
196
+ * megabyte rather than to edit an honest sentence - today's longest real one is the
197
+ * 426's, at 67 characters.
198
+ */
199
+ export declare const SERVER_MESSAGE_MAX = 500;
200
+ /**
201
+ * The 426 `details.remedy` - a copy-pasteable command, so the cap needs measured
202
+ * headroom rather than a round number.
203
+ *
204
+ * ARITHMETIC (server side, lib/cli-auth/protocol.ts `upgradeRemedy`): the prefix
205
+ * "Run: npx promptdock@latest " is 27 characters and the echoed args are already
206
+ * allowlisted and sliced to 120 there, so a legitimate remedy tops out at 147. 256
207
+ * leaves room for the prefix to grow without ever truncating an honest command.
208
+ *
209
+ * WARNING: AND THE CAP IS APPLIED TO THE REMEDY ALONE, BEFORE api.ts joins it to the
210
+ * message. Applied after the join it would be measured against about 215 characters
211
+ * worst case and cut the tail off the very command the remedy exists to provide.
212
+ */
213
+ export declare const SERVER_REMEDY_MAX = 256;
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Terminal-output hygiene - the ONE place this package decides what an untrusted
3
+ * string may write to a user's terminal.
4
+ *
5
+ * WARNING: THIS FILE EXISTS BECAUSE A DOC COMMENT IS NOT A CONTRACT. Its predecessor
6
+ * was a private `sanitizeForTerminal` inside api.ts whose comment stated a GENERAL
7
+ * rule ("strip control chars before a server-supplied string reaches the terminal")
8
+ * while the code was applied at exactly ONE call site - the redirect Location. Five
9
+ * sibling sites in the same function shipped raw for the life of the file, and the
10
+ * comment is what stopped anyone looking. So: the rule now lives in its own module,
11
+ * is exported, is unit-tested against a character table, and every guarantee below is
12
+ * one a test pins. Do not restate a guarantee here that no test holds.
13
+ *
14
+ * THE THREAT. A hostile or MITM'd API response (a 401 needs no valid token to
15
+ * produce, and a captive portal can mint any status) can put terminal control
16
+ * sequences into a string this CLI prints. What that buys an attacker, worst first:
17
+ * 1. NEWLINE injection - needs no escape at all, so it survives any strip-ESC
18
+ * filter. One newline in a server message forges a whole line of CLI output: a
19
+ * green "installed" tick, a fake footer URL, a fake prompt.
20
+ * 2. CSI erase/cursor-move (ESC, or the 8-bit C1 introducer U+009B) - repaints or
21
+ * ERASES legitimate output already on screen, including an install summary the
22
+ * user just approved.
23
+ * 3. OSC 52 - writes the system clipboard, so the developer's next paste is
24
+ * attacker-chosen. The only class here with an effect outside the terminal.
25
+ * 4. OSC 8 hyperlinks - displayed text is not the link target, aimed at the DX3
26
+ * footer URL a stuck user is told to click.
27
+ * 5. SGR conceal/reverse - hides text and forges this CLI's own red/green
28
+ * semantics.
29
+ * 6. Bidi overrides and isolates (Trojan Source) and zero-width characters - NO
30
+ * control byte and NO escape at all, so they defeat a strip-ESC filter AND every
31
+ * terminal-agnostic argument about which emulator honours which sequence.
32
+ *
33
+ * WHY C1 MATTERS EVEN THOUGH JSON HIDES ESC. Measured, not assumed: `JSON.stringify`
34
+ * escapes every C0 byte (ESC becomes the six inert characters backslash-u-0-0-1-b),
35
+ * but passes DEL, the whole C1 range - including U+009B, the 8-bit CSI introducer, as
36
+ * bytes c2 9b - and every bidi/zero-width character straight through. So the `--json`
37
+ * document a developer reads in their terminal is a partial sink, not a safe one.
38
+ *
39
+ * PORTED FROM `lib/seo/meta-text.ts`, which solved the same problem for HTML meta
40
+ * attributes and paid for two lessons this file inherits. It cannot be imported:
41
+ * packages/cli ships to npm on its own and must not reach into the app (the same
42
+ * constraint that makes test/footer-codes.test.ts a text-scanning parity test).
43
+ *
44
+ * WHERE TO APPLY IT, AND WHY THE ANSWER DIFFERS BY CHANNEL.
45
+ *
46
+ * For an ERROR, the right place is the trust BOUNDARY — `Api.envelope()` sanitizes
47
+ * `error.message` once and every CliError built from it is safe, including the ones in
48
+ * files that never touch `mapEnvelopeError`.
49
+ *
50
+ * For everything else it is the DISPLAY SITE, and that is not inconsistency. A server
51
+ * string like a manifest `path` is PRINTED and then USED: `path` names the file the
52
+ * installer writes and the sha256 it verifies against. Sanitizing it at the boundary
53
+ * would silently change the byte the integrity check runs on — the fix would become a
54
+ * supply-chain bug. The same is true of a `slug` that builds a URL, a `version_id` sent
55
+ * back to the server, and a `device_code`. So the rule is:
56
+ *
57
+ * sanitize the FRAGMENT, at the moment it is interpolated for a human to read,
58
+ * and never the value the program goes on to use.
59
+ *
60
+ * WARNING: SANITIZE THE FRAGMENT, NOT THE COMPOSED LINE. `terminalLine` collapses
61
+ * whitespace runs, so running it over an already-built line destroys deliberate
62
+ * alignment — install.ts's summary block pads its labels into columns
63
+ * (" version: v3"), and one pass over the finished string would flatten every one of
64
+ * them to a single space.
65
+ *
66
+ * WARNING: A `CliError` FRAGMENT STILL NEEDS `terminalLine`. The constructor backstop
67
+ * strips control characters, but it deliberately preserves newlines and applies no cap
68
+ * — so a hostile skill title inside an error message can still forge a second output
69
+ * line or run to a megabyte. The backstop makes an error repaint-safe; only the
70
+ * fragment treatment makes it BOUNDED.
71
+ *
72
+ * WARNING: EVERY INVISIBLE OR CONTROL CHARACTER IN THIS FILE IS A \uXXXX ESCAPE, AND
73
+ * MUST STAY ONE. A literal one is invisible in a diff, is the exact Trojan-Source
74
+ * shape semgrep's `contains-bidirectional-characters` rule fires on (docs/memory.md
75
+ * records that firing as a TRUE positive on a guard that embedded a raw U+202E), and
76
+ * cannot be pasted into a shell safely. Name characters by code point in prose.
77
+ */
78
+ /**
79
+ * Invisible and bidi-reordering code points, DELETED rather than spaced - unlike a
80
+ * control character, replacing one with a space inserts a gap nobody wrote.
81
+ *
82
+ * WARNING: THE RANGE STOPS AT U+202E, one short of U+202F: NARROW NO-BREAK SPACE is
83
+ * real whitespace and must become a space, not vanish. `lib/seo/meta-text.ts` records
84
+ * shipping a draft that covered only U+200B-U+200F and U+2060-U+206F, so RLO
85
+ * (U+202E) - the one that actually reverses rendered text - sailed through while the
86
+ * comment claimed otherwise. Caught there by a unit test, not by review.
87
+ *
88
+ * THE TWO CLASSES NOW HOLD THE SAME MEMBERS, and packages/cli/test/zwj-parity.test.ts
89
+ * pins that as text. This paragraph used to say this class was WIDER than meta-text's -
90
+ * that meta-text skipped the bidi ISOLATES (U+2066-U+2069) and left U+FEFF to its
91
+ * whitespace pass. Both were true when written and both went stale on 2026-09-10, when
92
+ * meta-text was widened to every Bidi_Control, U+00AD and U+FEFF. A comment describing a
93
+ * sibling file is a claim nothing checks, which is why the parity is now a test.
94
+ *
95
+ * U+FEFF is included for a reason that holds here regardless: the whitespace pass exists
96
+ * only in line mode, block mode below has none, so the class has to carry it.
97
+ *
98
+ * WARNING: U+200D ZERO WIDTH JOINER IS NOT IN THIS CLASS, AND IT USED TO BE. It sat inside
99
+ * U+200B-U+200F, so every emoji ZWJ sequence reaching the terminal was broken: measured
100
+ * through the equivalent web function, the superhero went 4 code points to 3 and
101
+ * rendered as two glyphs, the family 5 to 3 (three separate people), the rainbow flag
102
+ * 4 to 3 (a flag beside a rainbow). Skill titles, author names and server messages all
103
+ * reach the terminal through this module, and emoji in a title are common. It is now
104
+ * deleted CONTEXTUALLY, by {@link LONE_ZWJ}. NEVER collapse this class back to the
105
+ * U+200B-U+200F range: that one edit re-adds the joiner.
106
+ */
107
+ const INVISIBLE = /[\u00ad\u061c\u200b-\u200c\u200e-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/gu;
108
+ /**
109
+ * A ZERO WIDTH JOINER that is not joining two emoji elements - DELETED. A joiner
110
+ * holding an emoji sequence together is KEPT.
111
+ *
112
+ * The rule is contextual rather than a removal from the class, because deleting a LONE
113
+ * joiner is still exactly right: outside an emoji sequence it is invisible padding, and
114
+ * "Pay" + U+200D + "Pal" becoming "PayPal" is the obfuscation this module exists to
115
+ * remove. A joiner survives only when its left neighbour ends an emoji element and its
116
+ * right neighbour starts one.
117
+ *
118
+ * WHAT MAY SIT BETWEEN THE PICTOGRAPH AND THE JOINER, and both are load-bearing. UTS #51
119
+ * defines a ZWJ element as a pictograph, a pictograph plus U+FE0F, or a pictograph plus
120
+ * a skin-tone modifier:
121
+ * - U+FE0F: the rainbow flag is U+1F3F3 U+FE0F U+200D U+1F308.
122
+ * - Emoji_Modifier, U+1F3FB-U+1F3FF. WARNING: THE WEB RULE THIS WAS PORTED FROM ALLOWED
123
+ * ONLY U+FE0F, AND IT BROKE EVERY SKIN-TONED SEQUENCE. The Fitzpatrick modifiers are
124
+ * Emoji_Modifier, not Extended_Pictographic (measured: false for all five). Against
125
+ * 863 ZWJ sequences validated by the engine's own RGI_Emoji data, that version broke
126
+ * 715 - every one carrying a skin tone. Found while porting it, fixed on both sides in
127
+ * the same change; this version breaks 0. The test file sweeps that corpus for
128
+ * exactly this reason.
129
+ *
130
+ * WARNING: THIS REGEX IS lib/seo/meta-text.ts's LONE_ZWJ, BYTE FOR BYTE, and
131
+ * test/zwj-parity.test.ts fails the build if the two ever differ. It was generated from
132
+ * that source rather than retyped - retyping is how a literal invisible creeps back in.
133
+ *
134
+ * Lookbehind and property escapes are u-flag features every Node >= 18 parses - but a
135
+ * property escape also needs ICU, which is why the regex is BUILT (see loneJoinerRule)
136
+ * rather than written as a literal.
137
+ */
138
+ const LONE_ZWJ = loneJoinerRule();
139
+ /**
140
+ * Build {@link LONE_ZWJ} WITHOUT letting a runtime that lacks ICU take the module down.
141
+ *
142
+ * WARNING: THIS IS A `new RegExp` FROM A STRING, NOT A REGEX LITERAL, AND THAT IS THE
143
+ * WHOLE POINT. A Node built `--with-intl=none` has no Unicode property escapes: V8
144
+ * rejects every `\p{...}` as an invalid regular expression. In a LITERAL that rejection
145
+ * happens when the file is PARSED, so it is a SyntaxError no try can catch, and in the
146
+ * published CLI it killed every command at load - `--help` and `--version` included -
147
+ * with a raw stack trace instead of a CliError (review finding, reproduced on Node 18;
148
+ * the CLI declares `engines: node >= 18` and no ICU requirement). Constructed from a
149
+ * string, the same rejection is an ordinary exception, and the rule degrades to what it
150
+ * was before 2026-09-10: every joiner deleted. Emoji ZWJ sequences break on such a
151
+ * runtime, and nothing else does.
152
+ *
153
+ * packages/cli/test/terminal-text.test.ts fails the build on any `\p{` regex LITERAL
154
+ * in the CLI source, because this failure mode cannot be reproduced at runtime on a
155
+ * build that HAS ICU - a patched RegExp constructor never sees a literal.
156
+ */
157
+ function loneJoinerRule() {
158
+ try {
159
+ return new RegExp("(?<!\\p{Extended_Pictographic}(?:\\uFE0F|\\p{Emoji_Modifier})?)\\u200D|\\u200D(?!\\p{Extended_Pictographic})", "gu");
160
+ }
161
+ catch {
162
+ // No property escapes on this runtime (a V8 without ICU).
163
+ return /\u200D/gu;
164
+ }
165
+ }
166
+ /**
167
+ * Every escape INTRODUCER: C0 (where newline, carriage return and tab live -
168
+ * deliberately), DEL, and C1. Removing the introducer leaves any payload behind it as
169
+ * inert printable text, which is why this is a complete answer to threat classes 2-5
170
+ * above rather than a best-effort sequence matcher. There is no allowlist of "safe"
171
+ * sequences to maintain and no parser to keep in step with a terminal's.
172
+ */
173
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/gu;
174
+ /** The same class MINUS U+000A. See {@link terminalBlock} for why newline survives. */
175
+ const CONTROL_KEEP_LF = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu;
176
+ /**
177
+ * Trim to at most `max` CODE POINTS, marking the cut with an ellipsis.
178
+ *
179
+ * WARNING: Code points, not UTF-16 units. The predecessor iterated code points and
180
+ * then did `out.slice(0, 200)` on the result, so a string ending in an astral
181
+ * character was cut mid-pair: measured, it returned a lone high surrogate U+D83C, and
182
+ * `Buffer.from(out,"utf8").toString("utf8") !== out` - invalid UTF-8, emitted straight
183
+ * at the terminal. Emoji in a server error message is not hypothetical.
184
+ *
185
+ * The ellipsis is not decoration. A silently truncated remedy is a WRONG command the
186
+ * user pastes; a visibly truncated one is a command they can see is incomplete.
187
+ *
188
+ * THE CUT LANDS ON A GRAPHEME BOUNDARY ({@link graphemePrefix}). The budget is still
189
+ * counted in code points - the name and every caller's arithmetic stay true - but the
190
+ * cut backs off to the last whole grapheme that fits. WARNING: KEEPING THE JOINER
191
+ * CREATED A NEW WAY TO BREAK IT. Once {@link LONE_ZWJ} preserves a joiner inside an
192
+ * emoji, a plain code-point slice can cut that emoji in half and leave the joiner
193
+ * DANGLING in front of the ellipsis - the exact lone-joiner shape the rule exists to
194
+ * remove - or leave a couple where a family was. Grapheme segmentation handles every such
195
+ * case by construction (ZWJ sequences, skin tones, flag pairs, combining marks).
196
+ *
197
+ * The repaint invariant in ui.ts (`clampLine`: logical lines == physical rows) is
198
+ * untouched: a grapheme cut only ever lands EARLIER than the code-point cut would, so a
199
+ * clamped line can never get wider.
200
+ */
201
+ export function clampCodePoints(s, max) {
202
+ if (Array.from(s).length <= max)
203
+ return s;
204
+ // One code point of the budget belongs to the ellipsis.
205
+ return graphemePrefix(s, Math.max(0, max - 1)) + "…";
206
+ }
207
+ /**
208
+ * The longest prefix of `s` that fits in `budget` CODE POINTS and ends on a grapheme
209
+ * boundary. A grapheme larger than the whole budget yields an empty prefix: half an
210
+ * emoji is worse than none, and the caller's ellipsis still says something was cut.
211
+ *
212
+ * `Intl.Segmenter` is read at CALL time, through `globalThis` so that a runtime with no
213
+ * `Intl` at all (a Node built without ICU) is covered too. A runtime without it - or one
214
+ * that throws constructing it - falls back to a
215
+ * code-point cut with any trailing joiner removed, so the no-dangling-joiner guarantee
216
+ * holds on BOTH paths and a test can exercise the fallback by removing the constructor.
217
+ * A published CLI runs on Node builds this repo never sees, so the fallback is not
218
+ * hypothetical, and a clamp that could throw would turn a long title into a crash.
219
+ *
220
+ * WARNING: lib/seo/meta-text.ts carries the same function for its own clamp.
221
+ */
222
+ function graphemePrefix(s, budget) {
223
+ if (budget <= 0)
224
+ return "";
225
+ // `Intl` itself does not exist on a runtime built without ICU, so a bare `Intl.Segmenter`
226
+ // throws ReferenceError before the try below can help. Read it through globalThis.
227
+ const Segmenter = globalThis.Intl?.Segmenter;
228
+ if (typeof Segmenter === "function") {
229
+ try {
230
+ let out = "";
231
+ let used = 0;
232
+ for (const { segment } of new Segmenter(undefined, { granularity: "grapheme" }).segment(s)) {
233
+ const n = Array.from(segment).length;
234
+ if (used + n > budget)
235
+ break;
236
+ out += segment;
237
+ used += n;
238
+ }
239
+ return out;
240
+ }
241
+ catch {
242
+ // A build without the break-iterator data. Fall through to the code-point cut.
243
+ }
244
+ }
245
+ return Array.from(s).slice(0, budget).join("").replace(/\u200D+$/u, "");
246
+ }
247
+ /**
248
+ * Collapse untrusted text to ONE safe, bounded line. Apply this at the trust
249
+ * BOUNDARY - the moment a server string enters this package - never at the terminal,
250
+ * where it is already mixed with the CLI's own escapes.
251
+ *
252
+ * Guarantees, each pinned by test/terminal-text.test.ts:
253
+ * - no C0, DEL or C1 byte survives (so no escape introducer, 8-bit forms included)
254
+ * - EVERY Unicode Bidi_Control code point is removed (all 12, swept by property in
255
+ * the test — not by example), plus the named zero-width and format characters
256
+ * listed on INVISIBLE. Other Default_Ignorable code points (variation selectors,
257
+ * Hangul fillers, Mongolian FVS) are NOT stripped and are not claimed to be:
258
+ * U+FE0F is load-bearing in legitimate emoji, so removing that whole property
259
+ * would corrupt real text to close a channel that cannot reorder anything.
260
+ * - U+200D is removed EXCEPT where it joins two emoji elements ({@link LONE_ZWJ}):
261
+ * every lone joiner goes, and every RGI emoji ZWJ sequence survives intact - the
262
+ * test sweeps an engine-validated corpus of 863, not a handful of examples.
263
+ * - the result contains no newline: an untrusted fragment is ONE line and cannot
264
+ * forge a second one. Line structure is the CLI's to compose, never the server's.
265
+ * - the result is at most `max` code points, and the clamp never splits a GRAPHEME -
266
+ * so never a surrogate pair, never an emoji sequence, and never a joiner left
267
+ * dangling in front of the ellipsis. (It cannot make an already ill-formed INPUT
268
+ * well-formed — a lone
269
+ * surrogate in the server's JSON stays one, though Node's UTF-8 encoder writes it
270
+ * out as U+FFFD, so no invalid byte reaches the terminal.)
271
+ *
272
+ * Control characters become a SPACE, never nothing: "line one\nline two" must not
273
+ * collapse to "line oneline two". The predecessor deleted them and did exactly that -
274
+ * measured. The whitespace pass then folds the runs it creates.
275
+ */
276
+ export function terminalLine(raw, max) {
277
+ if (!raw)
278
+ return "";
279
+ const flat = raw
280
+ .replace(LONE_ZWJ, "")
281
+ .replace(INVISIBLE, "")
282
+ .replace(CONTROL, " ")
283
+ .replace(/\s+/gu, " ")
284
+ .trim();
285
+ return clampCodePoints(flat, max);
286
+ }
287
+ /**
288
+ * Make already-composed text safe WITHOUT touching its line structure - the
289
+ * defence-in-depth backstop applied in the `CliError` constructor.
290
+ *
291
+ * Two deliberate differences from {@link terminalLine}, both load-bearing:
292
+ *
293
+ * 1. NEWLINE SURVIVES. Exactly two repo-authored CliError messages are multi-line and
294
+ * both are legitimate: api.ts's 426 joins message and remedy so the remedy keeps
295
+ * its own copy-pasteable line, and lifecycle.ts's batch roll-up is one indented
296
+ * line per failed item. A newline-stripping backstop would jam the remedy onto the
297
+ * sentence - and integration.test.ts's two separate `toContain`s would still pass
298
+ * while it did.
299
+ *
300
+ * 2. NO LENGTH CAP. The `--json` batch roll-up is legitimately around 435 characters
301
+ * with real temp paths; a 200-cap drops the second failure and batch-exit.test.ts
302
+ * asserts two. Bounding length is the boundary's job, where the untrusted fragment
303
+ * is still identifiable as untrusted.
304
+ *
305
+ * This is a BACKSTOP, not the primary control: it cannot tell an untrusted fragment
306
+ * from the CLI's own prose, so it can only strip what is never legitimate anywhere.
307
+ * A future call site that reads a new server field still owes it a `terminalLine`.
308
+ *
309
+ * THE JOINER RULE IS THE SAME HERE AS IN {@link terminalLine}, DELIBERATELY - it is NOT a
310
+ * third difference. Two reasons, and the first is sufficient on its own:
311
+ * - This backstop runs over COMPOSED messages that already contain line-sanitised
312
+ * fragments: verdicts.ts builds `${title} needs promptdock CLI ...` from a
313
+ * `terminalLine`'d skill title and hands it to `new CliError`, whose constructor
314
+ * calls this. A block pass that still deleted every U+200D would re-break, in every
315
+ * error message, the emoji title that line mode had just preserved - the fix would
316
+ * reach the install summary and silently miss every refusal.
317
+ * - "Strip only what is never legitimate anywhere" IS the contextual rule. A joiner
318
+ * inside an emoji sequence is legitimate text; a lone one never is.
319
+ */
320
+ export function terminalBlock(raw) {
321
+ return raw.replace(LONE_ZWJ, "").replace(INVISIBLE, "").replace(CONTROL_KEEP_LF, " ");
322
+ }
323
+ /**
324
+ * A redirect `Location`, which is a URL - the original 200, and the one call site the
325
+ * predecessor actually covered. Also the right cap for the device-flow
326
+ * `verification_url`, which is the same kind of thing.
327
+ */
328
+ export const LOCATION_MAX = 200;
329
+ /**
330
+ * A short identifying LABEL a server sends for display: a skill title or license, a
331
+ * handle, a slug, a role, a version string, a pairing code.
332
+ *
333
+ * 120 is the largest of those bounds, not a round number — `skills.title` is capped at
334
+ * 120 server-side, and everything else is smaller by construction (the slug regex
335
+ * `^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$` tops out at 64, the handle regex at 63, a role is a
336
+ * closed set, a `user_code` is the 9-character `XXXX-XXXX`). So a legitimate value is
337
+ * never clamped and the cap only ever bounds a hostile one.
338
+ */
339
+ export const SERVER_LABEL_MAX = 120;
340
+ /**
341
+ * A manifest file path, printed in the download list and in the refusal
342
+ * `assertSafeManifest` throws.
343
+ *
344
+ * 256 EXACTLY MIRRORS `validateSkillPaths` in paths.ts, which rejects any path longer
345
+ * than that — so this cap can never truncate a path the CLI would go on to accept, and a
346
+ * path being clamped here is one that was already refused.
347
+ */
348
+ export const SERVER_PATH_MAX = 256;
349
+ /**
350
+ * Server prose (`error.message`). Generous, because the point is to bound a hostile
351
+ * megabyte rather than to edit an honest sentence - today's longest real one is the
352
+ * 426's, at 67 characters.
353
+ */
354
+ export const SERVER_MESSAGE_MAX = 500;
355
+ /**
356
+ * The 426 `details.remedy` - a copy-pasteable command, so the cap needs measured
357
+ * headroom rather than a round number.
358
+ *
359
+ * ARITHMETIC (server side, lib/cli-auth/protocol.ts `upgradeRemedy`): the prefix
360
+ * "Run: npx promptdock@latest " is 27 characters and the echoed args are already
361
+ * allowlisted and sliced to 120 there, so a legitimate remedy tops out at 147. 256
362
+ * leaves room for the prefix to grow without ever truncating an honest command.
363
+ *
364
+ * WARNING: AND THE CAP IS APPLIED TO THE REMEDY ALONE, BEFORE api.ts joins it to the
365
+ * message. Applied after the join it would be measured against about 215 characters
366
+ * worst case and cut the tail off the very command the remedy exists to provide.
367
+ */
368
+ export const SERVER_REMEDY_MAX = 256;
package/dist/ui.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { clampCodePoints } from "./terminal-text.js";
1
2
  import { CliError, EXIT } from "./errors.js";
2
3
  import { reduceSelectKey, selectHint } from "./select-keys.js";
3
4
  /** NO_COLOR (https://no-color.org) honored: any non-empty value disables color.
@@ -78,10 +79,12 @@ export async function promptSelect(io, title, options, preselect, c, hotkeys, ho
78
79
  * prompt's absolute paths wrap on a stock 80-col terminal). Clamping before
79
80
  * colorization keeps logical lines == physical rows by construction. */
80
81
  function clampLine(line, columns) {
81
- const max = Math.max(8, columns - 1);
82
- if (line.length <= max)
83
- return line;
84
- return line.slice(0, max - 1) + "…";
82
+ // ⚠️ CODE POINTS, NOT UTF-16 UNITS. `.length`/`.slice` cut an astral character in
83
+ // half measured, this returned a lone high surrogate, which is not valid UTF-8.
84
+ // The picker paints this straight into a repaint frame, and an emoji in a skill
85
+ // title is not hypothetical. Same defect `clampCodePoints` exists to prevent, so it
86
+ // is reused rather than re-derived.
87
+ return clampCodePoints(line, Math.max(8, columns - 1));
85
88
  }
86
89
  async function interactiveSelect(io, title, options, preselect, c, hotkeys, hotkeyLabel) {
87
90
  const count = options.length;
@@ -1,4 +1,7 @@
1
1
  import type { ResolveResponse } from "./contract.js";
2
- export declare const UPGRADE_URL = "https://promptdock.ai/pricing";
2
+ /** ⚠️ DERIVED from `CANONICAL_ORIGIN`. This is the CTA a user follows the moment the CLI
3
+ * tells them their plan is too low — the one link most likely to be clicked under
4
+ * friction, so it must not spend a redirect hop. */
5
+ export declare const UPGRADE_URL = "https://www.promptdock.ai/pricing";
3
6
  /** Throws the DX3-copy CliError for a deny verdict; returns for ok/already_entitled. */
4
7
  export declare function assertInstallable(resolve: ResolveResponse, refString: string, cliVersion: string): void;