[object Object]

← back to Secrets Manager

TK-11683: harden transcript scanner read loop (mid-write race) — retry-read once + exclude ENOENT-vanished files from unreadable, add fail-safe negative test

11e21a4aa6b59a6c05be27de9f33b05a31972545 · 2026-09-16 12:29:22 -0700 · Steve Abrams

A live transcript caught mid-write or rotated away between find-enumeration and
read (TK-11795) flipped the whole canary to WARN 'cannot certify clean'. Now:
retry the read once after a 150ms sync sleep for the transient case; a VANISHED
file (ENOENT — no longer on disk, so it cannot hold a persistent secret) is
excluded rather than counted as an unmeasured gap; a genuinely-unreadable
(non-ENOENT) file surviving the retry still counts unreadable -> WARN. Negative
test extended to prove the fail-safe direction holds (EACCES file -> WARN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qf3DHSrZSGBXkNENgsYdM8

Files touched

Diff

commit 11e21a4aa6b59a6c05be27de9f33b05a31972545
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 16 12:29:22 2026 -0700

    TK-11683: harden transcript scanner read loop (mid-write race) — retry-read once + exclude ENOENT-vanished files from unreadable, add fail-safe negative test
    
    A live transcript caught mid-write or rotated away between find-enumeration and
    read (TK-11795) flipped the whole canary to WARN 'cannot certify clean'. Now:
    retry the read once after a 150ms sync sleep for the transient case; a VANISHED
    file (ENOENT — no longer on disk, so it cannot hold a persistent secret) is
    excluded rather than counted as an unmeasured gap; a genuinely-unreadable
    (non-ENOENT) file surviving the retry still counts unreadable -> WARN. Negative
    test extended to prove the fail-safe direction holds (EACCES file -> WARN).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Qf3DHSrZSGBXkNENgsYdM8
---
 scan-transcripts.mjs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 53 insertions(+), 4 deletions(-)

diff --git a/scan-transcripts.mjs b/scan-transcripts.mjs
index 22920ee..e5f9ff8 100644
--- a/scan-transcripts.mjs
+++ b/scan-transcripts.mjs
@@ -48,17 +48,42 @@ function runScan(projectsDir) {
   const patterns = loadPatterns();
   const findings = [];
   const hcSeen = new Set();          // distinct high-confidence secret digests
-  let scanned = 0, unreadable = 0;
+  let scanned = 0, unreadable = 0, vanished = 0;
   const unreadableFiles = [];
 
   const measurable = fs.existsSync(projectsDir);
   const files = measurable ? (listTranscripts(projectsDir) || []) : [];
   const enumOk = measurable && files !== null;
 
+  // Synchronous short sleep (no busy-spin) — used only on the rare retry path.
+  const sleepMs = (ms) => {
+    try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
+    catch { const until = Date.now() + ms; while (Date.now() < until) {} }
+  };
+
+  // Read every transcript. A LIVE transcript can be caught mid-write or rotated
+  // between enumeration (find) and read (TK-11795): the first read then fails.
+  // Two failure modes, handled distinctly so neither NOISES the canary nor opens
+  // a false-green:
+  //   • transient (file briefly locked / partial) → retry ONCE after a short
+  //     delay; a single retry clears the mid-write race.
+  //   • VANISHED (ENOENT — the file was deleted/rotated away) → it no longer
+  //     exists on disk, so it cannot hold a persistent secret. Excluding it is
+  //     NOT a measurement gap (nothing to certify), so it does NOT flip to WARN.
+  //     A genuinely-unreadable file (a real, non-ENOENT error surviving the
+  //     retry) still counts as unreadable → WARN (fail-safe, cannot certify clean).
   for (const f of files) {
     let text;
     try { text = fs.readFileSync(f, 'utf8'); scanned++; }
-    catch { unreadable++; unreadableFiles.push(f); continue; }
+    catch (e1) {
+      if (e1 && e1.code === 'ENOENT') { vanished++; continue; }
+      sleepMs(150);
+      try { text = fs.readFileSync(f, 'utf8'); scanned++; }
+      catch (e2) {
+        if (e2 && e2.code === 'ENOENT') { vanished++; continue; }
+        unreadable++; unreadableFiles.push(f); continue;
+      }
+    }
     scanText(f, text, patterns, findings, hcSeen);
   }
 
@@ -100,7 +125,8 @@ function runScan(projectsDir) {
     projectsDir,
     population: files.length,                      // total transcripts discovered
     scanned,                                       // successfully read
-    unreadable,                                    // NOT MEASURED
+    unreadable,                                    // NOT MEASURED (real read error)
+    vanished,                                      // deleted/rotated mid-scan — excluded, not a gap
     findings_total: findings.length,
     high_confidence: hcFindings.length,
     distinct_hc_secrets: hcSeen.size,
@@ -145,12 +171,35 @@ function runTest() {
 
   fs.rmSync(tmp, { recursive: true, force: true });
 
-  const pass = dirtyFlagged && !cleanFlagged && r.verdict === 'FAIL' && !rawLeaked;
+  // Scenario 2 (TK-11795 fail-safe direction) — a GENUINELY unreadable transcript
+  // (EACCES, exists but can't be read) with NO secret must STILL yield WARN, never
+  // a silent PASS. Proves the retry-then-count-unreadable path preserves the
+  // "cannot certify clean" fail-safe after the mid-write-race fix.
+  let unreadableIsWarn = true, unreadableCounted = true;
+  try {
+    const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11683-negtest2-'));
+    const sub2 = path.join(tmp2, '-Users-fake-Projects-perm');
+    fs.mkdirSync(sub2, { recursive: true });
+    fs.writeFileSync(path.join(sub2, 'ok.jsonl'), JSON.stringify({ type: 'user', text: 'nothing secret here' }) + '\n');
+    const locked = path.join(sub2, 'locked.jsonl');
+    fs.writeFileSync(locked, JSON.stringify({ type: 'user', text: 'placeholder' }) + '\n');
+    fs.chmodSync(locked, 0o000);   // EACCES on read (non-ENOENT) — a real read error
+    const r2 = runScan(tmp2);
+    unreadableIsWarn = r2.verdict === 'WARN';
+    unreadableCounted = r2.unreadable === 1 && (r2.vanished ?? 0) === 0;
+    try { fs.chmodSync(locked, 0o644); } catch {}
+    fs.rmSync(tmp2, { recursive: true, force: true });
+  } catch { unreadableIsWarn = false; unreadableCounted = false; }
+
+  const pass = dirtyFlagged && !cleanFlagged && r.verdict === 'FAIL' && !rawLeaked &&
+    unreadableIsWarn && unreadableCounted;
   console.log('NEGATIVE TEST');
   console.log(`  flags injected fake secret:      ${dirtyFlagged ? 'YES ✓' : 'NO ✗'}`);
   console.log(`  ignores clean/placeholder line:  ${!cleanFlagged ? 'YES ✓' : 'NO ✗ (false positive!)'}`);
   console.log(`  verdict on injected fault:       ${r.verdict} ${r.verdict === 'FAIL' ? '✓' : '✗'}`);
   console.log(`  report contains NO raw secret:   ${!rawLeaked ? 'YES ✓' : 'NO ✗ (LEAK!)'}`);
+  console.log(`  genuinely-unreadable → WARN:     ${unreadableIsWarn ? 'YES ✓' : 'NO ✗ (false green!)'}`);
+  console.log(`  unreadable counted (not vanished): ${unreadableCounted ? 'YES ✓' : 'NO ✗'}`);
   console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL');
   process.exit(pass ? 0 : 1);
 }

← 7c0d555 auto-data-snapshot: 2026-09-16T10:07:35 (1 data files) — reg  ·  back to Secrets Manager  ·  TK-11683: document ENOENT-exclusion tradeoff (codex-check/Gr b0935a9 →