loki-mode 7.129.4 → 8.0.0

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.
Files changed (74) hide show
  1. package/README.md +81 -5
  2. package/SKILL.md +66 -5
  3. package/VERSION +1 -1
  4. package/autonomy/app-runner.sh +37 -0
  5. package/autonomy/completion-council.sh +862 -28
  6. package/autonomy/council-v2.sh +39 -0
  7. package/autonomy/crash.sh +3 -2
  8. package/autonomy/grill.sh +42 -0
  9. package/autonomy/hooks/validate-bash.sh +301 -4
  10. package/autonomy/lib/cockpit-render.sh +8 -2
  11. package/autonomy/lib/cr-rematerialize.py +101 -4
  12. package/autonomy/lib/deadline.py +957 -0
  13. package/autonomy/lib/dependency-setup.sh +205 -0
  14. package/autonomy/lib/done-recognition.sh +45 -0
  15. package/autonomy/lib/efficiency_cost.py +13 -6
  16. package/autonomy/lib/no_mock_scan.py +793 -0
  17. package/autonomy/lib/prd-enrich.sh +42 -0
  18. package/autonomy/lib/proof-analytics-props.py +69 -0
  19. package/autonomy/lib/proof-generator.py +282 -95
  20. package/autonomy/lib/proof-template.html +15 -12
  21. package/autonomy/lib/proof-verify.py +151 -102
  22. package/autonomy/lib/requirements_contract.py +848 -0
  23. package/autonomy/lib/sdk-mode.sh +103 -0
  24. package/autonomy/lib/secret-scan.sh +139 -0
  25. package/autonomy/lib/spec-expand.sh +208 -0
  26. package/autonomy/lib/tree_digest.py +266 -0
  27. package/autonomy/lib/voter-agents.sh +58 -8
  28. package/autonomy/lib/workspace_diff.py +124 -0
  29. package/autonomy/loki +189 -17
  30. package/autonomy/playwright-verify.sh +501 -0
  31. package/autonomy/prd-checklist.sh +17 -8
  32. package/autonomy/provider-offer.sh +111 -0
  33. package/autonomy/run.sh +4424 -678
  34. package/autonomy/sandbox.sh +42 -0
  35. package/autonomy/spec-interrogation.sh +148 -5
  36. package/autonomy/spec.sh +118 -1
  37. package/autonomy/telemetry.sh +133 -7
  38. package/autonomy/verify.sh +107 -0
  39. package/bin/loki +110 -3
  40. package/completions/_loki +10 -0
  41. package/completions/loki.bash +1 -1
  42. package/dashboard/__init__.py +1 -1
  43. package/dashboard/audit.py +96 -7
  44. package/dashboard/build_supervisor.py +1619 -0
  45. package/dashboard/control.py +8 -0
  46. package/dashboard/prompt_optimizer.py +7 -0
  47. package/dashboard/server.py +577 -22
  48. package/dashboard/static/trust.html +1 -1
  49. package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
  50. package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
  51. package/docs/INSTALLATION.md +19 -2
  52. package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
  53. package/docs/PLANS-INDEX.md +33 -0
  54. package/docs/PRIVACY.md +29 -1
  55. package/docs/SIGNED-RECEIPTS.md +102 -0
  56. package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
  57. package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
  58. package/docs/V8-AGENT-SDK-PLAN.md +692 -0
  59. package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
  60. package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
  61. package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
  62. package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
  63. package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
  64. package/docs/alternative-installations.md +4 -4
  65. package/loki-ts/dist/loki.js +674 -285
  66. package/loki-ts/package.json +2 -0
  67. package/mcp/__init__.py +1 -1
  68. package/package.json +7 -6
  69. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  70. package/providers/claude.sh +75 -0
  71. package/providers/codex.sh +85 -13
  72. package/references/sdk-mode.md +106 -0
  73. package/skills/model-selection.md +1 -1
  74. package/skills/quality-gates.md +22 -0
@@ -253,6 +253,507 @@ SMOKE_SCRIPT
253
253
  return 0
254
254
  }
255
255
 
