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.
- package/README.md +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4424 -678
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- 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.
|
|
192
|
-
|
|
193
|
-
status_token
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|