kryptheon 0.1.22 → 0.1.23

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 CHANGED
@@ -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.expressions.map((e) => again(e)));
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([again(node.left), again(node.right)]);
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
- if (name === 'join' && node.callee.object && /CallExpression$/.test(node.callee.object.type) &&
172
- calleeName(node.callee.object.callee) === 'map') {
173
- const fn = node.callee.object.arguments[0];
174
- // The callback's item is whatever the array it walks holds.
175
- const listOrigin = again(node.callee.object.callee.object);
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 &amp; &lt; &gt;. 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('&amp;') || !writes('&lt;') || !writes('&gt;')) return null;
334
+ return {
335
+ dq: /"/.test(patterns) && (writes('&quot;') || writes('&#34;') || writes('&#x22;')),
336
+ sq: /'/.test(patterns) && (writes('&#39;') || writes('&#x27;') || writes('&apos;')),
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
- return classify(init, binding.path.scope, code, depth + 1);
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.
@@ -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>
@@ -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
- why: 'Sign up, log in, pay - do each once while Kryptheon watches. It then knows what "working" looks like.',
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 of your Postgres or Supabase database and reports what got in. Needs your connection string, so it runs in your terminal.',
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
- detail: f.sink + ': ' + f.expression,
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",