256
+ #===============================================================================
257
+ # Functional Proof (Proof-of-Function dynamic half) -- PERSISTENCE + AUTH
258
+ #===============================================================================
259
+ #
260
+ # Drives the RUNNING app and writes .loki/verification/functional-proof.json
261
+ # with an explicit tri-state per property that council_evidence_gate reads:
262
+ # {"persistence": {"attempted": bool, "proven": bool, "reason": str},
263
+ # "auth": {"attempted": bool, "proven": bool, "reason": str},
264
+ # "url": str, "verified_at": str}
265
+ #
266
+ # PERSISTENCE: discovers a create path (a <form> with a submit, or an Add/Create/
267
+ # Save button), fills it with a UNIQUE sentinel token (loki-persist-<uuid>),
268
+ # submits, waits for network idle, RELOADS, and asserts the sentinel is present
269
+ # after reload (a real DB/API read-back). proven=false if it does not survive
270
+ # -> the gate BLOCKS. no create path -> attempted:false reason=no_create_path.
271
+ #
272
+ # AUTH: if an auth signal is detected (a login/signin route/link, or
273
+ # LOKI_PROOF_PROTECTED_PATH configured), issues a logged-out request to a
274
+ # protected route and asserts REJECTION (401/403 or redirect to login).
275
+ # A login screen that merely renders -> proven:false -> BLOCK. No auth signal
276
+ # -> attempted:false reason=no_auth.
277
+ #
278
+ # Never blocks the iteration itself (|| true at the call site). The council gate
279
+ # is what turns proven:false into a BLOCK. Interval + serveable gated upstream.
280
+ # Opt out per-axis with LOKI_PROOF_PERSIST=0 / LOKI_PROOF_AUTH=0 at the gate.
281
+ #
282
+ # Env knobs:
283
+ # LOKI_PROOF_CREATE_SELECTOR - CSS selector to point the driver at a form
284
+ # LOKI_PROOF_PROTECTED_PATH - path to test for a logged-out rejection
285
+ # LOKI_PROOF_RELOAD_WAIT_MS - wait before asserting sentinel absence (flake)
286
+ #===============================================================================
287
+
288
+ playwright_prove_functional() {
289
+ local url="$1"
290
+
291
+ if [ -z "$url" ]; then
292
+ log_warn "playwright_prove_functional: no URL provided"
293
+ return 0
294
+ fi
295
+
296
+ local verify_dir="${PLAYWRIGHT_VERIFY_DIR:-.loki/verification}"
297
+ local screenshots_dir="${verify_dir}/screenshots"
298
+ mkdir -p "$screenshots_dir"
299
+
300
+ local results_file="${verify_dir}/functional-proof.json"
301
+ local script_file="${verify_dir}/.functional-proof.js"
302
+
303
+ # BUG 1a: invalidate any PRIOR proof BEFORE driving. A timeout (exit 124) or a
304
+ # driver hang must not leave a stale proven:true from an earlier iteration that
305
+ # the gate would read as current. The node script re-writes a fresh, stamped
306
+ # proof in its finally; if it never gets there, the file stays absent (which
307
+ # the gate treats as inconclusive pass-through, never a stale green).
308
+ rm -f "$results_file" "$results_file.tmp" 2>/dev/null || true
309
+
310
+ log_step "Running Playwright functional proof (persistence + auth) against ${url}..."
311
+
312
+ # Freshness stamp: the current iteration and the current HEAD SHA. The council
313
+ # gate reads this and treats a proof whose iteration does not match the current
314
+ # ITERATION_COUNT as NOT PRESENT (inconclusive), so a stale file never passes.
315
+ local _proof_iter="${ITERATION_COUNT:-0}"
316
+ local _proof_head
317
+ _proof_head="$(git rev-parse HEAD 2>/dev/null || echo '')"
318
+
319
+ cat > "$script_file" << 'PROOF_SCRIPT'
320
+ (async () => {
321
+ const url = process.argv[2];
322
+ const resultsPath = process.argv[3];
323
+ const pageTimeout = parseInt(process.argv[4] || '15000', 10);
324
+ const reloadWaitMs = parseInt(process.env.LOKI_PROOF_RELOAD_WAIT_MS || '1500', 10);
325
+ const createSelector = process.env.LOKI_PROOF_CREATE_SELECTOR || '';
326
+ const protectedPath = process.env.LOKI_PROOF_PROTECTED_PATH || '';
327
+ // Authorization / tenant-isolation knobs (all optional; absence => auto-detect
328
+ // or inconclusive pass-through). Configured selectors are the highest-confidence
329
+ // path; auto-detect (signup form) is the best-effort fallback.
330
+ const azSignupSel = process.env.LOKI_PROOF_AUTHZ_SIGNUP_SELECTOR || '';
331
+ const azLoginSel = process.env.LOKI_PROOF_AUTHZ_LOGIN_SELECTOR || '';
332
+ const azUserField = process.env.LOKI_PROOF_AUTHZ_USER_FIELD || '';
333
+ const azPassField = process.env.LOKI_PROOF_AUTHZ_PASS_FIELD || '';
334
+ const azSubmitSel = process.env.LOKI_PROOF_AUTHZ_SUBMIT || '';
335
+ const azOwnedListSel = process.env.LOKI_PROOF_AUTHZ_OWNED_LIST_SELECTOR || '';
336
+ const azDetailTemplate = process.env.LOKI_PROOF_AUTHZ_DETAIL_URL_TEMPLATE || '';
337
+ const azApiTemplate = process.env.LOKI_PROOF_AUTHZ_API_TEMPLATE || '';
338
+
339
+ const results = {
340
+ verified_at: new Date().toISOString(),
341
+ url: url,
342
+ // BUG 1b: freshness stamp the gate validates against the current iteration.
343
+ stamp: {
344
+ iteration: parseInt(process.env.LOKI_PROOF_ITER || '0', 10),
345
+ head: process.env.LOKI_PROOF_HEAD || '',
346
+ },
347
+ persistence: { attempted: false, proven: false, reason: 'not_run' },
348
+ auth: { attempted: false, proven: false, reason: 'not_run' },
349
+ authorization: { attempted: false, proven: false, reason: 'not_run' },
350
+ };
351
+
352
+ const write = () => {
353
+ const fs = require('fs');
354
+ const tmp = resultsPath + '.tmp';
355
+ fs.writeFileSync(tmp, JSON.stringify(results, null, 2));
356
+ fs.renameSync(tmp, resultsPath);
357
+ };
358
+
359
+ let browser;
360
+ try {
361
+ // BUG 3: require playwright INSIDE the try. If the driver is absent, node
362
+ // would otherwise throw at module-top require time and write NO proof at all
363
+ // -- which, combined with the missing-file handling, previously false-blocked.
364
+ // A fresh fallback proof {attempted:false, reason:driver_unavailable} keeps
365
+ // the axis inconclusive pass-through instead.
366
+ const { chromium } = require('playwright');
367
+ browser = await chromium.launch({ headless: true });
368
+ const page = await browser.newPage();
369
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: pageTimeout });
370
+
371
+ // ---- PERSISTENCE ----
372
+ try {
373
+ const sentinel = 'loki-persist-' + Math.random().toString(36).slice(2, 10);
374
+ // Find a create path: a configured selector, else a form with a text input
375
+ // and a submit, else an Add/Create/Save button revealing a form.
376
+ let formHandle = null;
377
+ if (createSelector) {
378
+ formHandle = await page.$(createSelector);
379
+ }
380
+ if (!formHandle) {
381
+ // Try to reveal a form behind an Add/Create/New/Save trigger.
382
+ const trigger = await page.$(
383
+ 'button:has-text("Add"), button:has-text("Create"), button:has-text("New"), a:has-text("Add"), a:has-text("Create")'
384
+ ).catch(() => null);
385
+ if (trigger) { await trigger.click().catch(() => {}); await page.waitForTimeout(300); }
386
+ formHandle = await page.$('form');
387
+ }
388
+
389
+ // Locate a fillable text input inside/near the form.
390
+ let input = null;
391
+ if (formHandle) {
392
+ input = await formHandle.$('input[type="text"], input:not([type]), input[type="search"], input[type="email"], textarea').catch(() => null);
393
+ }
394
+ if (!input) {
395
+ input = await page.$('input[type="text"], input:not([type]), textarea').catch(() => null);
396
+ }
397
+
398
+ if (!input) {
399
+ results.persistence = { attempted: false, proven: false, reason: 'no_create_path' };
400
+ } else {
401
+ results.persistence.attempted = true;
402
+ await input.fill(sentinel);
403
+ // Submit: prefer a submit button, else press Enter.
404
+ let submitted = false;
405
+ const submitBtn = formHandle
406
+ ? await formHandle.$('button[type="submit"], input[type="submit"], button:has-text("Add"), button:has-text("Create"), button:has-text("Save"), button:has-text("Submit")').catch(() => null)
407
+ : null;
408
+ try {
409
+ if (submitBtn) { await submitBtn.click(); submitted = true; }
410
+ else { await input.press('Enter'); submitted = true; }
411
+ } catch (e) { submitted = false; }
412
+
413
+ if (!submitted) {
414
+ results.persistence = { attempted: true, proven: false, reason: 'submit_failed' };
415
+ } else {
416
+ // Wait for the write to settle, then read back in a FRESH context with
417
+ // NO client storage. BUG 3: a plain page.reload() keeps localStorage /
418
+ // sessionStorage / IndexedDB, so a no-backend SPA that only persists to
419
+ // localStorage would falsely prove persistence. A brand-new context
420
+ // (fresh cookies + empty storage) only sees the sentinel if it survived
421
+ // in a real DB/API the server read back -- true beyond-client persistence.
422
+ await page.waitForLoadState('networkidle', { timeout: pageTimeout }).catch(() => {});
423
+ await page.waitForTimeout(reloadWaitMs);
424
+ const readCtx = await browser.newContext();
425
+ const readPage = await readCtx.newPage();
426
+ await readPage.goto(url, { waitUntil: 'networkidle', timeout: pageTimeout }).catch(() => {});
427
+ const body = await readPage.content();
428
+ await readCtx.close().catch(() => {});
429
+ if (body.indexOf(sentinel) !== -1) {
430
+ results.persistence = { attempted: true, proven: true, reason: 'sentinel_survived_fresh_context', sentinel };
431
+ } else {
432
+ results.persistence = { attempted: true, proven: false, reason: 'sentinel_gone_after_reload', sentinel };
433
+ }
434
+ }
435
+ }
436
+ } catch (e) {
437
+ // A create path was found but the drive errored -> proven:false (BLOCK).
438
+ // "submit errored" is the #1 churn bug and must not green-wash.
439
+ results.persistence = { attempted: true, proven: false, reason: 'drive_error: ' + String(e.message).slice(0, 120) };
440
+ }
441
+
442
+ // ---- AUTH (negative path) ----
443
+ try {
444
+ // Detect an auth signal: a configured protected path, or a login/signin
445
+ // route/link in the app.
446
+ let path = protectedPath;
447
+ let authSignal = !!protectedPath;
448
+ if (!authSignal) {
449
+ // Re-load the app root (persistence may have navigated it) and look.
450
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: pageTimeout }).catch(() => {});
451
+ const hasLogin = await page.$('a[href*="login"], a[href*="signin"], a:has-text("Log in"), a:has-text("Login"), a:has-text("Sign in"), form[action*="login"], input[type="password"]').catch(() => null);
452
+ authSignal = !!hasLogin;
453
+ // Guess a protected path from common conventions.
454
+ if (authSignal) {
455
+ for (const cand of ['/dashboard', '/account', '/admin', '/app', '/settings', '/profile']) {
456
+ path = cand; break;
457
+ }
458
+ }
459
+ }
460
+
461
+ if (!authSignal) {
462
+ results.auth = { attempted: false, proven: false, reason: 'no_auth' };
463
+ } else if (!path) {
464
+ results.auth = { attempted: false, proven: false, reason: 'auth_detected_untestable' };
465
+ } else {
466
+ results.auth.attempted = true;
467
+ // Logged-out request in a FRESH context (no cookies/storage).
468
+ const ctx = await browser.newContext();
469
+ const p2 = await ctx.newPage();
470
+ const target = new URL(path, url).toString();
471
+ let observed = 'none';
472
+ const resp = await p2.goto(target, { waitUntil: 'domcontentloaded', timeout: pageTimeout }).catch(() => null);
473
+ const status = resp ? resp.status() : 0;
474
+ const finalUrl = p2.url();
475
+ const redirectedToLogin = /login|signin|sign-in|auth/i.test(finalUrl) && finalUrl !== target;
476
+ const hasPasswordField = await p2.$('input[type="password"]').catch(() => null);
477
+ if (status === 401 || status === 403) {
478
+ observed = String(status);
479
+ results.auth = { attempted: true, proven: true, reason: 'rejected_' + status, path, observed_status: status };
480
+ } else if (redirectedToLogin) {
481
+ observed = 'redirect';
482
+ results.auth = { attempted: true, proven: true, reason: 'redirect_to_login', path, observed_status: 'redirect' };
483
+ } else if (status >= 200 && status < 300 && !hasPasswordField) {
484
+ // Protected route served content logged-out -> auth NOT enforced.
485
+ results.auth = { attempted: true, proven: false, reason: 'served_200_logged_out', path, observed_status: status };
486
+ } else if (hasPasswordField) {
487
+ // A login screen merely rendered at the protected path. That is not
488
+ // enforcement of the protected resource; but it is also not a served
489
+ // resource. Treat as untestable (a protected page that IS the login).
490
+ results.auth = { attempted: true, proven: false, reason: 'login_screen_only', path, observed_status: status };
491
+ } else {
492
+ results.auth = { attempted: true, proven: false, reason: 'auth_detected_untestable', path, observed_status: status };
493
+ }
494
+ await ctx.close().catch(() => {});
495
+ }
496
+ } catch (e) {
497
+ results.auth = { attempted: true, proven: false, reason: 'auth_detected_timeout' };
498
+ }
499
+
500
+ // ---- AUTHORIZATION (tenant isolation) ----
501
+ // The Lovable-breach class: two LOGGED-IN users where A can read B's owned
502
+ // rows. Prove by OBSERVED artifact only -- a real second session actually
503
+ // being DENIED A's sentinel. The ONLY blocking verdict is a fresh positive
504
+ // leak (reason prefix 'user_b_read_user_a_'); every undetectable/absent case
505
+ // is attempted:false or a non-leak reason -> inconclusive pass-through.
506
+ try {
507
+ // rand sentinel + two distinct identities.
508
+ const rnd = () => Math.random().toString(36).slice(2, 10);
509
+ const sentinel = 'loki-authz-' + rnd();
510
+ const identityA = 'authzA-' + rnd() + '@loki.test';
511
+ const identityB = 'authzB-' + rnd() + '@loki.test';
512
+ const pass = 'Loki-authz-Pw1!';
513
+
514
+ // Locate a signup/login form in a page. Returns {user, pass, submit} handles
515
+ // or null. Configured selectors win; else auto-detect a password field + a
516
+ // text/email input + a submit.
517
+ const findAuthForm = async (pg, isSignup) => {
518
+ let userH = null, passH = null, submitH = null;
519
+ if (azUserField) userH = await pg.$(azUserField).catch(() => null);
520
+ if (azPassField) passH = await pg.$(azPassField).catch(() => null);
521
+ if (azSubmitSel) submitH = await pg.$(azSubmitSel).catch(() => null);
522
+ const scopeSel = isSignup ? azSignupSel : azLoginSel;
523
+ if (scopeSel) {
524
+ const scope = await pg.$(scopeSel).catch(() => null);
525
+ if (scope) {
526
+ if (!userH) userH = await scope.$('input[type="email"], input[type="text"], input:not([type])').catch(() => null);
527
+ if (!passH) passH = await scope.$('input[type="password"]').catch(() => null);
528
+ if (!submitH) submitH = await scope.$('button[type="submit"], input[type="submit"], button').catch(() => null);
529
+ }
530
+ }
531
+ if (!passH) passH = await pg.$('input[type="password"]').catch(() => null);
532
+ if (!passH) return null;
533
+ if (!userH) userH = await pg.$('input[type="email"], input[type="text"], input:not([type])').catch(() => null);
534
+ if (!userH) return null;
535
+ if (!submitH) submitH = await pg.$('button[type="submit"], input[type="submit"], button:has-text("Sign up"), button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Register"), button:has-text("Continue")').catch(() => null);
536
+ return { userH, passH, submitH };
537
+ };
538
+
539
+ // Authenticate a fresh context as (email). Prefer signup (guarantees a fresh
540
+ // distinct user); fall back to login only if a login form is present.
541
+ // Returns {ctx, page} on success or null.
542
+ const authAs = async (email) => {
543
+ const ctx = await browser.newContext();
544
+ const pg = await ctx.newPage();
545
+ // Try signup route first, then app root.
546
+ let form = null;
547
+ for (const cand of ['/signup', '/register', '/sign-up', '/']) {
548
+ const target = new URL(cand, url).toString();
549
+ await pg.goto(target, { waitUntil: 'domcontentloaded', timeout: pageTimeout }).catch(() => {});
550
+ form = await findAuthForm(pg, true);
551
+ if (form) break;
552
+ }
553
+ if (!form) { await ctx.close().catch(() => {}); return null; }
554
+ try {
555
+ await form.userH.fill(email);
556
+ await form.passH.fill(pass);
557
+ if (form.submitH) { await form.submitH.click().catch(() => {}); }
558
+ else { await form.passH.press('Enter').catch(() => {}); }
559
+ await pg.waitForLoadState('networkidle', { timeout: pageTimeout }).catch(() => {});
560
+ await pg.waitForTimeout(reloadWaitMs);
561
+ } catch (e) { await ctx.close().catch(() => {}); return null; }
562
+ return { ctx, page: pg };
563
+ };
564
+
565
+ // Step 1a: two-session capability. If neither configured nor an auto signup
566
+ // path exists, this authAs returns null and we bail as no_multiuser_auth.
567
+ const sessA = await authAs(identityA);
568
+ if (!sessA) {
569
+ results.authorization = { attempted: false, proven: false, reason: 'no_multiuser_auth', identity_a: identityA };
570
+ } else {
571
+ // Step 1b + 2: as A, create an owned record with the sentinel (reuse the
572
+ // persistence create-path finder shape).
573
+ const pgA = sessA.page;
574
+ let createInput = null;
575
+ let formHandleA = null;
576
+ if (createSelector) formHandleA = await pgA.$(createSelector).catch(() => null);
577
+ if (!formHandleA) {
578
+ const trig = await pgA.$('button:has-text("Add"), button:has-text("Create"), button:has-text("New"), a:has-text("Add"), a:has-text("Create")').catch(() => null);
579
+ if (trig) { await trig.click().catch(() => {}); await pgA.waitForTimeout(300); }
580
+ formHandleA = await pgA.$('form').catch(() => null);
581
+ }
582
+ if (formHandleA) createInput = await formHandleA.$('input[type="text"], input:not([type]), input[type="search"], textarea').catch(() => null);
583
+ if (!createInput) createInput = await pgA.$('input[type="text"], input:not([type]), textarea').catch(() => null);
584
+
585
+ if (!createInput) {
586
+ results.authorization = { attempted: false, proven: false, reason: 'no_owned_data', identity_a: identityA };
587
+ await sessA.ctx.close().catch(() => {});
588
+ } else {
589
+ await createInput.fill(sentinel);
590
+ const submitBtnA = formHandleA
591
+ ? await formHandleA.$('button[type="submit"], input[type="submit"], button:has-text("Add"), button:has-text("Create"), button:has-text("Save"), button:has-text("Submit")').catch(() => null)
592
+ : null;
593
+ try {
594
+ if (submitBtnA) await submitBtnA.click();
595
+ else await createInput.press('Enter');
596
+ } catch (e) {}
597
+ await pgA.waitForLoadState('networkidle', { timeout: pageTimeout }).catch(() => {});
598
+ await pgA.waitForTimeout(reloadWaitMs);
599
+ // Capture the list route A saw and any detail URL/id after create.
600
+ const listRouteA = azOwnedListSel ? '' : new URL(pgA.url(), url).pathname;
601
+ const afterCreateUrl = pgA.url();
602
+ // Extract a plausible record id from the post-create URL (/items/123).
603
+ let recordId = '';
604
+ const idMatch = afterCreateUrl.match(/\/(\d+|[0-9a-f]{8,})(?:[/?#].*)?$/i);
605
+ if (idMatch) recordId = idMatch[1];
606
+ const detailUrlA = (afterCreateUrl !== url && /\/(\d+|[0-9a-f]{8,})/.test(afterCreateUrl)) ? afterCreateUrl : '';
607
+ await sessA.ctx.close().catch(() => {});
608
+
609
+ // Step 3: session B in a SEPARATE fresh context.
610
+ const sessB = await authAs(identityB);
611
+ if (!sessB) {
612
+ results.authorization = { attempted: false, proven: false, reason: 'could_not_create_second_user', sentinel, identity_a: identityA, identity_b: identityB };
613
+ } else {
614
+ const pgB = sessB.page;
615
+ // Distinctness guard: if B's initial view already shows A's sentinel
616
+ // as a BASELINE (single-user app silently reused A), we cannot tell a
617
+ // leak from a shared store -> inconclusive.
618
+ const baselineB = await pgB.content().catch(() => '');
619
+ if (baselineB.indexOf(sentinel) !== -1) {
620
+ results.authorization = { attempted: false, proven: false, reason: 'could_not_create_second_user', sentinel, identity_a: identityA, identity_b: identityB };
621
+ await sessB.ctx.close().catch(() => {});
622
+ } else {
623
+ // Step 4: as B, attempt to READ A's sentinel via every path.
624
+ const pathsTried = [];
625
+ let leakPath = '';
626
+
627
+ // (a) list view: the configured owned-list route, else the route A saw.
628
+ let listTarget = '';
629
+ if (azOwnedListSel && /^https?:|^\//.test(azOwnedListSel)) listTarget = azOwnedListSel;
630
+ else if (listRouteA) listTarget = listRouteA;
631
+ if (listTarget) {
632
+ const t = new URL(listTarget, url).toString();
633
+ const r = await pgB.goto(t, { waitUntil: 'networkidle', timeout: pageTimeout }).catch(() => null);
634
+ const st = r ? r.status() : 0;
635
+ let contained = false;
636
+ try {
637
+ // If configured, scope the read to the owned-list selector.
638
+ if (azOwnedListSel && !/^https?:|^\//.test(azOwnedListSel)) {
639
+ const el = await pgB.$(azOwnedListSel).catch(() => null);
640
+ const txt = el ? await el.textContent().catch(() => '') : await pgB.content().catch(() => '');
641
+ contained = (txt || '').indexOf(sentinel) !== -1;
642
+ } else {
643
+ contained = (await pgB.content().catch(() => '')).indexOf(sentinel) !== -1;
644
+ }
645
+ } catch (e) {}
646
+ pathsTried.push({ path: 'list', target: t, observed_status: st, contained_sentinel: contained });
647
+ if (contained) leakPath = leakPath || 'list';
648
+ }
649
+
650
+ // (b) direct object (IDOR): a detail URL/template navigated in B's ctx.
651
+ let detailTarget = '';
652
+ if (azDetailTemplate && recordId) detailTarget = azDetailTemplate.replace('{id}', recordId);
653
+ else if (detailUrlA) detailTarget = detailUrlA;
654
+ if (detailTarget) {
655
+ const t = new URL(detailTarget, url).toString();
656
+ const r = await pgB.goto(t, { waitUntil: 'domcontentloaded', timeout: pageTimeout }).catch(() => null);
657
+ const st = r ? r.status() : 0;
658
+ const body = await pgB.content().catch(() => '');
659
+ const contained = st >= 200 && st < 300 && body.indexOf(sentinel) !== -1;
660
+ pathsTried.push({ path: 'detail', target: t, observed_status: st, contained_sentinel: contained });
661
+ if (contained) leakPath = leakPath || 'detail';
662
+ }
663
+
664
+ // (c) API: configured template in B's cookie context via fetch.
665
+ let apiTarget = '';
666
+ if (azApiTemplate) apiTarget = azApiTemplate.replace('{id}', recordId || '');
667
+ if (apiTarget) {
668
+ const t = new URL(apiTarget, url).toString();
669
+ let st = 0, body = '';
670
+ try {
671
+ const resp = await pgB.request.get(t, { timeout: pageTimeout });
672
+ st = resp.status();
673
+ body = await resp.text().catch(() => '');
674
+ } catch (e) {}
675
+ const contained = st >= 200 && st < 300 && body.indexOf(sentinel) !== -1;
676
+ pathsTried.push({ path: 'api', target: t, observed_status: st, contained_sentinel: contained });
677
+ if (contained) leakPath = leakPath || 'api';
678
+ }
679
+
680
+ await sessB.ctx.close().catch(() => {});
681
+
682
+ // Step 5: verdict.
683
+ if (pathsTried.length === 0) {
684
+ // Created data but no list/detail/api to read it as B. INCONCLUSIVE.
685
+ results.authorization = { attempted: true, proven: false, reason: 'no_read_path_for_b', sentinel, identity_a: identityA, identity_b: identityB, paths_tried: pathsTried };
686
+ } else if (leakPath) {
687
+ // POSITIVE LEAK: B's real session received A's sentinel. The ONLY block.
688
+ results.authorization = { attempted: true, proven: false, reason: 'user_b_read_user_a_' + leakPath, sentinel, identity_a: identityA, identity_b: identityB, paths_tried: pathsTried };
689
+ } else {
690
+ // Isolation HOLDS: B was denied on every path actually tried.
691
+ results.authorization = { attempted: true, proven: true, reason: 'isolated_on_all_paths', sentinel, identity_a: identityA, identity_b: identityB, paths_tried: pathsTried };
692
+ }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ } catch (e) {
698
+ // Deliberate asymmetry vs persistence: our own driver error is NOT a proof
699
+ // of a leak. Map to inconclusive (the gate treats authz_drive_error:* as
700
+ // pass-through). A security property must never be DISPROVEN by our tooling.
701
+ results.authorization = { attempted: true, proven: false, reason: 'authz_drive_error: ' + String(e && e.message || e).slice(0, 120) };
702
+ }
703
+
704
+ } catch (err) {
705
+ // Two inconclusive-passthrough cases land here, both non-blocking:
706
+ // - the playwright driver could not be required/launched -> driver_unavailable
707
+ // - the app was unreachable at all -> not_serveable
708
+ const msg = String(err && err.message || err);
709
+ const driverGone = /Cannot find module 'playwright'|Cannot find package 'playwright'|playwright.*not.*found|browserType.launch/i.test(msg);
710
+ const reason = driverGone ? 'driver_unavailable' : 'not_serveable';
711
+ results.persistence = { attempted: false, proven: false, reason };
712
+ results.auth = { attempted: false, proven: false, reason };
713
+ results.authorization = { attempted: false, proven: false, reason };
714
+ results.error = msg.slice(0, 160);
715
+ } finally {
716
+ if (browser) await browser.close();
717
+ write();
718
+ }
719
+
720
+ process.exit(0);
721
+ })();
722
+ PROOF_SCRIPT
723
+
724
+ # Run with outer timeout (never block iteration). A timeout leaves NO proof
725
+ # (we deleted the prior file above and the script only writes in its finally);
726
+ # a hung app therefore reads as inconclusive, never as a stale green.
727
+ timeout "${PLAYWRIGHT_TIMEOUT_SEC}" \
728
+ env LOKI_PROOF_ITER="$_proof_iter" LOKI_PROOF_HEAD="$_proof_head" \
729
+ node "$script_file" \
730
+ "$url" "$results_file" "$PLAYWRIGHT_TIMEOUT" 2>/dev/null
731
+ local exit_code=$?
732
+
733
+ rm -f "$script_file"
734
+
735
+ if [ "$exit_code" -eq 124 ]; then
736
+ log_warn "Playwright functional proof timed out after ${PLAYWRIGHT_TIMEOUT_SEC}s (proof left inconclusive)"
737
+ elif [ -f "$results_file" ]; then
738
+ local psummary
739
+ psummary=$(_PF="$results_file" python3 -c "
740
+ import json, os
741
+ try:
742
+ d = json.load(open(os.environ['_PF']))
743
+ p = d.get('persistence', {}); a = d.get('auth', {}); z = d.get('authorization', {})
744
+ print('persistence attempted=%s proven=%s (%s) | auth attempted=%s proven=%s (%s) | authz attempted=%s proven=%s (%s)' % (
745
+ p.get('attempted'), p.get('proven'), p.get('reason'),
746
+ a.get('attempted'), a.get('proven'), a.get('reason'),
747
+ z.get('attempted'), z.get('proven'), z.get('reason')))
748
+ except Exception:
749
+ print('')
750
+ " 2>/dev/null || true)
751
+ [ -n "$psummary" ] && log_info "Playwright functional proof: $psummary"
752
+ fi
753
+
754
+ return 0
755
+ }
756
+
256
757
  #===============================================================================
257
758
  # Screenshot Rotation
258
759
  #===============================================================================
@@ -188,13 +188,17 @@ checklist_oracle_triangulate() {
188
188
 
189
189
  # Feed the detector to python3 via a quoted heredoc (delimiter in quotes) so
190
190
  # bash performs NO interpolation: no dollar-digit footgun, no quote-escaping
191
- # hazards. All inputs arrive through _ORACLE_* env vars.
192
- local status_token
193
- status_token=$(_ORACLE_SPEC="$CHECKLIST_PRD_PATH" \
194
- _ORACLE_OUT="$findings_file" \
195
- _ORACLE_PROJECT="$project_dir" \
196
- _ORACLE_INSTALL_DIR="$oracle_install_dir" \
197
- python3 - <<'ORACLE_PY' 2>/dev/null || echo "NOOP"
191
+ # hazards. Keep the heredoc outside command substitution for macOS Bash 3.2.
192
+ # All inputs arrive through _ORACLE_* env vars.
193
+ local status_token="NOOP"
194
+ local status_file=""
195
+ local status_tmp_root="${TMPDIR:-/tmp}"
196
+ if status_file="$(mktemp "${status_tmp_root%/}/loki-oracle-status.XXXXXX" 2>/dev/null)"; then
197
+ if _ORACLE_SPEC="$CHECKLIST_PRD_PATH" \
198
+ _ORACLE_OUT="$findings_file" \
199
+ _ORACLE_PROJECT="$project_dir" \
200
+ _ORACLE_INSTALL_DIR="$oracle_install_dir" \
201
+ python3 - > "$status_file" 2>/dev/null <<'ORACLE_PY'
198
202
  import json, os, re, sys, tempfile, glob
199
203
 
200
204
  spec_path = os.environ["_ORACLE_SPEC"]
@@ -656,7 +660,12 @@ if findings:
656
660
  else:
657
661
  print("CLEAN")
658
662
  ORACLE_PY
659
- )
663
+ then
664
+ IFS= read -r status_token < "$status_file" || status_token="NOOP"
665
+ [ -n "$status_token" ] || status_token="NOOP"
666
+ fi
667
+ rm -f "$status_file" 2>/dev/null || true
668
+ fi
660
669
 
661
670
  # Honest logging + best-effort trust event on a real conflict only.
662
671
  local tok rest