kryptheon 0.1.22 → 0.1.24
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/bin/kryptheon.js +3 -1
- package/kryptheon-code.js +221 -14
- package/kryptheon-dashboard-page.js +95 -0
- package/kryptheon-dashboard.html +37 -0
- package/kryptheon-dashboard.js +14 -3
- package/kryptheon-project.js +23 -0
- package/package.json +1 -1
package/bin/kryptheon.js
CHANGED
|
@@ -2019,7 +2019,7 @@ function readCodeAndSave(opened) {
|
|
|
2019
2019
|
}
|
|
2020
2020
|
|
|
2021
2021
|
function codeRead() {
|
|
2022
|
-
const here = project.
|
|
2022
|
+
const here = project.inspectCodeFolder(USER_DIR);
|
|
2023
2023
|
if (!here.ok) {
|
|
2024
2024
|
for (const line of project.noProjectLines(here)) console.error(line);
|
|
2025
2025
|
return 1;
|
|
@@ -2053,6 +2053,8 @@ function codeRead() {
|
|
|
2053
2053
|
console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
|
|
2054
2054
|
console.log(' ' + f.headline);
|
|
2055
2055
|
console.log(' ' + f.sink + ': ' + f.expression);
|
|
2056
|
+
const parts = (f.parts || []).filter((p) => p.text !== f.expression);
|
|
2057
|
+
if (parts.length) console.log(' the risky part' + (parts.length === 1 ? ': ' : 's: ') + parts.map((p) => p.text).join(', '));
|
|
2056
2058
|
}
|
|
2057
2059
|
const prompted = high.slice(0, CODE_PROMPTS_SHOWN);
|
|
2058
2060
|
if (prompted.length) {
|
package/kryptheon-code.js
CHANGED
|
@@ -149,10 +149,10 @@ function classify(node, scope, code, depth, aliases) {
|
|
|
149
149
|
case 'NullLiteral':
|
|
150
150
|
return 'safe';
|
|
151
151
|
case 'TemplateLiteral':
|
|
152
|
-
return worst(node.
|
|
152
|
+
return worst(judgedParts(node, scope, code).map((e) => again(e)));
|
|
153
153
|
case 'BinaryExpression':
|
|
154
154
|
if (node.operator !== '+') return 'safe'; // - * / % comparisons: never text
|
|
155
|
-
return worst(
|
|
155
|
+
return worst(judgedParts(node, scope, code).map((e) => again(e)));
|
|
156
156
|
case 'UnaryExpression':
|
|
157
157
|
case 'UpdateExpression':
|
|
158
158
|
return 'safe';
|
|
@@ -168,16 +168,16 @@ function classify(node, scope, code, depth, aliases) {
|
|
|
168
168
|
if (NUMERIC_CALLS.test(name)) return 'safe';
|
|
169
169
|
if (node.callee.type === 'MemberExpression' && node.callee.object && node.callee.object.name === 'Math') return 'safe';
|
|
170
170
|
// items.map(i => `<li>${i.name}</li>`).join('') - judge what map builds.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const item = fn && fn.params && fn.params[0] && fn.params[0].type === 'Identifier' ? fn.params[0].name : null;
|
|
177
|
-
const inner = Object.assign({}, aliases || {});
|
|
178
|
-
if (item) inner[item] = listOrigin;
|
|
179
|
-
return classify(returnedBy(fn), scope, code, depth + 1, inner);
|
|
171
|
+
const mapped = mapCallback(node, scope, code, depth, aliases);
|
|
172
|
+
if (mapped) {
|
|
173
|
+
return mapped.returns.length
|
|
174
|
+
? worst(mapped.returns.map((r) => classify(r.node, r.scope, code, depth + 1, mapped.inner)))
|
|
175
|
+
: 'unknown';
|
|
180
176
|
}
|
|
177
|
+
// An escaping helper of this file's own, known by what it does. Inside a
|
|
178
|
+
// template, one that leaves quotes alone does not protect a value that
|
|
179
|
+
// lands in an attribute - the template judges that case itself.
|
|
180
|
+
if (escaperOf(node, scope, code)) return 'safe';
|
|
181
181
|
// A function of this file's own: as dangerous as what it returns. One
|
|
182
182
|
// that only ever returns constants - a lookup of labels, icons and
|
|
183
183
|
// colours - cannot carry anything in; one that returns a parameter
|
|
@@ -264,6 +264,154 @@ function returnsOf(fnPath) {
|
|
|
264
264
|
return found;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Every function in the piece being read, by node, so a callback handed to
|
|
268
|
+
// map() can be judged in its own scope - where its own `const color = ...`
|
|
269
|
+
// lives - instead of the caller's, where that name means nothing.
|
|
270
|
+
let FN_PATHS = new WeakMap();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* list.map(cb).join(''): the callback's returns, each with its own scope, and
|
|
274
|
+
* what the callback's names stand for - the item is whatever the list holds,
|
|
275
|
+
* the index is a number. Null when node is not that shape.
|
|
276
|
+
*/
|
|
277
|
+
function mapCallback(node, scope, code, depth, aliases) {
|
|
278
|
+
if (calleeName(node.callee) !== 'join' || !node.callee.object || !/CallExpression$/.test(node.callee.object.type) ||
|
|
279
|
+
calleeName(node.callee.object.callee) !== 'map') {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const mapCall = node.callee.object;
|
|
283
|
+
const fn = mapCall.arguments[0];
|
|
284
|
+
const listOrigin = classify(mapCall.callee.object, scope, code, depth + 1, aliases);
|
|
285
|
+
const inner = Object.assign({}, aliases || {});
|
|
286
|
+
const params = (fn && fn.params) || [];
|
|
287
|
+
if (params[0] && params[0].type === 'Identifier') inner[params[0].name] = listOrigin;
|
|
288
|
+
if (params[1] && params[1].type === 'Identifier') inner[params[1].name] = 'safe';
|
|
289
|
+
const fnPath = fn && FN_PATHS.get(fn);
|
|
290
|
+
const returns = fnPath
|
|
291
|
+
? returnsOf(fnPath)
|
|
292
|
+
: (returnedBy(fn) ? [{ node: returnedBy(fn), scope: scope }] : []);
|
|
293
|
+
return { returns: returns, inner: inner };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether a call escapes HTML - by name for the well-known ones, and for a
|
|
298
|
+
* helper of this file's own, by what it does: every return is a replace()
|
|
299
|
+
* chain, its patterns cover & < and >, and it writes & < >. The
|
|
300
|
+
* name decides nothing - `esc` and `clean3` count, and an "escape" that only
|
|
301
|
+
* handles "<", or escapes and then returns the original, does not.
|
|
302
|
+
* Returns { dq, sq }: whether " and ' are escaped too. Null when it does not escape.
|
|
303
|
+
*/
|
|
304
|
+
function escaperOf(call, scope, code) {
|
|
305
|
+
if (!call || !/CallExpression$/.test(call.type)) return null;
|
|
306
|
+
const name = calleeName(call.callee);
|
|
307
|
+
if (MAKES_SAFE.test(name)) return { dq: true, sq: true };
|
|
308
|
+
if (call.callee.type !== 'Identifier') return null;
|
|
309
|
+
const fnPath = localFunction(call.callee.name, scope);
|
|
310
|
+
if (!fnPath) return null;
|
|
311
|
+
const returns = returnsOf(fnPath);
|
|
312
|
+
if (!returns.length || !returns.every((r) => /CallExpression$/.test(r.node.type) &&
|
|
313
|
+
/^(replace|replaceAll)$/.test(calleeName(r.node.callee)))) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
let patterns = '';
|
|
317
|
+
const written = [];
|
|
318
|
+
fnPath.traverse({
|
|
319
|
+
RegExpLiteral(p) {
|
|
320
|
+
patterns += p.node.pattern;
|
|
321
|
+
},
|
|
322
|
+
StringLiteral(p) {
|
|
323
|
+
written.push(p.node.value);
|
|
324
|
+
const parent = p.parent;
|
|
325
|
+
if (parent && /CallExpression$/.test(parent.type) && parent.arguments[0] === p.node &&
|
|
326
|
+
/^(replace|replaceAll)$/.test(calleeName(parent.callee))) {
|
|
327
|
+
patterns += p.node.value;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
const writes = (entity) => written.some((s) => s.indexOf(entity) !== -1);
|
|
332
|
+
if (!/&/.test(patterns) || !/</.test(patterns) || !/>/.test(patterns)) return null;
|
|
333
|
+
if (!writes('&') || !writes('<') || !writes('>')) return null;
|
|
334
|
+
return {
|
|
335
|
+
dq: /"/.test(patterns) && (writes('"') || writes('"') || writes('"')),
|
|
336
|
+
sq: /'/.test(patterns) && (writes(''') || writes(''') || writes(''')),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The quote an HTML attribute value is open in at the end of this text: '"',
|
|
342
|
+
* "'", 'bare' for an unquoted one, '' when the text is not inside a tag at all.
|
|
343
|
+
* Only inside a tag - after a "<" that no ">" has closed - is an "=" an
|
|
344
|
+
* attribute; the "=" of "/go?next=" in a URL string is not one.
|
|
345
|
+
*/
|
|
346
|
+
function openAttribute(text) {
|
|
347
|
+
const lt = text.lastIndexOf('<');
|
|
348
|
+
if (lt === -1 || text.lastIndexOf('>') > lt) return '';
|
|
349
|
+
const tag = text.slice(lt);
|
|
350
|
+
const quoted = /=\s*(["'])[^"']*$/.exec(tag);
|
|
351
|
+
if (quoted) return quoted[1];
|
|
352
|
+
return /\s[^\s"'<>\/=]+\s*=\s*$/.test(tag) ? 'bare' : '';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The pieces of a template or a + concatenation to judge one by one. An
|
|
357
|
+
* escaped value is judged as its escaped input when the escaping is not
|
|
358
|
+
* enough for where it lands: inside a "..." attribute that needs " escaped,
|
|
359
|
+
* inside '...' that needs ' escaped, and an unquoted attribute is broken by a
|
|
360
|
+
* space no escaper touches.
|
|
361
|
+
*/
|
|
362
|
+
function judgedParts(node, scope, code) {
|
|
363
|
+
const pairs = [];
|
|
364
|
+
if (node.type === 'TemplateLiteral') {
|
|
365
|
+
// Everything written before this value, so an attribute opened in an
|
|
366
|
+
// earlier piece - `<a title="${a}" href="${b}"` - is still seen as open.
|
|
367
|
+
let before = '';
|
|
368
|
+
node.expressions.forEach((e, i) => {
|
|
369
|
+
before += node.quasis[i].value.raw;
|
|
370
|
+
pairs.push({ before: before, value: e });
|
|
371
|
+
before += 'x';
|
|
372
|
+
});
|
|
373
|
+
} else {
|
|
374
|
+
const text = (n) => (n.type === 'StringLiteral' ? n.value
|
|
375
|
+
: n.type === 'TemplateLiteral' ? n.quasis[n.quasis.length - 1].value.raw : '');
|
|
376
|
+
pairs.push({ before: '', value: node.left });
|
|
377
|
+
pairs.push({ before: text(node.left), value: node.right });
|
|
378
|
+
}
|
|
379
|
+
return pairs.map(({ before, value }) => {
|
|
380
|
+
const esc = escaperOf(value, scope, code);
|
|
381
|
+
if (!esc) return value;
|
|
382
|
+
const quote = openAttribute(before);
|
|
383
|
+
const enough = quote === '' || (quote === '"' && esc.dq) || (quote === "'" && esc.sq);
|
|
384
|
+
return enough ? { type: 'StringLiteral' } : (value.arguments[0] || value);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Which parts of a value make it unsafe, in the words of the code - so a fix
|
|
390
|
+
* prompt can say "only ${log.caller_number}" instead of quoting sixty lines of
|
|
391
|
+
* template and leaving the reader to find it. At most five, never repeated.
|
|
392
|
+
*/
|
|
393
|
+
function riskyParts(node, scope, code, aliases, out, depth) {
|
|
394
|
+
if (!node || out.length >= 5 || depth > MAX_DEPTH) return;
|
|
395
|
+
const origin = classify(node, scope, code, 0, aliases);
|
|
396
|
+
if (origin === 'safe') return;
|
|
397
|
+
const go = (n, s, a) => riskyParts(n, s || scope, code, a || aliases, out, depth + 1);
|
|
398
|
+
if (node.type === 'TemplateLiteral' || (node.type === 'BinaryExpression' && node.operator === '+')) {
|
|
399
|
+
judgedParts(node, scope, code).forEach((p) => go(p));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (node.type === 'ConditionalExpression') { go(node.consequent); go(node.alternate); return; }
|
|
403
|
+
if (node.type === 'LogicalExpression') { go(node.left); go(node.right); return; }
|
|
404
|
+
if (/CallExpression$/.test(node.type)) {
|
|
405
|
+
const mapped = mapCallback(node, scope, code, depth, aliases);
|
|
406
|
+
if (mapped && mapped.returns.length) {
|
|
407
|
+
mapped.returns.forEach((r) => go(r.node, r.scope, mapped.inner));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const text = code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 80);
|
|
412
|
+
if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin });
|
|
413
|
+
}
|
|
414
|
+
|
|
267
415
|
function rootIdentifier(node) {
|
|
268
416
|
let n = node;
|
|
269
417
|
while (n && /MemberExpression$/.test(n.type)) n = n.object;
|
|
@@ -284,13 +432,50 @@ function fromBinding(id, scope, code, depth, aliases) {
|
|
|
284
432
|
const binding = scope.getBinding(id.name);
|
|
285
433
|
if (!binding) return 'unknown';
|
|
286
434
|
if (binding.path && binding.path.type === 'CatchClause') return 'error';
|
|
435
|
+
if (binding.kind === 'param') return fromCalls(binding, code, depth);
|
|
287
436
|
const init = binding.path && binding.path.node && binding.path.node.init;
|
|
288
437
|
if (!init) return 'unknown';
|
|
289
438
|
if (readsResponseBody(init)) return 'network';
|
|
290
439
|
// Reassigned later: the first value says nothing reliable about the last.
|
|
291
440
|
if (binding.constantViolations && binding.constantViolations.length) return 'unknown';
|
|
292
441
|
// Destructured from a response body: const { name } = await res.json()
|
|
293
|
-
|
|
442
|
+
// Aliases carry through: inside a map() callback, `const t = r.title` is as
|
|
443
|
+
// dangerous as the item r it was read from.
|
|
444
|
+
return classify(init, binding.path.scope, code, depth + 1, aliases);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* A parameter is as dangerous as the worst thing any call passes it. Every
|
|
449
|
+
* use of the function has to be a call this file makes: one handed on as a
|
|
450
|
+
* value - a callback, an export, an event handler - is called from somewhere
|
|
451
|
+
* the code does not show, so its parameter stays unknown.
|
|
452
|
+
*/
|
|
453
|
+
function fromCalls(binding, code, depth) {
|
|
454
|
+
const fn = binding.path && binding.path.parentPath;
|
|
455
|
+
if (!fn || !/Function/.test(fn.node.type)) return 'unknown';
|
|
456
|
+
const index = fn.node.params.indexOf(binding.path.node);
|
|
457
|
+
if (index === -1) return 'unknown';
|
|
458
|
+
let name = fn.node.id && fn.node.id.name;
|
|
459
|
+
if (!name && fn.parentPath && fn.parentPath.node.type === 'VariableDeclarator' && fn.parentPath.node.id.type === 'Identifier') {
|
|
460
|
+
name = fn.parentPath.node.id.name;
|
|
461
|
+
}
|
|
462
|
+
const fnBinding = name && fn.parentPath && fn.parentPath.scope.getBinding(name);
|
|
463
|
+
if (!fnBinding || (fnBinding.constantViolations && fnBinding.constantViolations.length)) return 'unknown';
|
|
464
|
+
const refs = fnBinding.referencePaths || [];
|
|
465
|
+
if (!refs.length) return 'unknown';
|
|
466
|
+
const origins = [];
|
|
467
|
+
for (const ref of refs) {
|
|
468
|
+
const call = ref.parent;
|
|
469
|
+
if (!call || !/CallExpression$/.test(call.type) || call.callee !== ref.node) return 'unknown';
|
|
470
|
+
const arg = call.arguments[index];
|
|
471
|
+
if (!arg) {
|
|
472
|
+
origins.push('safe'); // not passed: undefined
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (call.arguments.slice(0, index + 1).some((a) => a.type === 'SpreadElement')) return 'unknown';
|
|
476
|
+
origins.push(classify(arg, ref.scope, code, depth + 1));
|
|
477
|
+
}
|
|
478
|
+
return worst(origins);
|
|
294
479
|
}
|
|
295
480
|
|
|
296
481
|
function readsResponseBody(node) {
|
|
@@ -342,6 +527,11 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
342
527
|
// each call actually hands it.
|
|
343
528
|
const calls = new Map();
|
|
344
529
|
const viaHelper = [];
|
|
530
|
+
const partsOf = (value, scope) => {
|
|
531
|
+
const out = [];
|
|
532
|
+
riskyParts(value, scope, code, undefined, out, 0);
|
|
533
|
+
return out;
|
|
534
|
+
};
|
|
345
535
|
const add = (node, scope, sink, value) => {
|
|
346
536
|
const param = paramOf(value, scope);
|
|
347
537
|
if (param) {
|
|
@@ -350,9 +540,13 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
350
540
|
}
|
|
351
541
|
const origin = classify(value, scope, code, 0);
|
|
352
542
|
if (origin === 'safe') return;
|
|
353
|
-
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin });
|
|
543
|
+
found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: partsOf(value, scope) });
|
|
354
544
|
};
|
|
545
|
+
FN_PATHS = new WeakMap();
|
|
355
546
|
parser.traverse(ast, {
|
|
547
|
+
Function(p) {
|
|
548
|
+
FN_PATHS.set(p.node, p);
|
|
549
|
+
},
|
|
356
550
|
CallExpression: {
|
|
357
551
|
exit(p) {
|
|
358
552
|
if (p.node.callee.type !== 'Identifier') return;
|
|
@@ -411,6 +605,7 @@ function sinksIn(parser, code, file, lineOffset) {
|
|
|
411
605
|
sink: h.sink,
|
|
412
606
|
expression: textOf(arg),
|
|
413
607
|
origin: origin,
|
|
608
|
+
parts: partsOf(arg, call.scope),
|
|
414
609
|
via: { fn: h.param.fn, line: lineOf(h.node) },
|
|
415
610
|
});
|
|
416
611
|
}
|
|
@@ -518,18 +713,29 @@ function describe(f) {
|
|
|
518
713
|
'HTML entities). If the HTML is meant to allow some formatting, pass it through a sanitizer ' +
|
|
519
714
|
'such as DOMPurify first.',
|
|
520
715
|
];
|
|
716
|
+
// A long template is mostly harmless markup. Naming the part that decides it
|
|
717
|
+
// keeps the fix - and the AI tool doing it - to that part alone.
|
|
718
|
+
const parts = (f.parts || []).filter((p) => p.text && p.text !== f.expression);
|
|
719
|
+
const only = parts.length
|
|
720
|
+
? [''].concat(
|
|
721
|
+
[(parts.length === 1 ? 'Only this part' : 'Only these parts') + ' of it can carry text from outside the page, ' +
|
|
722
|
+
'so only ' + (parts.length === 1 ? 'it needs' : 'they need') + ' changing:'],
|
|
723
|
+
parts.map((p) => ' ${' + p.text + '}'),
|
|
724
|
+
)
|
|
725
|
+
: [];
|
|
521
726
|
const fixPrompt = [
|
|
522
727
|
'My app may have a security problem - please check it rather than assume it.',
|
|
523
728
|
'',
|
|
524
729
|
where,
|
|
525
730
|
'',
|
|
526
731
|
' ' + f.expression,
|
|
732
|
+
].concat(only, [
|
|
527
733
|
'',
|
|
528
734
|
'The value is ' + ORIGIN_WORDS[f.origin] + '. If it can ever contain text a user or another ' +
|
|
529
735
|
'account typed, a "<" in it becomes a real tag and can run script in this page with the ' +
|
|
530
736
|
"signed-in person's session.",
|
|
531
737
|
'',
|
|
532
|
-
].concat(fix, [
|
|
738
|
+
]).concat(fix, [
|
|
533
739
|
'',
|
|
534
740
|
'Keep the page looking the same, change only how this value is inserted, and look for the ' +
|
|
535
741
|
'same pattern elsewhere in this file. Afterwards, open this page and check that ordinary ' +
|
|
@@ -546,6 +752,7 @@ function describe(f) {
|
|
|
546
752
|
sink: f.sink,
|
|
547
753
|
expression: f.expression,
|
|
548
754
|
origin: f.origin,
|
|
755
|
+
parts: (f.parts || []).map((p) => ({ text: p.text, origin: p.origin })),
|
|
549
756
|
via: f.via || null,
|
|
550
757
|
headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
|
|
551
758
|
' is inserted as HTML in ' + f.file + ':' + f.line +
|
|
@@ -279,6 +279,100 @@
|
|
|
279
279
|
});
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/* ------------------------------ connect your AI tool ------------------------------ */
|
|
283
|
+
|
|
284
|
+
// The same set-up kryptheon.tech shows, so the two never disagree. Every
|
|
285
|
+
// step comes from that tool's own documentation, except where a tool is
|
|
286
|
+
// marked tested - there it was run by hand. Static text: nothing here comes
|
|
287
|
+
// from the project being checked.
|
|
288
|
+
var SERVER = { command: 'npx', args: ['-y', 'kryptheon-mcp'] };
|
|
289
|
+
var MCP_JSON = JSON.stringify({ mcpServers: { kryptheon: SERVER } }, null, 2);
|
|
290
|
+
var VSCODE_JSON = JSON.stringify({ servers: { kryptheon: SERVER } }, null, 2);
|
|
291
|
+
var AI_TOOLS = [
|
|
292
|
+
{ id: 'claude-code', name: 'Claude Code', tested: true,
|
|
293
|
+
steps: ['Run this once, in any terminal. That is the whole set-up.'],
|
|
294
|
+
code: 'claude mcp add kryptheon -- npx -y kryptheon-mcp', file: 'terminal' },
|
|
295
|
+
{ id: 'cursor', name: 'Cursor', tested: false,
|
|
296
|
+
oneClick: { href: 'cursor://anysphere.cursor-deeplink/mcp/install?name=kryptheon&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImtyeXB0aGVvbi1tY3AiXX0=', label: 'Add to Cursor' },
|
|
297
|
+
steps: ['Press Add to Cursor. Cursor asks you to confirm - say yes.', 'Or by hand: paste the config into mcp.json in the .cursor folder of your home folder.'],
|
|
298
|
+
code: MCP_JSON, file: '~/.cursor/mcp.json' },
|
|
299
|
+
{ id: 'vscode', name: 'VS Code', tested: false,
|
|
300
|
+
oneClick: { href: 'vscode:mcp/install?' + encodeURIComponent(JSON.stringify({ name: 'kryptheon', command: 'npx', args: ['-y', 'kryptheon-mcp'] })), label: 'Install in VS Code' },
|
|
301
|
+
steps: ['Press Install in VS Code and confirm.', 'Or by hand: run MCP: Add Server from the Command Palette, or put the config in .vscode/mcp.json - VS Code calls the list "servers", not "mcpServers".'],
|
|
302
|
+
code: VSCODE_JSON, file: '.vscode/mcp.json' },
|
|
303
|
+
{ id: 'antigravity', name: 'Antigravity', tested: false,
|
|
304
|
+
steps: ['Open Settings, bottom left, then Customizations, then Add MCP. The MCP Store opens.', 'Click Manage MCP Servers, then View raw config.', 'Paste the config and save. Antigravity reloads it by itself.'],
|
|
305
|
+
code: MCP_JSON, file: '~/.gemini/config/mcp_config.json' },
|
|
306
|
+
{ id: 'windsurf', name: 'Windsurf', tested: false,
|
|
307
|
+
steps: ['In the Cascade panel, open the ... menu at the top right and click Open MCP config file.', 'Paste the config and save.'],
|
|
308
|
+
note: 'Windsurf is Devin Desktop now. Use the menu rather than hunting for the file: newer versions keep it in %APPDATA%\\devin, older ones in .codeium\\windsurf.',
|
|
309
|
+
code: MCP_JSON, file: 'mcp_config.json' },
|
|
310
|
+
{ id: 'claude-desktop', name: 'Claude Desktop', tested: false,
|
|
311
|
+
steps: ['From the Claude menu choose Settings..., then Developer, then Edit Config.', 'Paste the config and save.', 'Quit Claude completely - not just the window - and open it again.'],
|
|
312
|
+
code: MCP_JSON, file: 'claude_desktop_config.json' },
|
|
313
|
+
{ id: 'codex', name: 'Codex', tested: false,
|
|
314
|
+
steps: ['Run this once, in any terminal.'],
|
|
315
|
+
code: 'codex mcp add kryptheon -- npx -y kryptheon-mcp', file: 'terminal' },
|
|
316
|
+
];
|
|
317
|
+
var aiTool = 'claude-code';
|
|
318
|
+
|
|
319
|
+
function renderAI() {
|
|
320
|
+
var tabs = byId('aiTabs');
|
|
321
|
+
var panel = byId('aiPanel');
|
|
322
|
+
if (!tabs || !panel) return;
|
|
323
|
+
clear(tabs);
|
|
324
|
+
clear(panel);
|
|
325
|
+
AI_TOOLS.forEach(function (t, i) {
|
|
326
|
+
var b = el('button', 'ai-tab' + (t.id === aiTool ? ' on' : ''));
|
|
327
|
+
b.type = 'button';
|
|
328
|
+
b.setAttribute('role', 'tab');
|
|
329
|
+
b.setAttribute('aria-selected', t.id === aiTool ? 'true' : 'false');
|
|
330
|
+
b.appendChild(el('span', 'ai-num', String(i + 1).padStart(2, '0')));
|
|
331
|
+
b.appendChild(el('span', 'ai-name', t.name));
|
|
332
|
+
if (t.oneClick) b.appendChild(el('span', 'ai-tag', '1-click'));
|
|
333
|
+
b.addEventListener('click', function () { aiTool = t.id; renderAI(); });
|
|
334
|
+
tabs.appendChild(b);
|
|
335
|
+
});
|
|
336
|
+
var tool = AI_TOOLS.filter(function (t) { return t.id === aiTool; })[0] || AI_TOOLS[0];
|
|
337
|
+
panel.appendChild(el('span', 'ai-badge' + (tool.tested ? ' tested' : ''), tool.tested ? 'Tested by hand' : "From " + tool.name + "'s own docs - not tested by hand yet"));
|
|
338
|
+
panel.appendChild(el('h4', null, tool.name));
|
|
339
|
+
if (tool.oneClick) {
|
|
340
|
+
var oc = el('div', 'ai-oneclick');
|
|
341
|
+
var link = el('a', 'btn sun', tool.oneClick.label + ' →');
|
|
342
|
+
link.href = tool.oneClick.href;
|
|
343
|
+
oc.appendChild(link);
|
|
344
|
+
oc.appendChild(el('small', null, 'or by hand, below'));
|
|
345
|
+
panel.appendChild(oc);
|
|
346
|
+
}
|
|
347
|
+
var ol = el('ol', 'ai-steps');
|
|
348
|
+
tool.steps.forEach(function (s, i) {
|
|
349
|
+
var li = el('li');
|
|
350
|
+
li.appendChild(el('span', null, String(i + 1)));
|
|
351
|
+
li.appendChild(el('div', null, s));
|
|
352
|
+
ol.appendChild(li);
|
|
353
|
+
});
|
|
354
|
+
panel.appendChild(ol);
|
|
355
|
+
var plate = el('div', 'ai-plate');
|
|
356
|
+
var bar = el('div', 'ai-plate-bar');
|
|
357
|
+
bar.appendChild(el('span', null, tool.file));
|
|
358
|
+
var cp = el('button', null, 'Copy');
|
|
359
|
+
cp.type = 'button';
|
|
360
|
+
cp.addEventListener('click', function () { copy(tool.code, tool.file === 'terminal' ? 'Command copied - run it in a terminal' : 'Config copied - paste it into ' + tool.file); });
|
|
361
|
+
bar.appendChild(cp);
|
|
362
|
+
plate.appendChild(bar);
|
|
363
|
+
plate.appendChild(el('pre', null, tool.code));
|
|
364
|
+
panel.appendChild(plate);
|
|
365
|
+
if (tool.note) panel.appendChild(el('p', 'ai-note', tool.note));
|
|
366
|
+
if (tool.file !== 'terminal') {
|
|
367
|
+
panel.appendChild(el('p', 'ai-note', 'On Windows, if it says it cannot start npx: use "command": "cmd" and "args": ["/c", "npx", "-y", "kryptheon-mcp"] instead.'));
|
|
368
|
+
}
|
|
369
|
+
panel.appendChild(el('p', 'ai-note', 'To let it check your database too, add "env": { "KN_DATABASE_URL": "postgresql://..." } to the kryptheon entry. Never paste the connection string into the chat - that puts the password to your whole database in a transcript.'));
|
|
370
|
+
var then = el('p', 'ai-then');
|
|
371
|
+
then.appendChild(document.createTextNode('Then just ask your assistant: '));
|
|
372
|
+
then.appendChild(el('b', null, '“Check my app.”'));
|
|
373
|
+
panel.appendChild(then);
|
|
374
|
+
}
|
|
375
|
+
|
|
282
376
|
/* ---------------------------------- findings ---------------------------------- */
|
|
283
377
|
|
|
284
378
|
var statusFilter = 'all';
|
|
@@ -595,6 +689,7 @@
|
|
|
595
689
|
.catch(function (err) { byId('subline').textContent = 'Could not load: ' + err.message; });
|
|
596
690
|
}
|
|
597
691
|
|
|
692
|
+
renderAI();
|
|
598
693
|
showView();
|
|
599
694
|
load();
|
|
600
695
|
// Not while something runs, and not while a finding is open being read.
|
package/kryptheon-dashboard.html
CHANGED
|
@@ -157,6 +157,36 @@
|
|
|
157
157
|
.cmd-copy { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border: 1px solid var(--line-dark); color: var(--on-dark); transition: color 0.3s var(--ease), border-color 0.3s var(--ease); }
|
|
158
158
|
.cmd-copy:hover { color: var(--orange); border-color: var(--orange); }
|
|
159
159
|
|
|
160
|
+
/* --- connect your AI tool: an index of tools, one tool's page ------------ */
|
|
161
|
+
.ai { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 34px; padding-top: 18px; }
|
|
162
|
+
@media (max-width: 900px) { .ai { grid-template-columns: minmax(0, 1fr); gap: 18px; } }
|
|
163
|
+
.ai-tabs { display: flex; flex-direction: column; border-top: 1px solid var(--line); }
|
|
164
|
+
@media (max-width: 900px) { .ai-tabs { flex-direction: row; flex-wrap: wrap; } }
|
|
165
|
+
.ai-tab { position: relative; display: flex; align-items: baseline; gap: 10px; padding: 13px 8px 13px 14px; text-align: left; border-bottom: 1px solid var(--line); transition: color 0.3s var(--ease), padding 0.3s var(--ease); }
|
|
166
|
+
.ai-tab .ai-num { font: 10px var(--mono); color: var(--muted); }
|
|
167
|
+
.ai-tab .ai-name { font-weight: 800; font-stretch: 74%; text-transform: uppercase; }
|
|
168
|
+
.ai-tab .ai-tag { margin-left: auto; font: 9px var(--mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
|
|
169
|
+
.ai-tab:hover, .ai-tab.on { color: var(--orange-deep); }
|
|
170
|
+
.ai-tab.on { padding-left: 20px; }
|
|
171
|
+
.ai-tab.on::before { content: ""; position: absolute; top: -1px; bottom: -1px; left: 0; width: 3px; background: var(--orange); }
|
|
172
|
+
.ai-panel h4 { margin: 10px 0 0; font-weight: 900; font-stretch: 63%; font-size: clamp(2rem, 4vw, 3rem); line-height: 0.95; text-transform: uppercase; }
|
|
173
|
+
.ai-badge { font: 10px var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
|
|
174
|
+
.ai-badge.tested { color: var(--orange-deep); }
|
|
175
|
+
.ai-steps { margin: 18px 0 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
|
|
176
|
+
.ai-steps li { display: grid; grid-template-columns: 40px minmax(0, 1fr); padding: 12px 0; font-size: 14px; line-height: 1.55; border-bottom: 1px solid var(--line); }
|
|
177
|
+
.ai-steps li span { font: 13px var(--mono); color: var(--orange); }
|
|
178
|
+
.ai-oneclick { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 18px; }
|
|
179
|
+
.ai-oneclick small { font: 10px var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); }
|
|
180
|
+
.ai-plate { margin-top: 18px; color: var(--on-dark); background: var(--ink); }
|
|
181
|
+
.ai-plate-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; font: 10.5px var(--mono); border-bottom: 1px solid var(--line-dark); }
|
|
182
|
+
.ai-plate-bar span { opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
183
|
+
.ai-plate-bar button { flex: none; padding: 6px 12px; font: 10px var(--mono); letter-spacing: 0.14em; text-transform: uppercase; border: 1px solid var(--line-dark); transition: color 0.3s var(--ease), border-color 0.3s var(--ease); }
|
|
184
|
+
.ai-plate-bar button:hover { color: var(--orange); border-color: var(--orange); }
|
|
185
|
+
.ai-plate pre { margin: 0; padding: 16px 14px 18px; overflow-x: auto; font: 12.5px/1.7 var(--mono); white-space: pre; }
|
|
186
|
+
.ai-note { max-width: 66ch; margin: 14px 0 0; padding-left: 14px; font-size: 13.5px; line-height: 1.6; color: var(--muted); border-left: 2px solid var(--orange); }
|
|
187
|
+
.ai-then { margin-top: 20px; font-size: 14px; }
|
|
188
|
+
.ai-then b { font-weight: 900; font-stretch: 63%; font-size: 1.6rem; text-transform: uppercase; }
|
|
189
|
+
|
|
160
190
|
/* --- tags: orange acts, ink states --------------------------------------- */
|
|
161
191
|
.tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; font-size: 9.5px; letter-spacing: 0.12em; white-space: nowrap; color: var(--muted); border: 1px solid var(--line); }
|
|
162
192
|
.tag.ember, .tag.sun { color: var(--orange-deep); border-color: var(--orange); }
|
|
@@ -282,6 +312,13 @@
|
|
|
282
312
|
<div class="card-head"><div><h3>Start here</h3><div class="c-sub">Do these in order. A step is done only once it has actually run.</div></div></div>
|
|
283
313
|
<div class="steps" id="steps"></div>
|
|
284
314
|
</div>
|
|
315
|
+
<div class="card gap">
|
|
316
|
+
<div class="card-head"><div><h3>Connect your AI tool</h3><div class="c-sub">Optional - your assistant then checks its own work, and does not call a change done until the check passes</div></div></div>
|
|
317
|
+
<div class="ai">
|
|
318
|
+
<div class="ai-tabs" id="aiTabs" role="tablist" aria-label="Your AI tool"></div>
|
|
319
|
+
<div class="ai-panel" id="aiPanel" role="tabpanel"></div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
285
322
|
</section>
|
|
286
323
|
|
|
287
324
|
<section class="view" id="view-findings" hidden>
|
package/kryptheon-dashboard.js
CHANGED
|
@@ -127,6 +127,13 @@ function recentChanges(root) {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** " - the risky part: x" for a frontend finding that names one, else "". */
|
|
131
|
+
function riskyPartsLine(f) {
|
|
132
|
+
const parts = (f.parts || []).filter((p) => p && p.text && p.text !== f.expression);
|
|
133
|
+
if (!parts.length) return '';
|
|
134
|
+
return ' - the risky part' + (parts.length === 1 ? ': ' : 's: ') + parts.map((p) => p.text).join(', ');
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
/** When this flow last passed, from the run history, or null. */
|
|
131
138
|
function lastPassOf(history, title) {
|
|
132
139
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
@@ -197,7 +204,9 @@ function startSteps(checks, seen) {
|
|
|
197
204
|
{
|
|
198
205
|
id: 'record',
|
|
199
206
|
title: 'Record your main flows',
|
|
200
|
-
|
|
207
|
+
// The lesson of a test where 26 bugs were planted before recording and
|
|
208
|
+
// none was caught: what is recorded is taken as right.
|
|
209
|
+
why: 'Sign up, log in, pay - do each once while Kryptheon watches. Record while your app works: what it sees is taken as right, so a bug already there when you record becomes part of "working".',
|
|
201
210
|
state: seen.recordings ? 'done' : 'todo',
|
|
202
211
|
at: null,
|
|
203
212
|
count: seen.recordings,
|
|
@@ -214,7 +223,7 @@ function startSteps(checks, seen) {
|
|
|
214
223
|
{
|
|
215
224
|
id: 'data',
|
|
216
225
|
title: 'Check your database',
|
|
217
|
-
why: 'Attacks a copy
|
|
226
|
+
why: 'Attacks a copy inside your Postgres or Supabase database and reports what got in; your own rows are not touched. It asks for the connection string - in Supabase: Project Settings, Database, Connection string, URI. Try it on a test project first. It runs in your terminal, so the string never reaches this page.',
|
|
218
227
|
state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
|
|
219
228
|
at: seen.nightAt,
|
|
220
229
|
action: on('data') ? { kind: 'command', command: 'npx kryptheon-night' } : null,
|
|
@@ -290,7 +299,9 @@ function buildState(root, env) {
|
|
|
290
299
|
confidence: f.confidence,
|
|
291
300
|
where: f.file + ':' + f.line,
|
|
292
301
|
headline: f.headline,
|
|
293
|
-
|
|
302
|
+
// The part of a long template that decides it, so the person reading
|
|
303
|
+
// it looks at one value instead of sixty lines of markup.
|
|
304
|
+
detail: f.sink + ': ' + f.expression + riskyPartsLine(f),
|
|
294
305
|
fixPrompt: f.fixPrompt,
|
|
295
306
|
when: code.checkedAt,
|
|
296
307
|
});
|
package/kryptheon-project.js
CHANGED
|
@@ -134,6 +134,28 @@ function inspectProject(dir, env) {
|
|
|
134
134
|
return Object.assign({ ok: true, reason: null }, base);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Whether `kryptheon code` may read this folder.
|
|
139
|
+
*
|
|
140
|
+
* The same as any command, with one opening: a plain site - index.html and
|
|
141
|
+
* a few scripts, no npm anywhere - has no package.json and never will. Found
|
|
142
|
+
* on a blind test: exactly such an app was refused, and nothing was read. The
|
|
143
|
+
* refusal exists so that a check does not run somebody else's recordings;
|
|
144
|
+
* reading the files in front of you cannot do that. A page at the top of the
|
|
145
|
+
* folder is what says it is a site. A home or system folder stays refused.
|
|
146
|
+
*/
|
|
147
|
+
function inspectCodeFolder(dir, env) {
|
|
148
|
+
const here = inspectProject(dir, env);
|
|
149
|
+
if (here.ok || here.reason !== 'no-package') return here;
|
|
150
|
+
let pages = [];
|
|
151
|
+
try {
|
|
152
|
+
pages = fs.readdirSync(dir).filter((name) => /\.html?$/i.test(name) && fs.statSync(path.join(dir, name)).isFile());
|
|
153
|
+
} catch (e) {
|
|
154
|
+
pages = [];
|
|
155
|
+
}
|
|
156
|
+
return pages.length ? Object.assign({}, here, { ok: true, reason: null, plainSite: true }) : here;
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
/**
|
|
138
160
|
* Why this folder is not a project, and what to do instead.
|
|
139
161
|
*
|
|
@@ -565,6 +587,7 @@ module.exports = {
|
|
|
565
587
|
tidy: tidy,
|
|
566
588
|
isHomeOrSystem: isHomeOrSystem,
|
|
567
589
|
inspectProject: inspectProject,
|
|
590
|
+
inspectCodeFolder: inspectCodeFolder,
|
|
568
591
|
noProjectLines: noProjectLines,
|
|
569
592
|
noRecordingsLines: noRecordingsLines,
|
|
570
593
|
};
|