cache-safety-tripwire.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. /**
  2. * Cache-safety tripwire — scans prompt-assembly source directories for
  3. * volatile-input patterns that silently invalidate provider prompt caches.
  4. *
  5. * Provider caches are exact byte-prefix matches over the rendered request.
  6. * A `Date.now()`, `new Date(...)`, `Math.random()`, or `randomUUID()` whose
  7. * value reaches the prompt prefix makes every request's prefix unique, so
  8. * nothing is ever served from cache — silently, with no error.
  9. *
  10. * When this test fails for a new file:
  11. *
  12. * 1. If the value can reach prompt content, keep it out of the stable
  13. * prefix: route it through the trailing volatile zone via
  14. * src/hooks/cache-safe-injection.ts, or drop it.
  15. * 2. If the value never feeds prompt content (timers, temp file names,
  16. * internal bookkeeping), add an allowlist entry below with a
  17. * justification that a reviewer can verify.
  18. *
  19. * See docs/cache-verification.md for the full invariant.
  20. */
  21. import { describe, expect, test } from 'bun:test';
  22. import { readFileSync } from 'node:fs';
  23. import path from 'node:path';
  24. const SRC_ROOT = import.meta.dir;
  25. /** Directories that participate in prompt/payload assembly. */
  26. const SCAN_DIRS = ['hooks', 'agents', 'config'];
  27. const VOLATILE_PATTERNS: Array<{ name: string; regex: RegExp }> = [
  28. { name: 'Date.now()', regex: /\bDate\.now\(/ },
  29. { name: 'new Date(...)', regex: /\bnew Date\(/ },
  30. { name: 'Math.random()', regex: /\bMath\.random\(/ },
  31. { name: 'randomUUID()', regex: /\brandomUUID\b/ },
  32. { name: 'performance.now()', regex: /\bperformance\.now\(/ },
  33. ];
  34. /**
  35. * Files allowed to use volatile inputs, each with a reviewer-verifiable
  36. * reason why the value can never reach the prompt prefix. Adding an entry
  37. * is a code-review decision, not a formality.
  38. */
  39. const ALLOWLIST = new Map<string, string>([
  40. [
  41. 'hooks/auto-update-checker/skill-sync.ts',
  42. 'Update scheduling and install bookkeeping; produces no prompt content.',
  43. ],
  44. [
  45. 'hooks/loop-command/index.ts',
  46. 'Timestamps/randomness name per-run loop-history directories; the path only appears inside a newly appended user turn (payload tail), never in earlier prefix bytes.',
  47. ],
  48. [
  49. 'hooks/foreground-fallback/index.ts',
  50. 'Date.now() gates retry/dedup windows for model failover; no prompt content is derived from it.',
  51. ],
  52. [
  53. 'hooks/apply-patch/prepared-changes.ts',
  54. 'randomUUID() names temp files during atomic writes; never serialized into messages.',
  55. ],
  56. [
  57. 'hooks/task-session-manager/task-context-tracker.ts',
  58. 'Date.now() records lastReadAt for internal recency ordering; formatted prompt output (background job board) is confined to the volatile trailing message.',
  59. ],
  60. [
  61. 'hooks/task-session-manager/event-router.ts',
  62. 'Date.now() captures idleObservedAt to detect post-idle busy recovery from foreground-fallback re-prompts; never serialized into prompt content.',
  63. ],
  64. [
  65. 'hooks/image-hook.ts',
  66. 'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
  67. ],
  68. [
  69. 'hooks/auto-update-checker/cache.ts',
  70. 'Date.now() and process.pid name an on-disk quarantine directory during the atomic publish transaction; the path is filesystem bookkeeping, never serialized into prompt content.',
  71. ],
  72. [
  73. 'hooks/auto-update-checker/checker.ts',
  74. 'Date.now()/Math.random() compose a per-run temp token for install bookkeeping; it names local directories and never reaches the prompt prefix.',
  75. ],
  76. ]);
  77. async function scanForViolations(): Promise<string[]> {
  78. const violations: string[] = [];
  79. const glob = new Bun.Glob('**/*.ts');
  80. for (const dir of SCAN_DIRS) {
  81. const root = path.join(SRC_ROOT, dir);
  82. for await (const file of glob.scan(root)) {
  83. if (file.endsWith('.test.ts')) continue;
  84. const relative = `${dir}/${file}`;
  85. if (ALLOWLIST.has(relative)) continue;
  86. const content = readFileSync(path.join(root, file), 'utf8');
  87. for (const pattern of VOLATILE_PATTERNS) {
  88. if (pattern.regex.test(content)) {
  89. violations.push(`${relative} uses ${pattern.name}`);
  90. }
  91. }
  92. }
  93. }
  94. return violations;
  95. }
  96. describe('cache-safety tripwire', () => {
  97. test('prompt-assembly code introduces no unreviewed volatile inputs', async () => {
  98. const violations = await scanForViolations();
  99. if (violations.length > 0) {
  100. throw new Error(
  101. [
  102. 'Volatile input detected in prompt-assembly code. If its value can',
  103. 'reach the prompt, it will silently bust the provider cache on',
  104. 'every request — keep it in the volatile tail via',
  105. 'src/hooks/cache-safe-injection.ts, or add a justified allowlist',
  106. 'entry in src/cache-safety-tripwire.test.ts (see file header).',
  107. '',
  108. ...violations,
  109. ].join('\n'),
  110. );
  111. }
  112. });
  113. test('allowlist contains no stale entries', async () => {
  114. const stale: string[] = [];
  115. for (const [relative] of ALLOWLIST) {
  116. const absolute = path.join(SRC_ROOT, relative);
  117. let content: string;
  118. try {
  119. content = readFileSync(absolute, 'utf8');
  120. } catch {
  121. stale.push(`${relative} (file no longer exists)`);
  122. continue;
  123. }
  124. const stillMatches = VOLATILE_PATTERNS.some((pattern) =>
  125. pattern.regex.test(content),
  126. );
  127. if (!stillMatches) {
  128. stale.push(`${relative} (no volatile patterns remain)`);
  129. }
  130. }
  131. expect(stale).toEqual([]);
  132. });
  133. });