@ctrl-spc/cs 0.7.3 → 0.7.4
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/dist/companion.js +178 -18
- package/dist/daemon.js +103 -6
- package/dist/firewall.js +95 -0
- package/dist/local-paths.js +241 -0
- package/dist/mcp.js +59 -286
- package/dist/panel3/client.js +2 -1
- package/dist/panel3/prompt.js +22 -3
- package/dist/panel3/run.js +254 -37
- package/dist/panel3/tools.js +802 -28
- package/dist/presence-heartbeat.js +3 -0
- package/dist/presence.js +255 -76
- package/dist/screenshots.js +45 -0
- package/dist/supabase.js +43 -2
- package/dist/workflows.js +196 -8
- package/package.json +1 -1
|
@@ -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: `/Users` 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: `∷` 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
|
+
}
|