@ctrl-spc/cs 0.7.3 → 0.7.5

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,241 @@
1
+ /**
2
+ * ═══ AN ABSOLUTE LOCAL PATH MUST NEVER LEAVE THIS MACHINE. ═══
3
+ *
4
+ * GENERATION-NEUTRAL, and that is why it lives here rather than in `mcp.ts`. The
5
+ * rule is the product's, not one generation's: the hosted-path-privacy contract
6
+ * forbids an absolute local path in hosted browser JS, so every writer of prose
7
+ * the web renders has to ask the same question of it. This file is the one
8
+ * answer; `mcp.ts` wraps it in `refuseAbsolutePaths` and `workflows.ts` sweeps a
9
+ * workflow with it before building one.
10
+ *
11
+ * Nothing here reads a file, a table or an environment. It is a scan over a
12
+ * string and the reasoning about what a path looks like, moved whole out of
13
+ * `mcp.ts` with that reasoning intact.
14
+ */
15
+ /** Absolute in any form a repo path can arrive in: POSIX (`/etc`), UNC and
16
+ * root-relative Windows (`\\server\share`, `\Users\Lane\repo`), Windows
17
+ * drive-letter (`C:\repo`, `C:/repo`) and drive-RELATIVE (`C:AGENTS.md`, which
18
+ * resolves against that drive's current directory and still discloses a local
19
+ * layout). One leading separator of EITHER kind, or any drive letter, is
20
+ * enough — a single leading backslash is a Windows absolute path just as `/`
21
+ * is a POSIX one.
22
+ *
23
+ * `cliv2_work_reservations_path_relative`
24
+ * (20260722160000_cliv2_coordination.sql) is the same rule for the same
25
+ * reason, and it has the narrower form with both of those holes. That is a
26
+ * pre-existing bug on that table, not a licence to repeat it here. */
27
+ export const ABSOLUTE_PATH_RE = /^(?:[\\/]|[A-Za-z]:)/;
28
+ /** Decode the HTML entities a browser would decode, so the guard scans what
29
+ * the USER will eventually read rather than what the agent happened to type.
30
+ * Without this, `/Users/lane/x` sails past every scan and then
31
+ * `interactiveArtifactHtml()` — which parses with DOMParser — paints the
32
+ * real absolute path into hosted browser JS. The guard and the renderer must
33
+ * agree about what a string SAYS; entities are exactly where they diverge.
34
+ * Numeric (decimal and hex) plus the small set of named entities that can
35
+ * spell a path separator or a drive colon. */
36
+ const NAMED_ENTITIES = {
37
+ sol: '/',
38
+ bsol: '\\',
39
+ colon: ':',
40
+ period: '.',
41
+ lowbar: '_',
42
+ quot: '"',
43
+ apos: "'",
44
+ amp: '&',
45
+ lt: '<',
46
+ gt: '>',
47
+ };
48
+ /* The trailing `;` is OPTIONAL for numeric entities, because browsers accept
49
+ it that way: `&#47Users` renders as `/Users`. A guard that required the
50
+ semicolon allowed exactly that string through while the renderer painted a
51
+ real path — found by differentially testing this function against jsdom's
52
+ DOMParser, which is the same parser interactiveArtifactHtml() uses. Named
53
+ entities keep the required `;` (that is what browsers do outside a short
54
+ legacy list, and dropping it would eat `&sole` in ordinary prose). */
55
+ function decodeEntities(text) {
56
+ return text.replace(/&(?:(#[Xx][0-9A-Fa-f]+|#\d+);?|([A-Za-z][A-Za-z0-9]*);)/g, (whole, numericBody, namedBody) => {
57
+ const body = numericBody ?? namedBody ?? '';
58
+ if (body.startsWith('#')) {
59
+ const code = body[1] === 'x' || body[1] === 'X'
60
+ ? Number.parseInt(body.slice(2), 16)
61
+ : Number.parseInt(body.slice(1), 10);
62
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
63
+ }
64
+ // Named entities are case-SENSITIVE in HTML: `&Colon;` is U+2237 (∷), not
65
+ // a colon, so lower-casing here refused text the browser never renders as
66
+ // a path. Exact match only.
67
+ const named = NAMED_ENTITIES[body];
68
+ return named ?? whole;
69
+ });
70
+ }
71
+ /**
72
+ * The first absolute local path inside `text`, or null.
73
+ *
74
+ * THE DESIGN, and why it is not the previous one. This helper used to split
75
+ * the text into tokens on a separator list and test each token. Three review
76
+ * rounds each answered a leak by adding characters to that list (`(,=;`, then
77
+ * `<>"'`), and the third round proved the approach is wrong rather than
78
+ * incomplete: a quote *inside* a path (`/'Users/lane/x`, `/Users/lane/Lane's
79
+ * Docs/x`) SPLIT the path into fragments, none of which starts with a root
80
+ * marker — so widening the separator list to catch markup simultaneously
81
+ * opened a hole for paths containing that markup, and the refusal message for
82
+ * a legitimate apostrophe path named a truncated path that did not exist.
83
+ * Separators cannot both be inside and outside the thing being matched.
84
+ *
85
+ * So: do not tokenise. SCAN for the path shape itself, anchored at a root
86
+ * marker, and let the match end where a character that cannot appear in a
87
+ * path appears. A path is a match, not a token. The exclusions below are the
88
+ * genuine false positives, tested against the same corpus as before:
89
+ *
90
+ * - a URL's authority (`https://host/path`) is not a local path — a match
91
+ * immediately preceded by `//` of a non-`file:` scheme is skipped, while
92
+ * `file:///Users/…` IS reported (its body is a real local path);
93
+ * - a bare drive label `A:` with no path body is a list marker;
94
+ * - a single-segment `/word` (`/dashboard`, `/api/`) is a URL path — local
95
+ * paths in prose always carry a second separator.
96
+ *
97
+ * Entities are decoded first (see `decodeEntities`), because the renderer
98
+ * decodes them too.
99
+ */
100
+ /* The scan finds the path SHAPE anywhere in the text and decides what it is
101
+ from WHAT MATCHED, not from what precedes it.
102
+ *
103
+ * A previous round anchored this with a lookbehind excluding "characters a
104
+ * path cannot follow". That was the separator-list mistake one layer down: the
105
+ * exclusion set is itself a character list, and every character in it became a
106
+ * hiding place. `~/Users/lane/x`, `x./Users/lane/x` and a path embedded in a
107
+ * URL all leaked, and `~/…` is an entirely ordinary thing for an agent to
108
+ * write. A rule that says "not after these characters" can always be defeated
109
+ * by writing one of them first.
110
+ *
111
+ * So there is no lookbehind. Instead the two genuine false positives are
112
+ * excluded by structure, below:
113
+ * - a repo-relative path (`web/src/App.tsx`) never starts at a root marker,
114
+ * so requiring the match to BEGIN with `/`, `\\` or `C:` already excludes
115
+ * it — `/src/App.tsx` inside it is only reachable mid-token, which the
116
+ * single-segment and second-separator rules then handle;
117
+ * - a URL's authority is recognised by its own scheme, checked explicitly.
118
+ *
119
+ * `'` and `"` are NOT body terminators — a quote inside a path is exactly the
120
+ * case that made the separator-splitting design leak. */
121
+ const ABSOLUTE_PATH_SCAN = /(?:[A-Za-z]:[\\/'"]|\\\\|\/)[^\s<>`(){}\[\],;|&*?\n\r\t]*/g;
122
+ /* The POSIX roots that actually hold a user's files. A leading `/` alone does
123
+ NOT make a machine path — `/api/items`, `/img/logo.png` and `/dashboard` are
124
+ site paths, and an agent's panel is full of them (href, src, srcset, CSS
125
+ url()). Refusing those would make the house style unusable and agents would
126
+ route around the guard, which is worse than a narrower rule. These roots are
127
+ the ones whose contents are private to the machine. */
128
+ const POSIX_MACHINE_ROOT = /^\/(?:Users|home|root|var|etc|opt|srv|private|tmp|mnt|media|Volumes|Applications|Library|System|usr\/local|dev)(?:\/|$)/i;
129
+ /** A machine path buried inside a longer match — the segment of a remote URL
130
+ * that spells one (`https://host/x/Users/lane/secret.txt`). The scan's body
131
+ * is greedy, so such a path never gets a match of its own; it has to be dug
132
+ * out of the match that swallowed it. Rendered in front of a viewer, it
133
+ * discloses exactly what bare text would. */
134
+ function buriedMachinePath(candidate) {
135
+ // A Windows path can be buried too — `https://host/xC:\Users\Lane\x` — and
136
+ // it does not begin at a `/`, so scanning only slash positions missed it.
137
+ // Shape, not just a colon-slash: a drive letter, a separator, then at least
138
+ // one real segment. `a:/b` in a URL query is not a Windows path; the `\` or
139
+ // a `Users`-style segment is what makes it one. Requiring a BACKSLASH
140
+ // separator keeps this narrow — a forward-slash drive path (`C:/x`) inside a
141
+ // URL is indistinguishable from an ordinary URL fragment, and the POSIX pass
142
+ // below already covers the roots that matter.
143
+ const drive = /[A-Za-z]:\\[^\s<>"'`]+/.exec(candidate);
144
+ if (drive)
145
+ return candidate.slice(drive.index);
146
+ for (let at = candidate.indexOf('/'); at !== -1; at = candidate.indexOf('/', at + 1)) {
147
+ const tail = candidate.slice(at);
148
+ if (POSIX_MACHINE_ROOT.test(tail))
149
+ return tail;
150
+ }
151
+ return null;
152
+ }
153
+ export function absolutePathToken(text) {
154
+ const decoded = decodeEntities(text);
155
+ // `file:///Users/lane/x` is a local path wearing a URL. Handle it up front
156
+ // and by NAME, rather than leaving the generic scan to reason about where a
157
+ // scheme ends — that reasoning is what produced two of this function's bugs.
158
+ const fileUrl = /\bfile:\/\/(\/\S*)/i.exec(decoded);
159
+ if (fileUrl && POSIX_MACHINE_ROOT.test(fileUrl[1]))
160
+ return fileUrl[1];
161
+ ABSOLUTE_PATH_SCAN.lastIndex = 0;
162
+ let match;
163
+ while ((match = ABSOLUTE_PATH_SCAN.exec(decoded)) !== null) {
164
+ // Trailing wrapping punctuation belongs to the prose, not the path:
165
+ // `("/Users/lane/x.md")` and `see /Users/lane/x.md.` both end early.
166
+ const candidate = match[0].replace(/["')\].,:;]+$/, '');
167
+ if (!candidate || !ABSOLUTE_PATH_RE.test(candidate))
168
+ continue;
169
+ const before = decoded.slice(0, match.index);
170
+ // A URL: skip its authority and its ordinary path — `https://host/a/b`
171
+ // matches at `//host/a/b` and again at `/b`, and neither is a local path.
172
+ // The scheme identifies it, so look for the scheme rather than testing the
173
+ // character immediately before. (`file:` never reaches here; it is handled
174
+ // by name above.)
175
+ //
176
+ // EXCEPT when the URL's path is itself rooted at a machine root. A remote
177
+ // URL spelling `…/x/Users/lane/private/secret.txt` puts that path in front
178
+ // of a viewer just as plainly as bare text does, and the contract is about
179
+ // the string reaching hosted browser JS, not about how it got there. A
180
+ // false alarm costs the agent one retry; a leak is permanent, so this errs
181
+ // toward refusing.
182
+ if (/([A-Za-z][A-Za-z0-9+.-]*):\/\/\S*$/.test(before) && !POSIX_MACHINE_ROOT.test(candidate)) {
183
+ const buried = buriedMachinePath(candidate);
184
+ if (buried)
185
+ return buried;
186
+ continue;
187
+ }
188
+ // `https://host/x` also matches AT the scheme's own `s://…`, which reads
189
+ // as a one-letter drive. A real drive letter is followed by a separator
190
+ // and then a path segment — never by `//`. Before discarding it, check
191
+ // whether a machine root is buried INSIDE: the scan's body is greedy, so
192
+ // `s://example.com/x/Users/lane/x` is one match and the `/Users/…` inside
193
+ // it never gets a match of its own. A remote URL spelling a machine path
194
+ // discloses it to a viewer just as plainly as bare text does.
195
+ if (/^[A-Za-z]:\/\//.test(candidate)) {
196
+ const buried = buriedMachinePath(candidate);
197
+ if (buried)
198
+ return buried;
199
+ continue;
200
+ }
201
+ // A CONTINUATION of a relative path: `web/src/App.tsx` matches at
202
+ // `/src/App.tsx`, which is not a local path — the token it belongs to
203
+ // began with a bare word. Walk back to the start of the whole token and
204
+ // ask what IT begins with. This is deliberately not "is the preceding
205
+ // character in a set": `~/Users/lane/x` and `x./Users/lane/x` both have a
206
+ // path-ish character before the slash, and both DO carry a real absolute
207
+ // path, so a character test leaks them. Reading the token's own first
208
+ // character answers correctly in every one of those cases.
209
+ // The token runs back to whitespace OR a markup boundary (`<>"'=`) — a
210
+ // quote or a tag bracket ends the token even though it is not whitespace,
211
+ // which is what lets `class="mono">/Users/…` be seen as a path rather than
212
+ // as the tail of the token `class`.
213
+ // Only a token that is itself a plausible RELATIVE PATH suppresses the
214
+ // match — `web/src` in `web/src/App.tsx`. A bare word (`x.`), a number
215
+ // (`1`), or anything not shaped like a path segment does not, because
216
+ // `x./Users/lane/x` and `1/Users/lane/x` still carry a real absolute path.
217
+ const tokenStart = /([^\s<>"'=]+)$/.exec(before)?.[1] ?? '';
218
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]+)*\/?$/.test(tokenStart)
219
+ && tokenStart.includes('/')
220
+ && !POSIX_MACHINE_ROOT.test(candidate))
221
+ continue;
222
+ if (/^[A-Za-z]:$/.test(candidate))
223
+ continue;
224
+ // A POSIX candidate is only a MACHINE path if it starts at a real root.
225
+ // `/img/x.png`, `/api/items`, `/dashboard` and `2026/07/31` are site paths
226
+ // and dates — they are not local paths, they are the ordinary content of
227
+ // an agent's panel (CSS urls, hrefs, srcset), and refusing them makes the
228
+ // house style unusable. The roots below are the ones that actually carry a
229
+ // user's files; a path under any of them is refused, everything else is
230
+ // left alone. Windows (`C:\…`) and UNC (`\\host\share`) are unambiguous by
231
+ // construction and are not filtered here.
232
+ // Strip a quote sitting immediately after the root before classifying:
233
+ // `/'Users/lane/x` is the separator-design leak, and the root test must
234
+ // see `/Users/…` to recognise it.
235
+ const rooted = candidate.replace(/^([\\/]+)["']/, '$1');
236
+ if (rooted.startsWith('/') && !POSIX_MACHINE_ROOT.test(rooted))
237
+ continue;
238
+ return candidate;
239
+ }
240
+ return null;
241
+ }