@foldspace_npm/harness 0.1.33 → 0.1.35

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,785 @@
1
+ // Safety rules: what a handler may do with the customer's backend and the user's
2
+ // sign-in. Cards may draw any markup - that is the builder's to use. Each rule
3
+ // here blocks the build (known-hosts-only only warns), so each
4
+ // one only fires on what it can see for certain in the source - an address
5
+ // written out, a token read with getAuthToken - and stays quiet on what it
6
+ // cannot follow (an address handed in as a parameter). Each message says what
7
+ // to do instead.
8
+ //
9
+ // no-fixed-customer-address one customer's address, on a product with a subdomain per customer
10
+ // token-stays-home the user's token returned to the agent, logged, stored again or sent elsewhere
11
+ // no-secrets keys, JWTs, cookies, HAR captures in the project
12
+ // known-hosts-only calls only to the product's own hosts, or hosts named in agent/constants.ts
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { finding, forEachNamedFunction, forEachReturn, isFunctionLike, parseFile, resolveModulePath, stringText } from "./ast.mjs";
17
+ import { ts } from "./ts.mjs";
18
+ import { hostMatchesAny, siteOf } from "../observe-core.mjs";
19
+ import { addressNamesLabel, customerLabel, suggestedApiBase } from "../customer-subdomain.mjs";
20
+
21
+ // ---------------------------------------------------------------- project facts
22
+
23
+ const PLACEHOLDER_HOST = "x-subdomain-x";
24
+
25
+ /** The host of an absolute address, or null. `{subdomain}` reads as a wildcard: `*.api.example.com`. */
26
+ export function absoluteHost(text) {
27
+ const value = String(text || "").trim();
28
+ if (!/^(?:(?:https?|wss?):)?\/\//i.test(value)) return null;
29
+ try {
30
+ const host = new URL(value.replace(/\{subdomain\}/g, PLACEHOLDER_HOST), "https://placeholder.invalid").hostname.toLowerCase();
31
+ if (!host || host === "placeholder.invalid") return null;
32
+ return host.startsWith(`${PLACEHOLDER_HOST}.`) ? `*.${host.slice(PLACEHOLDER_HOST.length + 1)}` : host;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function moduleConsts(sourceFile) {
39
+ const map = new Map();
40
+ for (const stmt of sourceFile.statements) {
41
+ if (!ts.isVariableStatement(stmt)) continue;
42
+ for (const decl of stmt.declarationList.declarations) {
43
+ if (ts.isIdentifier(decl.name) && decl.initializer) map.set(decl.name.text, decl);
44
+ }
45
+ }
46
+ return map;
47
+ }
48
+
49
+ /**
50
+ * What the project says about where it may call: the app's host, the hosts in
51
+ * foldspace.dev.json, and agent/constants.ts - API_BASE, PER_CUSTOMER_SUBDOMAIN
52
+ * and every address written there. Missing or unreadable files give fewer
53
+ * facts, never an error: the rules that need a fact stay quiet without it.
54
+ */
55
+ export function readProjectFacts(projectDir) {
56
+ const facts = {
57
+ appHost: null,
58
+ devHosts: [],
59
+ constantsFile: null,
60
+ constantsSource: null,
61
+ apiBase: null,
62
+ apiBaseNode: null,
63
+ perCustomerSubdomain: undefined,
64
+ namedHosts: [],
65
+ };
66
+
67
+ try {
68
+ const config = JSON.parse(fs.readFileSync(path.join(projectDir, "foldspace.dev.json"), "utf8"));
69
+ const targets = config?.targets && typeof config.targets === "object" ? config.targets : {};
70
+ const order = [config?.defaultTarget, ...Object.keys(targets)].filter((name) => typeof name === "string" && targets[name]);
71
+ for (const name of order) {
72
+ const target = targets[name];
73
+ for (const host of Array.isArray(target.hosts) ? target.hosts : []) {
74
+ if (typeof host === "string" && host) facts.devHosts.push(host.toLowerCase());
75
+ }
76
+ const start = absoluteHost(target.startUrl);
77
+ if (start) {
78
+ facts.devHosts.push(start);
79
+ facts.appHost ??= start;
80
+ }
81
+ }
82
+ } catch {
83
+ /* no dev config: fewer facts */
84
+ }
85
+
86
+ const constantsPath = ["constants.ts", "constants.js"].map((name) => path.join(projectDir, "agent", name)).find((file) => fs.existsSync(file));
87
+ if (constantsPath) {
88
+ const sourceFile = parseFile(constantsPath);
89
+ facts.constantsFile = path.relative(projectDir, constantsPath);
90
+ facts.constantsSource = sourceFile;
91
+ const consts = moduleConsts(sourceFile);
92
+ const text = (name) => {
93
+ const decl = consts.get(name);
94
+ return decl ? stringText(decl.initializer) : null;
95
+ };
96
+ const appDomain = text("APP_DOMAIN");
97
+ if (appDomain && !appDomain.includes("{{")) facts.appHost = absoluteHost(`https://${appDomain}`) ?? facts.appHost;
98
+ const apiBaseDecl = consts.get("API_BASE");
99
+ if (apiBaseDecl && stringText(apiBaseDecl.initializer) !== null) {
100
+ facts.apiBase = stringText(apiBaseDecl.initializer);
101
+ facts.apiBaseNode = apiBaseDecl.initializer;
102
+ }
103
+ const flag = consts.get("PER_CUSTOMER_SUBDOMAIN")?.initializer;
104
+ if (flag?.kind === ts.SyntaxKind.TrueKeyword) facts.perCustomerSubdomain = true;
105
+ if (flag?.kind === ts.SyntaxKind.FalseKeyword) facts.perCustomerSubdomain = false;
106
+ walkStrings(sourceFile, (node, value) => {
107
+ const host = absoluteHost(value);
108
+ if (host) facts.namedHosts.push(host);
109
+ });
110
+ }
111
+ if (facts.appHost) facts.devHosts.push(facts.appHost);
112
+ facts.devHosts = [...new Set(facts.devHosts)];
113
+ facts.namedHosts = [...new Set(facts.namedHosts)];
114
+ return facts;
115
+ }
116
+
117
+ function hostMatches(host, patterns) {
118
+ return patterns.some((pattern) => {
119
+ if (pattern.startsWith("*.")) return hostMatchesAny(host, [pattern]);
120
+ return host === pattern;
121
+ });
122
+ }
123
+
124
+ /** The product's own hosts: those foldspace.dev.json names, API_BASE's, and any on the app's own domain. */
125
+ function isProductHost(host, facts) {
126
+ if (!facts.appHost && !facts.devHosts.length) return true; // nothing to judge by
127
+ const apiHost = absoluteHost(facts.apiBase);
128
+ const bare = host.replace(/^\*\./, "");
129
+ return (
130
+ hostMatchesAny(bare, facts.devHosts) ||
131
+ (apiHost !== null && hostMatches(bare, [apiHost])) ||
132
+ facts.devHosts.some((pattern) => siteOf(pattern.replace(/^\*\./, "")) === siteOf(bare))
133
+ );
134
+ }
135
+
136
+ /** Foldspace's own hosts are trusted everywhere. */
137
+ function isFoldspaceHost(host) {
138
+ const bare = host.replace(/^\*\./, "").toLowerCase();
139
+ return bare === "foldspace.ai" || bare.endsWith(".foldspace.ai");
140
+ }
141
+
142
+ /** Product hosts, Foldspace's own, plus every host written in agent/constants.ts - the one reviewed list of other hosts. */
143
+ function isKnownHost(host, facts) {
144
+ return isFoldspaceHost(host) || isProductHost(host, facts) || hostMatches(host, facts.namedHosts);
145
+ }
146
+
147
+ // ---------------------------------------------------------------- scopes
148
+
149
+ /**
150
+ * Which declaration a name refers to, by function scope. Blocks are not
151
+ * scopes here: two `let token` in one function read as one. That only ever
152
+ * makes a rule see a token where there is one in the same function.
153
+ */
154
+ class Scopes {
155
+ constructor(sourceFile) {
156
+ this.sourceFile = sourceFile;
157
+ this.byScope = new Map();
158
+ const scopeOf = (node) => {
159
+ for (let up = node.parent; up; up = up.parent) {
160
+ if (isFunctionLike(up) || ts.isSourceFile(up)) return up;
161
+ }
162
+ return sourceFile;
163
+ };
164
+ const declare = (scope, name, decl) => {
165
+ if (!this.byScope.has(scope)) this.byScope.set(scope, new Map());
166
+ const names = this.byScope.get(scope);
167
+ if (!names.has(name)) names.set(name, decl);
168
+ };
169
+ const declareBinding = (scope, name, decl) => {
170
+ if (ts.isIdentifier(name)) declare(scope, name.text, decl);
171
+ else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
172
+ for (const element of name.elements) {
173
+ if (ts.isBindingElement(element)) declareBinding(scope, element.name, element);
174
+ }
175
+ }
176
+ };
177
+ const walk = (node) => {
178
+ if (ts.isVariableDeclaration(node)) declareBinding(scopeOf(node), node.name, node);
179
+ else if (ts.isParameter(node)) declareBinding(node.parent, node.name, node);
180
+ else if (ts.isFunctionDeclaration(node) && node.name) declare(scopeOf(node), node.name.text, node);
181
+ else if (ts.isImportSpecifier(node) || ts.isImportClause(node) || ts.isNamespaceImport(node)) {
182
+ if (node.name) declare(sourceFile, node.name.text, node);
183
+ }
184
+ ts.forEachChild(node, walk);
185
+ };
186
+ walk(sourceFile);
187
+ }
188
+
189
+ resolve(identifier) {
190
+ for (let up = identifier; up; up = up.parent) {
191
+ if (isFunctionLike(up) || ts.isSourceFile(up)) {
192
+ const found = this.byScope.get(up)?.get(identifier.text);
193
+ if (found) return found;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** The value a `const` name holds, when it is declared with one. */
200
+ constValue(identifier) {
201
+ const decl = this.resolve(identifier);
202
+ if (!decl || !ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return null;
203
+ const list = decl.parent;
204
+ if (!list || !(list.flags & ts.NodeFlags.Const)) return null;
205
+ return decl.initializer ?? null;
206
+ }
207
+
208
+ /** The module an imported name comes from. */
209
+ importedFrom(identifier) {
210
+ const decl = this.resolve(identifier);
211
+ if (!decl) return null;
212
+ let up = decl;
213
+ while (up && !ts.isImportDeclaration(up)) up = up.parent;
214
+ return up && ts.isStringLiteral(up.moduleSpecifier) ? up.moduleSpecifier.text : null;
215
+ }
216
+ }
217
+
218
+ const unwrap = (node) => {
219
+ while (
220
+ node &&
221
+ (ts.isParenthesizedExpression(node) ||
222
+ ts.isAsExpression(node) ||
223
+ ts.isNonNullExpression(node) ||
224
+ ts.isTypeAssertionExpression(node) ||
225
+ ts.isSatisfiesExpression(node) ||
226
+ ts.isAwaitExpression(node))
227
+ ) {
228
+ node = node.expression;
229
+ }
230
+ return node;
231
+ };
232
+
233
+ const calleeName = (call) => {
234
+ const callee = unwrap(call.expression);
235
+ if (ts.isIdentifier(callee)) return callee.text;
236
+ if (ts.isPropertyAccessExpression(callee)) return callee.name.text;
237
+ return null;
238
+ };
239
+
240
+ const receiverText = (call, sourceFile) => {
241
+ const callee = unwrap(call.expression);
242
+ return ts.isPropertyAccessExpression(callee) ? callee.expression.getText(sourceFile).replace(/\s+/g, "") : "";
243
+ };
244
+
245
+ function walkStrings(node, visit) {
246
+ const walk = (current) => {
247
+ if (ts.isImportDeclaration(current) || ts.isExportDeclaration(current)) return;
248
+ if (ts.isStringLiteral(current) || ts.isNoSubstitutionTemplateLiteral(current)) visit(current, current.text);
249
+ else if (ts.isTemplateExpression(current)) visit(current, current.head.text);
250
+ ts.forEachChild(current, walk);
251
+ };
252
+ walk(node);
253
+ }
254
+
255
+ // ---------------------------------------------------------------- where a URL goes
256
+
257
+ /**
258
+ * Where an address expression points, as far as the source shows:
259
+ * `{ kind: "backend" }` - API_BASE, apiBaseUrl(), the page's own origin or a
260
+ * path on it; `{ kind: "host", host }` - an address written out;
261
+ * `{ kind: "unknown" }` - anything the source does not show.
262
+ */
263
+ function destination(node, scopes, depth = 0) {
264
+ node = unwrap(node);
265
+ if (!node || depth > 6) return { kind: "unknown" };
266
+ const fromText = (text, partial = false) => {
267
+ if (!text) return { kind: "unknown" };
268
+ if (!text.startsWith("//") && !/^[a-z][a-z0-9+.-]*:/i.test(text)) return { kind: "backend" }; // a path on the page's own host
269
+ // The start of a template names a host only when something follows it: `https://api.` + name does not.
270
+ if (partial && !/^(?:(?:https?|wss?):)?\/\/[^/?#:]+[/?#:]/i.test(text)) return { kind: "unknown" };
271
+ const host = absoluteHost(text);
272
+ return host ? { kind: "host", host } : { kind: "unknown" };
273
+ };
274
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return fromText(node.text);
275
+ if (ts.isTemplateExpression(node)) {
276
+ if (node.head.text) return fromText(node.head.text, true);
277
+ return destination(node.templateSpans[0].expression, scopes, depth + 1);
278
+ }
279
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
280
+ const left = unwrap(node.left);
281
+ if (ts.isStringLiteral(left) || ts.isNoSubstitutionTemplateLiteral(left)) return fromText(left.text, true);
282
+ return destination(node.left, scopes, depth + 1);
283
+ }
284
+ if (ts.isIdentifier(node)) {
285
+ if (node.text === "API_BASE" || node.text === "apiBase") return { kind: "backend" };
286
+ const value = scopes.constValue(node);
287
+ return value ? destination(value, scopes, depth + 1) : { kind: "unknown" };
288
+ }
289
+ if (ts.isPropertyAccessExpression(node)) {
290
+ if (node.name.text === "apiBase") return { kind: "backend" };
291
+ if (node.name.text === "origin" && /(^|\.)location$/.test(node.expression.getText(scopes.sourceFile))) return { kind: "backend" };
292
+ return { kind: "unknown" };
293
+ }
294
+ if (ts.isCallExpression(node)) {
295
+ return calleeName(node) === "apiBaseUrl" ? { kind: "backend" } : { kind: "unknown" };
296
+ }
297
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "URL" && node.arguments?.length) {
298
+ const first = destination(node.arguments[0], scopes, depth + 1);
299
+ if (first.kind === "host" || node.arguments.length < 2) return first;
300
+ return destination(node.arguments[1], scopes, depth + 1);
301
+ }
302
+ return { kind: "unknown" };
303
+ }
304
+
305
+ /** Calls that reach the network, and which argument holds the address. */
306
+ function networkCall(node, sourceFile) {
307
+ if (ts.isCallExpression(node)) {
308
+ const name = calleeName(node);
309
+ const receiver = receiverText(node, sourceFile);
310
+ if (name === "fetch" && (!receiver || /^(window|globalThis|self)$/.test(receiver))) return { what: "fetch", url: node.arguments[0], payload: node.arguments.slice(0, 2) };
311
+ if (name === "sendBeacon") return { what: "sendBeacon", url: node.arguments[0], payload: node.arguments.slice(0, 2) };
312
+ if (name === "open" && node.arguments.length >= 2 && ts.isStringLiteralLike(node.arguments[0]) && /^[A-Z]+$/.test(node.arguments[0].text)) {
313
+ return { what: "XMLHttpRequest", url: node.arguments[1], payload: [node.arguments[1]] };
314
+ }
315
+ if (name === "publicFetch" && node.arguments[1]) return { what: "publicFetch", url: node.arguments[1], payload: [] };
316
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) return { what: "import()", url: node.arguments[0], payload: [] };
317
+ if (name === "importScripts") return { what: "importScripts", url: node.arguments[0], payload: [] };
318
+ }
319
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && ["WebSocket", "EventSource"].includes(node.expression.text)) {
320
+ return { what: node.expression.text, url: node.arguments?.[0], payload: node.arguments?.slice(0, 1) ?? [] };
321
+ }
322
+ return null;
323
+ }
324
+
325
+ // ---------------------------------------------------------------- the user's token
326
+
327
+ const TOKENISH_KEY = /token|session|auth|jwt|bearer|\bsid\b|credential|access/i;
328
+ // atob is not here: decoding a JWT's middle part gives its claims (who the
329
+ // user is), the way parseJwt does - not the token.
330
+ const PASS_THROUGH_CALLS = new Set(["String", "encodeURIComponent", "encodeURI", "decodeURIComponent", "btoa", "escape", "unescape"]);
331
+ const NON_CARRYING_METHODS = new Set(["startsWith", "endsWith", "includes", "indexOf", "lastIndexOf", "localeCompare", "test", "charCodeAt", "search", "has"]);
332
+ const PASS_THROUGH_CONSTRUCTORS = new Set(["Headers", "URLSearchParams", "Blob", "FormData", "Request", "URL"]);
333
+ const AGENT_METHODS = new Set(["identify", "setContext", "addContext", "message", "sendMessage", "runTask"]);
334
+
335
+ class TokenFlow {
336
+ constructor(sourceFile, scopes) {
337
+ this.sourceFile = sourceFile;
338
+ this.scopes = scopes;
339
+ this.tainted = new Set();
340
+ this.taintedFunctions = new Set();
341
+ this.solve();
342
+ }
343
+
344
+ storageKeyIsToken(arg) {
345
+ const node = unwrap(arg);
346
+ if (!node) return false;
347
+ let text = stringText(node);
348
+ if (text === null && ts.isIdentifier(node)) {
349
+ const value = this.scopes.constValue(node);
350
+ text = value ? stringText(value) : null;
351
+ if (text === null) return TOKENISH_KEY.test(node.text);
352
+ }
353
+ return text !== null && TOKENISH_KEY.test(text);
354
+ }
355
+
356
+ isSource(node) {
357
+ if (ts.isCallExpression(node)) {
358
+ const name = calleeName(node);
359
+ if (name === "getAuthToken") return true;
360
+ if (name === "getItem" && /(^|\.)(localStorage|sessionStorage)$/.test(receiverText(node, this.sourceFile))) {
361
+ return this.storageKeyIsToken(node.arguments[0]);
362
+ }
363
+ return false;
364
+ }
365
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "cookie" && /(^|\.)document$/.test(node.expression.getText(this.sourceFile))) {
366
+ const parent = node.parent;
367
+ const assigned = parent && ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
368
+ return !assigned;
369
+ }
370
+ return false;
371
+ }
372
+
373
+ functionOf(decl) {
374
+ if (!decl) return null;
375
+ if (ts.isFunctionDeclaration(decl)) return decl;
376
+ if (ts.isVariableDeclaration(decl) && isFunctionLike(unwrap(decl.initializer))) return unwrap(decl.initializer);
377
+ return null;
378
+ }
379
+
380
+ carries(node, depth = 0) {
381
+ if (!node || depth > 40) return false;
382
+ const next = (child) => this.carries(child, depth + 1);
383
+ if (
384
+ ts.isParenthesizedExpression(node) ||
385
+ ts.isAsExpression(node) ||
386
+ ts.isNonNullExpression(node) ||
387
+ ts.isTypeAssertionExpression(node) ||
388
+ ts.isSatisfiesExpression(node) ||
389
+ ts.isAwaitExpression(node) ||
390
+ ts.isSpreadElement(node) ||
391
+ ts.isSpreadAssignment(node)
392
+ ) {
393
+ return next(node.expression);
394
+ }
395
+ if (this.isSource(node)) return true;
396
+ if (ts.isIdentifier(node)) {
397
+ const decl = this.scopes.resolve(node);
398
+ return Boolean(decl && this.tainted.has(decl));
399
+ }
400
+ if (ts.isTemplateExpression(node)) return node.templateSpans.some((span) => next(span.expression));
401
+ if (ts.isBinaryExpression(node)) {
402
+ const op = node.operatorToken.kind;
403
+ if (op === ts.SyntaxKind.PlusToken || op === ts.SyntaxKind.QuestionQuestionToken || op === ts.SyntaxKind.BarBarToken) {
404
+ return next(node.left) || next(node.right);
405
+ }
406
+ if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.CommaToken || op === ts.SyntaxKind.EqualsToken) {
407
+ return next(node.right);
408
+ }
409
+ return false;
410
+ }
411
+ if (ts.isConditionalExpression(node)) return next(node.whenTrue) || next(node.whenFalse);
412
+ if (ts.isObjectLiteralExpression(node)) {
413
+ return node.properties.some((prop) => {
414
+ if (ts.isPropertyAssignment(prop)) return next(prop.initializer);
415
+ if (ts.isShorthandPropertyAssignment(prop)) return next(prop.name);
416
+ if (ts.isSpreadAssignment(prop)) return next(prop.expression);
417
+ return false;
418
+ });
419
+ }
420
+ if (ts.isArrayLiteralExpression(node)) return node.elements.some(next);
421
+ if (ts.isPropertyAccessExpression(node)) return node.name.text !== "length" && next(node.expression);
422
+ if (ts.isElementAccessExpression(node)) return next(node.expression);
423
+ if (ts.isCallExpression(node)) {
424
+ const callee = unwrap(node.expression);
425
+ if (ts.isIdentifier(callee)) {
426
+ if (PASS_THROUGH_CALLS.has(callee.text)) return node.arguments.some(next);
427
+ const fn = this.functionOf(this.scopes.resolve(callee));
428
+ return Boolean(fn && this.taintedFunctions.has(fn));
429
+ }
430
+ if (ts.isPropertyAccessExpression(callee)) {
431
+ const owner = callee.expression.getText(this.sourceFile);
432
+ if (owner === "JSON" || (owner === "Object" && ["assign", "entries", "values", "fromEntries"].includes(callee.name.text))) {
433
+ return node.arguments.some(next);
434
+ }
435
+ return !NON_CARRYING_METHODS.has(callee.name.text) && next(callee.expression);
436
+ }
437
+ return false;
438
+ }
439
+ if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && PASS_THROUGH_CONSTRUCTORS.has(node.expression.text)) {
440
+ return (node.arguments ?? []).some(next);
441
+ }
442
+ return false;
443
+ }
444
+
445
+ solve() {
446
+ const decls = [];
447
+ const functions = [];
448
+ const assignments = [];
449
+ const walk = (node) => {
450
+ if (ts.isVariableDeclaration(node) && node.initializer) decls.push(node);
451
+ if (isFunctionLike(node)) functions.push(node);
452
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
453
+ assignments.push(node);
454
+ }
455
+ ts.forEachChild(node, walk);
456
+ };
457
+ walk(this.sourceFile);
458
+
459
+ const bindingNodes = (decl) => {
460
+ if (ts.isIdentifier(decl.name)) return [decl];
461
+ const out = [];
462
+ const visit = (pattern) => {
463
+ for (const element of pattern.elements) {
464
+ if (!ts.isBindingElement(element)) continue;
465
+ if (ts.isIdentifier(element.name)) out.push(element);
466
+ else visit(element.name);
467
+ }
468
+ };
469
+ visit(decl.name);
470
+ return out;
471
+ };
472
+
473
+ for (let round = 0; round < 12; round += 1) {
474
+ let changed = false;
475
+ for (const decl of decls) {
476
+ const targets = bindingNodes(decl).filter((target) => !this.tainted.has(target));
477
+ if (targets.length && this.carries(decl.initializer)) {
478
+ for (const target of targets) this.tainted.add(target);
479
+ changed = true;
480
+ }
481
+ }
482
+ for (const assignment of assignments) {
483
+ const decl = this.scopes.resolve(assignment.left);
484
+ if (decl && !this.tainted.has(decl) && this.carries(assignment.right)) {
485
+ this.tainted.add(decl);
486
+ changed = true;
487
+ }
488
+ }
489
+ for (const fn of functions) {
490
+ if (this.taintedFunctions.has(fn)) continue;
491
+ let returns = false;
492
+ forEachReturn(fn, (expression) => {
493
+ if (!returns && this.carries(expression)) returns = true;
494
+ });
495
+ if (returns) {
496
+ this.taintedFunctions.add(fn);
497
+ changed = true;
498
+ }
499
+ }
500
+ if (!changed) break;
501
+ }
502
+ }
503
+ }
504
+
505
+ const TOKEN_FIXES = {
506
+ agent:
507
+ "Return only the facts the answer needs. The token stays in the page: apiFetch adds it to the product's own calls for you.",
508
+ log: "Leave the token out of the log, or log redact(token) - it shows only the length.",
509
+ stored:
510
+ "Read it where the app keeps it, each time, with getAuthToken. Never keep a second copy - in storage, a cookie or on window.",
511
+ sent:
512
+ "Send it only to the product's own backend - call apiFetch with a path, and it adds the token for you.",
513
+ };
514
+
515
+ export function ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes = new Scopes(sourceFile)) {
516
+ const flow = new TokenFlow(sourceFile, scopes);
517
+ const report = (node, message, fix) =>
518
+ findings.push(finding({ rule: "token-stays-home", severity: "error", sourceFile, node, file: relative, message, fix }));
519
+
520
+ // Returned to the agent: what execute and a card's callback return, and what
521
+ // render hands to callback or readyState.
522
+ forEachNamedFunction(sourceFile, ["execute", "callback"], (fn, name) => {
523
+ forEachReturn(fn, (expression) => {
524
+ if (flow.carries(expression)) {
525
+ report(expression, `${name} returns the user's sign-in token. What ${name} returns reaches the agent and the model.`, TOKEN_FIXES.agent);
526
+ }
527
+ });
528
+ });
529
+ forEachNamedFunction(sourceFile, ["render"], (fn) => {
530
+ const params = fn.parameters.filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
531
+ const names = [params[3], params[5]].filter((param) => param && ts.isIdentifier(param.name)).map((param) => param.name.text);
532
+ const walk = (node) => {
533
+ if (ts.isCallExpression(node) && ts.isIdentifier(unwrap(node.expression)) && names.includes(unwrap(node.expression).text)) {
534
+ if (node.arguments.some((arg) => flow.carries(arg))) {
535
+ report(node, `The card hands the user's sign-in token to ${unwrap(node.expression).text}, which reaches the agent and the model.`, TOKEN_FIXES.agent);
536
+ }
537
+ }
538
+ ts.forEachChild(node, walk);
539
+ };
540
+ if (fn.body) walk(fn.body);
541
+ });
542
+
543
+ const walk = (node) => {
544
+ if (ts.isCallExpression(node)) {
545
+ const name = calleeName(node);
546
+ const receiver = receiverText(node, sourceFile);
547
+ const carried = () => node.arguments.some((arg) => flow.carries(arg));
548
+ if (/^console$/.test(receiver) && carried()) {
549
+ report(node, `console.${name} writes the user's sign-in token to the browser console, where anyone at the screen or any script on the page can read it.`, TOKEN_FIXES.log);
550
+ } else if (name === "setItem" && /(^|\.)(localStorage|sessionStorage)$/.test(receiver) && node.arguments[1] && flow.carries(node.arguments[1])) {
551
+ report(node, `The user's sign-in token is stored again (${receiver}.setItem).`, TOKEN_FIXES.stored);
552
+ } else if (name && AGENT_METHODS.has(name) && /foldspace|agent/i.test(receiver) && carried()) {
553
+ report(node, `The user's sign-in token is handed to Foldspace (${receiver}.${name}), which sends it to the agent.`, TOKEN_FIXES.agent);
554
+ } else if (ts.isIdentifier(unwrap(node.expression)) && name === "foldspace" && carried()) {
555
+ report(node, "The user's sign-in token is handed to Foldspace, which sends it to the agent.", TOKEN_FIXES.agent);
556
+ }
557
+ }
558
+ const call = networkCall(node, sourceFile);
559
+ if (call && call.payload.some((arg) => arg && flow.carries(arg))) {
560
+ const where = destination(call.url, scopes);
561
+ if (where.kind === "host" && !isProductHost(where.host, facts)) {
562
+ report(node, `The user's sign-in token is sent to ${where.host}, which is not the product's backend.`, TOKEN_FIXES.sent);
563
+ }
564
+ }
565
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && flow.carries(node.right)) {
566
+ const left = unwrap(node.left);
567
+ let root = left;
568
+ while (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) root = unwrap(root.expression);
569
+ const isCookie =
570
+ ts.isPropertyAccessExpression(left) && left.name.text === "cookie" && /(^|\.)document$/.test(unwrap(left.expression).getText(sourceFile));
571
+ if (isCookie) {
572
+ report(node, "The user's sign-in token is stored again, in a cookie.", TOKEN_FIXES.stored);
573
+ } else if (root !== left && ts.isIdentifier(root) && ["window", "globalThis", "self"].includes(root.text)) {
574
+ report(node, `The user's sign-in token is stored again, on ${root.text}, where every script on the page can read it.`, TOKEN_FIXES.stored);
575
+ }
576
+ }
577
+ ts.forEachChild(node, walk);
578
+ };
579
+ walk(sourceFile);
580
+ }
581
+
582
+ // ---------------------------------------------------------------- one customer's address
583
+
584
+ export function ruleNoFixedCustomerAddress(sourceFile, relative, findings, facts, { isConstants = false } = {}) {
585
+ const label = customerLabel(facts.appHost, facts.perCustomerSubdomain);
586
+ if (!label) return;
587
+ const decided = facts.perCustomerSubdomain === true;
588
+ const sameProduct = (host) => isProductHost(host, facts);
589
+
590
+ if (isConstants && facts.apiBaseNode) {
591
+ const value = facts.apiBase;
592
+ const host = absoluteHost(value);
593
+ if (host && !host.startsWith("*.") && sameProduct(host) && addressNamesLabel(value, label)) {
594
+ const suggestion = suggestedApiBase(value, facts.appHost, label);
595
+ findings.push(
596
+ finding({
597
+ rule: "no-fixed-customer-address",
598
+ severity: "error",
599
+ sourceFile,
600
+ node: facts.apiBaseNode,
601
+ file: relative,
602
+ message: decided
603
+ ? `API_BASE is one customer's address (${value}). Each customer has their own subdomain, so every user's agent would call this customer's data.`
604
+ : `API_BASE (${value}) carries "${label}", which may be one customer's own subdomain. If each customer has their own, every user's agent would call this customer's data.`,
605
+ fix: decided
606
+ ? `Write it from the page's address: API_BASE = "${suggestion}". ${suggestion.startsWith("/") ? "Each user's page then calls its own host." : "The runtime fills in each user's own subdomain."}`
607
+ : `Ask the human, as a choice, whether each of their customers gets their own address like ${facts.appHost}. Yes: set PER_CUSTOMER_SUBDOMAIN = true and API_BASE = "${suggestion}". No: set PER_CUSTOMER_SUBDOMAIN = false.`,
608
+ }),
609
+ );
610
+ }
611
+ }
612
+
613
+ walkStrings(sourceFile, (node, text) => {
614
+ if (isConstants && node === facts.apiBaseNode) return;
615
+ const host = absoluteHost(text);
616
+ if (!host || host.startsWith("*.") || !sameProduct(host) || !addressNamesLabel(text, label)) return;
617
+ const shown = text.length > 80 ? `${text.slice(0, 77)}...` : text;
618
+ findings.push(
619
+ finding({
620
+ rule: "no-fixed-customer-address",
621
+ severity: "error",
622
+ sourceFile,
623
+ node,
624
+ file: relative,
625
+ message: decided
626
+ ? `'${shown}' is one customer's address, written into the code. On every other customer's page it would reach this customer.`
627
+ : `'${shown}' carries "${label}", which may be one customer's own subdomain, written into the code. If each customer has their own, every other customer's page would reach this one.`,
628
+ fix: `Use a path alone: apiFetch("/…") for a call (the address comes from API_BASE), or "/…" for a link on the page's own host.${decided ? "" : ` If ${label} is not a customer, set PER_CUSTOMER_SUBDOMAIN = false in agent/constants.ts - with the human's answer.`}`,
629
+ }),
630
+ );
631
+ });
632
+ }
633
+
634
+ // ---------------------------------------------------------------- known hosts
635
+
636
+ export function ruleKnownHostsOnly(sourceFile, relative, findings, facts, scopes = new Scopes(sourceFile)) {
637
+ const walk = (node) => {
638
+ const call = networkCall(node, sourceFile);
639
+ if (call && call.url) {
640
+ const where = destination(call.url, scopes);
641
+ if (where.kind === "host" && !isKnownHost(where.host, facts)) {
642
+ findings.push(
643
+ finding({
644
+ rule: "known-hosts-only",
645
+ severity: "warning",
646
+ sourceFile,
647
+ node: call.url,
648
+ file: relative,
649
+ message: `${call.what} goes to ${where.host}, which is not the product's own backend or a host this project names. The user's signed-in page would send it there.`,
650
+ fix: `Call the product's own backend with apiFetch. If ${where.host} really is needed, ask the human, then name it as a constant in agent/constants.ts and call it through that constant.`,
651
+ }),
652
+ );
653
+ }
654
+ }
655
+ ts.forEachChild(node, walk);
656
+ };
657
+ walk(sourceFile);
658
+ }
659
+
660
+ // ---------------------------------------------------------------- secrets in the project
661
+
662
+ const SECRET_PATTERNS = [
663
+ { what: "a JWT (a signed-in session token)", re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
664
+ { what: "a private key", re: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/g },
665
+ { what: "an AWS access key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
666
+ { what: "a Stripe key", re: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}/g },
667
+ { what: "a GitHub token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g },
668
+ { what: "a Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
669
+ { what: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
670
+ { what: "an API key", re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}/g },
671
+ { what: "a bearer token", re: /\bBearer\s+(?!\$\{)[A-Za-z0-9._~+/-]{20,}=*/g },
672
+ { what: "a cookie value", re: /\b(?:Set-)?Cookie\b["']?\s*[:=]\s*["'`]?[A-Za-z0-9_.-]+=[^;\s"'`<>]{16,}/gi },
673
+ {
674
+ what: "a secret",
675
+ re: /\b(?:api[_-]?key|api[_-]?secret|secret[_-]?key|client[_-]?secret|password|passwd|private[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|session[_-]?token|id[_-]?token)["']?\s*[:=]\s*["'`]([^"'`\s]{16,})["'`]/gi,
676
+ value: 1,
677
+ },
678
+ ];
679
+
680
+ const PLACEHOLDER = /example|xxxx|\.\.\.|<|>|your[_-]|placeholder|changeme|redacted|dummy|sample|__observe|\$\{|\{\{/i;
681
+
682
+ function looksReal(value) {
683
+ return !PLACEHOLDER.test(value) && /[A-Za-z]/.test(value) && /\d/.test(value);
684
+ }
685
+
686
+ const CAPTURE_FILE = /(?:\.har|\.cookies?|^cookies?\.(?:json|txt)|^storage[-_.]?state\.json|^\.env(?:\.(?!example$|sample$|template$)[\w.-]+)?)$/i;
687
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".foldspace-dev", "video", ".claude"]);
688
+ const TEXT_FILE = /\.(?:[cm]?[jt]sx?|json|md|txt|html?|css|ya?ml|env|har)$/i;
689
+
690
+ function projectFiles(projectDir) {
691
+ const out = [];
692
+ const walk = (dir, depth) => {
693
+ let entries;
694
+ try {
695
+ entries = fs.readdirSync(dir, { withFileTypes: true });
696
+ } catch {
697
+ return;
698
+ }
699
+ for (const entry of entries) {
700
+ const full = path.join(dir, entry.name);
701
+ if (entry.isDirectory()) {
702
+ if (SKIP_DIRS.has(entry.name) || depth > 8) continue;
703
+ // The harness's own runtime copy is the harness's, not the project's.
704
+ if (path.relative(projectDir, full).split(path.sep).join("/") === "agent/runtime") continue;
705
+ walk(full, depth + 1);
706
+ } else if (entry.isFile()) out.push(full);
707
+ }
708
+ };
709
+ walk(projectDir, 0);
710
+ return out;
711
+ }
712
+
713
+ export function ruleNoSecrets(projectDir, findings) {
714
+ for (const file of projectFiles(projectDir)) {
715
+ const relative = path.relative(projectDir, file);
716
+ const base = path.basename(file);
717
+ if (base === "package-lock.json") continue;
718
+ if (CAPTURE_FILE.test(base)) {
719
+ findings.push({
720
+ rule: "no-secrets",
721
+ severity: "error",
722
+ file: relative,
723
+ line: 1,
724
+ column: 1,
725
+ message: `${relative} is a capture of browser traffic or storage. It holds live sign-ins and the customer's data, and anything in this folder can be saved and shared.`,
726
+ fix: "Delete it. What the build needs from a capture - the path, the field names, where the token lives - goes in docs/app-profile.md, as names, never values.",
727
+ });
728
+ continue;
729
+ }
730
+ if (!TEXT_FILE.test(base)) continue;
731
+ let text;
732
+ try {
733
+ const stat = fs.statSync(file);
734
+ if (stat.size > 2_000_000) continue;
735
+ text = fs.readFileSync(file, "utf8");
736
+ } catch {
737
+ continue;
738
+ }
739
+ const lineStarts = [0];
740
+ for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") lineStarts.push(i + 1);
741
+ const position = (index) => {
742
+ let low = 0;
743
+ let high = lineStarts.length - 1;
744
+ while (low < high) {
745
+ const mid = (low + high + 1) >> 1;
746
+ if (lineStarts[mid] <= index) low = mid;
747
+ else high = mid - 1;
748
+ }
749
+ return { line: low + 1, column: index - lineStarts[low] + 1 };
750
+ };
751
+ const seen = new Set();
752
+ for (const pattern of SECRET_PATTERNS) {
753
+ pattern.re.lastIndex = 0;
754
+ for (const match of text.matchAll(pattern.re)) {
755
+ const value = pattern.value ? match[pattern.value] : match[0];
756
+ if (!looksReal(value)) continue;
757
+ const at = position(match.index);
758
+ const key = `${at.line}`;
759
+ if (seen.has(key)) continue;
760
+ seen.add(key);
761
+ findings.push({
762
+ rule: "no-secrets",
763
+ severity: "error",
764
+ file: relative,
765
+ ...at,
766
+ message: `This line holds what looks like ${pattern.what}. Anything in this folder can be saved, shared and shipped in the bundle.`,
767
+ fix: "Delete the value. The agent reads the user's sign-in from the page at run time (getAuthToken); nothing secret is ever written into the project. A sample stays a placeholder such as \"<token>\".",
768
+ });
769
+ }
770
+ }
771
+ }
772
+ }
773
+
774
+ // ---------------------------------------------------------------- entry
775
+
776
+ /** Every source rule here, for one file under agent/ (not agent/runtime/). */
777
+ export function lintSafetyFile(filePath, relative, findings, facts) {
778
+ const sourceFile = parseFile(filePath);
779
+ const scopes = new Scopes(sourceFile);
780
+ const isConstants = facts.constantsFile !== null && relative === facts.constantsFile;
781
+ ruleNoFixedCustomerAddress(isConstants ? facts.constantsSource : sourceFile, relative, findings, facts, { isConstants });
782
+ ruleTokenStaysHome(sourceFile, relative, findings, facts, scopes);
783
+ ruleKnownHostsOnly(sourceFile, relative, findings, facts, scopes);
784
+ }
785
+