opencode-dejavu 2.2.1 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.0 — 2026-08-24
4
+
5
+ ### Multi-process hardening (several OpenCode windows = several plugin processes on one store)
6
+ - Remind→block session state is persisted ON THE GATE (`remindedSessions`/`failedSessions`) instead of per-process memory: the escalation chain now survives process restarts and is visible to every window serving the session (before: two windows or a restart reset it to "remind forever, never block"). Enforcement reads fresh gate state under the store lock.
7
+ - Session state rots after 24h and is capped per gate; `session.deleted` cleans it from disk.
8
+
9
+ ### Performance (hot path runs on every tool call)
10
+ - `fuzzySimilar`: O(1) length-band pre-filter (triangle inequality — zero false negatives) and a `FUZZY_MAX_LEN` cap — kills the Levenshtein explosion on long signatures (was 150-600ms/tool-call at scale).
11
+ - `GateStore`: O(1) key index + cached blocking subset for lookups; 1s TTL on the mtime cache so the hot path stops paying a `stat` per call (saves refresh the cache directly).
12
+ - Global log rotates at 2MB instead of 512KB — with several projects the aggregate forensics no longer vanish within a day.
13
+
14
+ ### Fixed
15
+ - Init-storm TOCTOU: index orphan-pruning now keeps a 24h grace window, so a just-promoted gate's index entry cannot be pruned by a concurrent startup.
16
+ - After-hook escalation state is written under the store lock and follows the gate to the global store on escalation (previously lost in both cases).
17
+
18
+ ### Added
19
+ - `doctor.ts` reports LOCK DEGRADATIONS (count of `degraded` log events) as an observability note — the evidence signal for whether the storage backend ever needs revisiting.
20
+
3
21
  ## 2.2.1 — 2026-08-24
4
22
 
5
23
  ### Fixed (adversarial-review round)
package/README.md CHANGED
@@ -67,6 +67,7 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
67
67
  - **Aborted ≠ failed** — cancelled/aborted tool executions ("Tool execution aborted") are infrastructure noise and are never counted as failures.
68
68
  - **File content is not command output** — text failure signatures are scanned for `bash` only; `read`/`edit`/`write` failures come exclusively from the event channel (a file containing "TypeError" is not a failure).
69
69
  - **Concurrency** — gates.json mutations run under an exclusive lockfile; log appends and rotation take their own lock (every OpenCode window shares the global log); writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix. If a lock cannot be acquired within 3s the critical section degrades to unlocked (the tool pipeline must never hang) and emits a `degraded` log event — the only window where updates can be lost is visible.
70
+ - **Multi-window safe** — the remind→block escalation chain is persisted on the gate itself (`remindedSessions`/`failedSessions`), not in process memory: several OpenCode windows on one store — and process restarts — all see the same chain. Enforcement always reads fresh gate state under the store lock.
70
71
  - **Near-duplicate consolidation** — new failures merge into existing patterns via normalized Levenshtein ≤ 0.3 with an absolute floor of 3 edits (replaces token Jaccard, which collapsed all `<str>` placeholders; the floor stops `git push` vs `git pull`-style merges).
71
72
  - **Bounded memory** — per-session maps are capped (200 sessions) and freed on `session.deleted`; handled part IDs evict FIFO; TTL expiry re-runs every 6 h in long-lived processes.
72
73
  - **Migration** — gates outside the blocking policy are demoted to `watching` automatically; project copies of already-global gates are merged into the global gate (evidence is consolidated, never deleted).
package/index.ts CHANGED
@@ -23,10 +23,6 @@ const TTL_DAYS = 60
23
23
  const TTL_INTERVAL_MS = 6 * 60 * 60 * 1000
24
24
  /** a gate firing this often without killing the error gets flagged for review */
25
25
  const REVIEW_FIRES = 10
26
- /** per-session state maps are capped to bound memory in long-lived processes */
27
- const SESSION_MAP_CAP = 200
28
- /** per-session key sets are capped too — one long session must not grow unbounded */
29
- const SESSION_KEY_CAP = 500
30
26
  /** a "retry" arriving this soon after a reminder was dispatched concurrently with it
31
27
  * (same tool-call burst) and never saw the reminder — it gets reminded as well.
32
28
  * A true agent retry needs a full model turn (≥1s in practice), so 500ms separates both. */
@@ -40,29 +36,6 @@ const PENDING_CAP = 1000
40
36
  /** Sentinel: intentional gate/reminder throws (rethrown); our own bugs are swallowed. */
41
37
  class GateSignal extends Error {}
42
38
 
43
- function addToSetMap(map: Map<string, Set<string>>, outer: string, inner: string): void {
44
- let set = map.get(outer)
45
- if (!set) {
46
- set = new Set()
47
- map.set(outer, set)
48
- }
49
- set.add(inner)
50
- while (set.size > SESSION_KEY_CAP) {
51
- const oldest = set.values().next()
52
- if (oldest.done) break
53
- set.delete(oldest.value)
54
- }
55
- }
56
-
57
- /** Drop oldest entries (Map preserves insertion order) to bound memory. */
58
- function capMap<K, V>(map: Map<K, V>, cap: number): void {
59
- while (map.size > cap) {
60
- const oldest = map.keys().next()
61
- if (oldest.done) break
62
- map.delete(oldest.value)
63
- }
64
- }
65
-
66
39
  function scrubbedArgs(args: Record<string, unknown>): Record<string, unknown> {
67
40
  if (typeof args.command === "string") return { ...args, command: scrubSecrets(args.command) }
68
41
  if (typeof args.pattern === "string") return { ...args, pattern: scrubSecrets(args.pattern) }
@@ -100,10 +73,6 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
100
73
  : null
101
74
  const stores = new Stores(globalStore, projectStore)
102
75
 
103
- /** sessions in which a gate key was already reminded about; value = remind time (race guard) */
104
- const reminded = new Map<string, Map<string, number>>()
105
- /** sessions in which a reminded pattern failed again — next attempt is blocked */
106
- const failedAfterReminder = new Map<string, Set<string>>()
107
76
  /** callID -> signature fallback when the after-hook does not receive args */
108
77
  const pendingCalls = new Map<string, string>()
109
78
  /** message part IDs already counted as tool-level errors */
@@ -197,35 +166,45 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
197
166
  return
198
167
  }
199
168
 
200
- // Repeat offense: reminded in this session, retried, failed again -> hard block.
201
- if (failedAfterReminder.get(session)?.has(gate.key)) {
202
- gate.blockedCount += 1
203
- if (gate.blockedCount >= REVIEW_FIRES) gate.review = true
204
- await found.store.save()
205
- await stores.logAll({ type: "blocked", key: gate.key, tool: gate.tool, session, project: directory, via })
206
- throw new GateSignal(blockMessage(gate, found.store.dir))
207
- }
169
+ // Enforce from FRESH gate state under the store lock. The remind→block
170
+ // chain lives on the gate itself (remindedSessions/failedSessions), so
171
+ // it survives process restarts and is visible to every window serving
172
+ // this session per-process maps lost it on both.
173
+ const target = found
174
+ let signal: GateSignal | null = null
175
+ await target.store.runLocked(async () => {
176
+ const fresh = (await target.store.load(true)).find((g) => g.key === gate.key)
177
+ if (fresh === undefined) return // gate deleted between find and lock
178
+
179
+ // Repeat offense: reminded, retried, failed again -> hard block.
180
+ if (fresh.failedSessions !== undefined && fresh.failedSessions.includes(session)) {
181
+ fresh.blockedCount += 1
182
+ if (fresh.blockedCount >= REVIEW_FIRES) fresh.review = true
183
+ await target.store.save()
184
+ await stores.logAll({ type: "blocked", key: fresh.key, tool: fresh.tool, session, project: directory, via })
185
+ signal = new GateSignal(blockMessage(fresh, target.store.dir))
186
+ return
187
+ }
208
188
 
209
- // First encounter this session -> remind (the call is aborted; agent may retry corrected).
210
- // Race guard: calls dispatched in the same burst all arrive before the agent can
211
- // have seen any reminder, so a "retry" within REMINDER_RACE_WINDOW_MS of the
212
- // remind is itself a concurrent first encounter and gets reminded too.
213
- const sessionReminded = reminded.get(session) ?? new Map<string, number>()
214
- if (!reminded.has(session)) reminded.set(session, sessionReminded)
215
- const remindedAt = sessionReminded.get(gate.key)
216
- if (remindedAt === undefined || Date.now() - remindedAt < REMINDER_RACE_WINDOW_MS) {
217
- sessionReminded.set(gate.key, Date.now())
218
- sessionReminded.set(patternKey(signature), Date.now()) // exact key too: retry may fuzzy-match differently
219
- capMap(sessionReminded, SESSION_KEY_CAP)
220
- capMap(reminded, SESSION_MAP_CAP)
221
- gate.remindedCount += 1
222
- await found.store.save()
223
- await stores.logAll({ type: "reminded", key: gate.key, tool: gate.tool, session, project: directory, via })
224
- throw new GateSignal(remindMessage(gate))
225
- }
189
+ // First encounter this session -> remind (the call is aborted; agent may retry corrected).
190
+ // Race guard: calls dispatched in the same burst all arrive before the agent can
191
+ // have seen any reminder, so a "retry" within REMINDER_RACE_WINDOW_MS of the
192
+ // remind is itself a concurrent first encounter and gets reminded too.
193
+ const remindedAt = fresh.remindedSessions?.[session]
194
+ if (remindedAt === undefined || Date.now() - remindedAt < REMINDER_RACE_WINDOW_MS) {
195
+ if (fresh.remindedSessions === undefined) fresh.remindedSessions = {}
196
+ fresh.remindedSessions[session] = Date.now()
197
+ fresh.remindedCount += 1
198
+ await target.store.save()
199
+ await stores.logAll({ type: "reminded", key: fresh.key, tool: fresh.tool, session, project: directory, via })
200
+ signal = new GateSignal(remindMessage(fresh))
201
+ return
202
+ }
226
203
 
227
- // Already reminded, no repeated failure yet -> allow one retry.
228
- await stores.logAll({ type: "retry-allowed", key: gate.key, tool: gate.tool, session, project: directory, via })
204
+ // Already reminded, no repeated failure yet -> allow one retry.
205
+ await stores.logAll({ type: "retry-allowed", key: fresh.key, tool: fresh.tool, session, project: directory, via })
206
+ })
207
+ if (signal !== null) throw signal
229
208
  } catch (error) {
230
209
  if (error instanceof GateSignal) throw error
231
210
  // Our own bugs must never break the user's tool calls.
@@ -309,21 +288,29 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
309
288
  await logClient("info", `dejavu: gate went global — "${result.gate.signature}"`)
310
289
  }
311
290
 
312
- // Metric: failure of an already-enforced pattern (the event that
313
- // promoted the gate does not count the gate did not exist yet).
314
- if (result.gate.status === "blocking" && !result.promoted) {
315
- result.gate.recurredAfterGate += 1
316
- await result.store.save()
317
- await stores.logAll({ type: "recurred-after-gate", key, tool: input.tool, session, project: directory })
318
- }
319
-
320
- // Same-session repeat after a reminder -> escalate to hard block.
321
- if (reminded.get(session)?.has(key) || reminded.get(session)?.has(result.gate.key)) {
322
- addToSetMap(failedAfterReminder, session, result.gate.key)
323
- capMap(failedAfterReminder, SESSION_MAP_CAP)
324
- result.gate.recurredAfterReminder += 1
325
- await result.store.save()
326
- }
291
+ // Persist escalation state on the gate itself (under the store lock) so
292
+ // every window serving this session sees the same remind→block chain.
293
+ const ownerStore = result.wentGlobal ? stores.globalStore : result.store
294
+ await ownerStore.runLocked(async () => {
295
+ const fresh = (await ownerStore.load(true)).find((g) => g.key === result.gate.key)
296
+ if (fresh === undefined) return
297
+ let changed = false
298
+ // Metric: failure of an already-enforced pattern (the event that
299
+ // promoted the gate does not count the gate did not exist yet).
300
+ if (fresh.status === "blocking" && !result.promoted) {
301
+ fresh.recurredAfterGate += 1
302
+ changed = true
303
+ await stores.logAll({ type: "recurred-after-gate", key: fresh.key, tool: input.tool, session, project: directory })
304
+ }
305
+ // Same-session repeat after a reminder -> escalate to hard block.
306
+ if (fresh.remindedSessions?.[session] !== undefined) {
307
+ if (fresh.failedSessions === undefined) fresh.failedSessions = []
308
+ if (!fresh.failedSessions.includes(session)) fresh.failedSessions.push(session)
309
+ fresh.recurredAfterReminder += 1
310
+ changed = true
311
+ }
312
+ if (changed) await ownerStore.save()
313
+ })
327
314
  } catch {
328
315
  // detection failures must never break the tool pipeline
329
316
  }
@@ -333,12 +320,11 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
333
320
  try {
334
321
  const type = (event as { type?: unknown }).type
335
322
 
336
- // Free per-session state when a session is deleted.
323
+ // Free the persisted per-session state when a session is deleted.
337
324
  if (type === "session.deleted") {
338
325
  const props = (event as { properties?: unknown }).properties as { sessionID?: unknown } | undefined
339
326
  if (typeof props?.sessionID === "string") {
340
- reminded.delete(props.sessionID)
341
- failedAfterReminder.delete(props.sessionID)
327
+ stores.forgetSession(props.sessionID).catch(() => {})
342
328
  }
343
329
  return
344
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Cross-session memory prosthesis for OpenCode: detects recurring tool-call failures and promotes them into enforced gates. Remind first, block on same-session repeat offense.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/AGENTS.md CHANGED
@@ -32,7 +32,8 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
32
32
  - Cross-project evidence lives ONLY in the global `index.json` — a gate's own `projects` array sees one store and never drives escalation alone
33
33
  - Escalation writes the global gate FIRST, then removes the project copy — a crash between the two writes must leave a duplicate (healed by migrate), never a hole
34
34
  - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
35
- - `GateStore.load` caches by mtime after external edits the cache refreshes on next stat; `save()` refreshes it manually
35
+ - Hot-path reads use the 1s TTL cache + key index (`byKey`/`blockingOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
36
+ - The remind→block chain is persisted ON THE GATE (`remindedSessions`/`failedSessions`) and enforced under the store lock — process memory holds nothing authoritative, so several windows and restarts share one escalation
36
37
  - Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
37
38
  - Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
38
39
  - Quarantine preserves bytes: unparseable files are renamed to `*.corrupt-*`, never deleted; every repair emits a `repaired`/`quarantined` log event
package/src/patterns.ts CHANGED
@@ -332,6 +332,11 @@ export function levenshtein(a: string, b: string): number {
332
332
  /** Code fingerprints are IDENTITY, not data — they must match exactly. */
333
333
  const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
334
334
 
335
+ /** Signatures longer than this match exactly only: a 300-char normalized
336
+ * command is already specific enough that "30% near" is meaningless, and
337
+ * Levenshtein on long signatures is the hot-path cost cliff. */
338
+ export const FUZZY_MAX_LEN = 300
339
+
335
340
  /**
336
341
  * Near-duplicate match: normalized edit distance <= 30% AND absolute distance
337
342
  * >= 3. Unlike token-set Jaccard, this does not collapse commands that merely
@@ -350,6 +355,11 @@ export function fuzzySimilar(a: string, b: string): boolean {
350
355
  }
351
356
  const maxLen = Math.max(a.length, b.length)
352
357
  if (maxLen === 0) return true
358
+ if (maxLen > FUZZY_MAX_LEN) return false
359
+ // Triangle inequality: distance >= |lenA - lenB|. If even that floor
360
+ // exceeds the ratio threshold, no Levenshtein result can pass — an O(1)
361
+ // pre-filter with zero false negatives that skips most DP computations.
362
+ if (Math.abs(a.length - b.length) / maxLen > 0.3) return false
353
363
  const distance = levenshtein(a, b)
354
364
  return distance >= 3 && distance / maxLen <= 0.3
355
365
  }
package/src/store.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
- import { canBlock, fuzzySimilar, scrubSecrets } from "./patterns"
3
+ import { canBlock, fuzzySimilar, FUZZY_MAX_LEN, scrubSecrets } from "./patterns"
4
4
  import { coerceGateShape, repairGate } from "./validate"
5
5
 
6
6
  /** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
7
- export const PLUGIN_VERSION = "2.2.1"
7
+ export const PLUGIN_VERSION = "2.3.0"
8
8
 
9
9
  export interface Gate {
10
10
  /** sha1 signature prefix — the pattern identity */
@@ -32,6 +32,12 @@ export interface Gate {
32
32
  recurredAfterGate: number
33
33
  /** flagged for manual review when the gate fires often but errors stopped */
34
34
  review?: boolean
35
+ /** sessions currently reminded about this gate: sessionID -> remind time (ms).
36
+ * Persisted on the gate so the remind→block chain survives process restarts
37
+ * and is visible to every window serving the session. */
38
+ remindedSessions?: Record<string, number>
39
+ /** sessions that failed again after a reminder — their next attempt blocks */
40
+ failedSessions?: string[]
35
41
  }
36
42
 
37
43
  interface GatesFile {
@@ -84,8 +90,12 @@ export interface LogEvent {
84
90
  const MAX_SESSIONS = 50
85
91
  const MAX_PROJECTS = 20
86
92
  const LOG_ROTATE_BYTES = 512 * 1024
93
+ /** the global log aggregates every project — rotate it later or forensics vanish in a day */
94
+ const GLOBAL_LOG_ROTATE_BYTES = 2048 * 1024
87
95
  const LOG_ROTATE_KEEP_LINES = 1000
88
96
  const DAY_MS = 24 * 60 * 60 * 1000
97
+ /** trust the loaded-gates cache this long without re-statting (hot path: every tool call) */
98
+ const LOAD_CACHE_TTL_MS = 1000
89
99
 
90
100
  /** failures required before a pattern becomes an enforced gate */
91
101
  export const PROMOTE_COUNT = 3
@@ -180,6 +190,10 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?:
180
190
  export class GateStore {
181
191
  private gates: Gate[] | null = null
182
192
  private mtimeMs = 0
193
+ /** hot-path caches: valid until LOAD_CACHE_TTL_MS / invalidated on mutation */
194
+ private cacheUntilMs = 0
195
+ private keyIndex: Map<string, Gate> | null = null
196
+ private blockingCache: Gate[] | null = null
183
197
  private index: IndexFile | null = null
184
198
  private indexMtimeMs = 0
185
199
 
@@ -210,9 +224,16 @@ export class GateStore {
210
224
  * dropped, repairable ones coerced — enforcement never sees raw state.
211
225
  */
212
226
  async load(force = false): Promise<Gate[]> {
227
+ // TTL fast path: the hot path (every tool call) must not pay a stat per
228
+ // call. Gates change rarely (promotion, manual edit); 1s staleness is
229
+ // invisible to enforcement and our own saves refresh the cache directly.
230
+ if (!force && this.gates !== null && Date.now() < this.cacheUntilMs) {
231
+ return this.gates
232
+ }
213
233
  try {
214
234
  const info = await stat(ntPath(this.gatesPath))
215
235
  if (!force && this.gates !== null && info.mtimeMs === this.mtimeMs) {
236
+ this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
216
237
  return this.gates
217
238
  }
218
239
  const raw = await readFile(ntPath(this.gatesPath), "utf8")
@@ -226,15 +247,39 @@ export class GateStore {
226
247
  gates.push(gate)
227
248
  }
228
249
  this.gates = gates
250
+ this.keyIndex = new Map(gates.map((g) => [g.key, g]))
251
+ this.blockingCache = gates.filter((g) => g.status === "blocking")
229
252
  this.mtimeMs = info.mtimeMs
253
+ this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
230
254
  return this.gates
231
255
  } catch {
232
256
  // missing or unreadable gates.json — treat as an empty store
233
- if (this.gates === null) this.gates = []
257
+ if (this.gates === null) {
258
+ this.gates = []
259
+ this.keyIndex = new Map()
260
+ this.blockingCache = []
261
+ }
262
+ this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
234
263
  return this.gates
235
264
  }
236
265
  }
237
266
 
267
+ /** O(1) exact lookup over the cached gates (call load() first to refresh). */
268
+ byKey(key: string): Gate | undefined {
269
+ if (this.keyIndex === null) {
270
+ this.keyIndex = new Map((this.gates ?? []).map((g) => [g.key, g]))
271
+ }
272
+ return this.keyIndex.get(key)
273
+ }
274
+
275
+ /** Cached blocking subset — the fuzzy scan iterates this, not all gates. */
276
+ blockingOnly(): Gate[] {
277
+ if (this.blockingCache === null) {
278
+ this.blockingCache = (this.gates ?? []).filter((g) => g.status === "blocking")
279
+ }
280
+ return this.blockingCache
281
+ }
282
+
238
283
  async save(): Promise<void> {
239
284
  if (this.gates === null) return
240
285
  await mkdir(ntPath(this.dir), { recursive: true })
@@ -245,6 +290,9 @@ export class GateStore {
245
290
  } catch {
246
291
  // mtime refresh is best-effort
247
292
  }
293
+ // We know the content we just wrote — refresh the TTL cache directly.
294
+ // (keyIndex/blockingCache hold references into this.gates, still valid.)
295
+ this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
248
296
  }
249
297
 
250
298
  /** Cross-project pattern index; meaningful only on the global store. */
@@ -312,15 +360,19 @@ export class GateStore {
312
360
  extract(keys: Set<string>): Gate[] {
313
361
  if (this.gates === null) return []
314
362
  const removed = this.gates.filter((g) => keys.has(g.key))
315
- if (removed.length > 0) this.gates = this.gates.filter((g) => !keys.has(g.key))
363
+ if (removed.length > 0) {
364
+ this.gates = this.gates.filter((g) => !keys.has(g.key))
365
+ this.keyIndex = null
366
+ this.blockingCache = null
367
+ }
316
368
  return removed
317
369
  }
318
370
 
319
- async rotateLog(): Promise<void> {
371
+ async rotateLog(rotateBytes: number = LOG_ROTATE_BYTES): Promise<void> {
320
372
  await withLock(this.logPath, async () => {
321
373
  try {
322
374
  const info = await stat(ntPath(this.logPath))
323
- if (info.size < LOG_ROTATE_BYTES) return
375
+ if (info.size < rotateBytes) return
324
376
  const raw = await readFile(ntPath(this.logPath), "utf8")
325
377
  const lines = raw.split("\n").filter((l) => l.trim() !== "")
326
378
  const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
@@ -471,7 +523,8 @@ export class Stores {
471
523
  /** True if a pattern with this key exists in any scope (chain attribution). */
472
524
  async hasKey(key: string): Promise<boolean> {
473
525
  for (const store of this.scopes()) {
474
- if ((await store.load()).some((g) => g.key === key)) return true
526
+ await store.load()
527
+ if (store.byKey(key) !== undefined) return true
475
528
  }
476
529
  return false
477
530
  }
@@ -482,13 +535,15 @@ export class Stores {
482
535
  signature: string,
483
536
  ): Promise<{ gate: Gate; store: GateStore; via: "exact" | "fuzzy" } | null> {
484
537
  for (const store of this.scopes()) {
485
- const exact = (await store.load()).find((g) => g.key === key)
538
+ await store.load()
539
+ const exact = store.byKey(key)
486
540
  if (exact) return { gate: exact, store, via: "exact" }
487
541
  }
542
+ // Over-long signatures match exactly only — see FUZZY_MAX_LEN.
543
+ if (signature.length > FUZZY_MAX_LEN) return null
488
544
  let best: { gate: Gate; store: GateStore; score: number } | null = null
489
545
  for (const store of this.scopes()) {
490
- for (const gate of await store.load()) {
491
- if (gate.status !== "blocking") continue
546
+ for (const gate of store.blockingOnly()) {
492
547
  if (!fuzzySimilar(signature, gate.signature)) continue
493
548
  const score = Math.abs(signature.length - gate.signature.length)
494
549
  if (best === null || score < best.score) best = { gate, store, score }
@@ -501,9 +556,8 @@ export class Stores {
501
556
  async blockingGates(): Promise<Gate[]> {
502
557
  const result: Gate[] = []
503
558
  for (const store of this.scopes()) {
504
- for (const gate of await store.load()) {
505
- if (gate.status === "blocking") result.push(gate)
506
- }
559
+ await store.load()
560
+ for (const gate of store.blockingOnly()) result.push(gate)
507
561
  }
508
562
  return result.sort((a, b) => b.count - a.count)
509
563
  }
@@ -541,7 +595,31 @@ export class Stores {
541
595
 
542
596
  async rotateLogs(): Promise<void> {
543
597
  for (const store of this.scopes()) {
544
- await store.rotateLog()
598
+ // The global log aggregates every project — give it more room.
599
+ await store.rotateLog(store === this.globalStore ? GLOBAL_LOG_ROTATE_BYTES : LOG_ROTATE_BYTES)
600
+ }
601
+ }
602
+
603
+ /** Forget per-session enforcement state when a session dies. */
604
+ async forgetSession(sessionID: string): Promise<void> {
605
+ for (const store of this.scopes()) {
606
+ await store.runLocked(async () => {
607
+ const gates = await store.load(true)
608
+ let changed = false
609
+ for (const gate of gates) {
610
+ if (gate.remindedSessions && gate.remindedSessions[sessionID] !== undefined) {
611
+ delete gate.remindedSessions[sessionID]
612
+ if (Object.keys(gate.remindedSessions).length === 0) delete gate.remindedSessions
613
+ changed = true
614
+ }
615
+ if (gate.failedSessions !== undefined && gate.failedSessions.includes(sessionID)) {
616
+ gate.failedSessions = gate.failedSessions.filter((s) => s !== sessionID)
617
+ if (gate.failedSessions.length === 0) delete gate.failedSessions
618
+ changed = true
619
+ }
620
+ }
621
+ if (changed) await store.save()
622
+ })
545
623
  }
546
624
  }
547
625
 
@@ -667,7 +745,11 @@ export class Stores {
667
745
  const index = await this.globalStore.loadIndex(true)
668
746
  let pruned = 0
669
747
  for (const key of Object.keys(index.keys)) {
670
- if (!knownKeys.has(key)) {
748
+ const entry = index.keys[key]
749
+ // Young entries may belong to a promotion in flight in another window
750
+ // (its gates were snapshotted after this key was written) — only prune
751
+ // orphans that have been stale for a day.
752
+ if (entry && !knownKeys.has(key) && Date.now() - Date.parse(entry.lastSeen) > DAY_MS) {
671
753
  delete index.keys[key]
672
754
  pruned += 1
673
755
  }
package/src/validate.ts CHANGED
@@ -11,6 +11,10 @@ import { canBlock, scrubSecrets } from "./patterns"
11
11
  const KEY_SHAPE = /^[0-9a-f]{12}$/
12
12
  /** detection truncates snippets at 200 chars on ingest */
13
13
  const SNIPPET_MAX = 200
14
+ /** per-session enforcement state rots after a day — sessions do not live longer */
15
+ const SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000
16
+ /** bound per-gate session state so long-lived gates cannot bloat */
17
+ const SESSION_STATE_CAP = 50
14
18
 
15
19
  /**
16
20
  * Structural parse of one persisted gate object. Returns a well-shaped Gate
@@ -50,6 +54,17 @@ export function coerceGateShape(raw: unknown): Gate | null {
50
54
  }
51
55
  if (typeof r.correction === "string") gate.correction = r.correction
52
56
  if (r.review === true) gate.review = true
57
+ if (r.remindedSessions !== null && typeof r.remindedSessions === "object" && !Array.isArray(r.remindedSessions)) {
58
+ const sessions: Record<string, number> = {}
59
+ for (const [session, at] of Object.entries(r.remindedSessions as Record<string, unknown>)) {
60
+ if (typeof at === "number" && Number.isFinite(at)) sessions[session] = at
61
+ }
62
+ if (Object.keys(sessions).length > 0) gate.remindedSessions = sessions
63
+ }
64
+ if (Array.isArray(r.failedSessions)) {
65
+ const sessions = r.failedSessions.filter((x): x is string => typeof x === "string")
66
+ if (sessions.length > 0) gate.failedSessions = sessions
67
+ }
53
68
  return gate
54
69
  }
55
70
 
@@ -87,6 +102,33 @@ export function repairGate(gate: Gate): boolean {
87
102
  changed = true
88
103
  }
89
104
  }
105
+ // Per-session enforcement state hygiene: rot stale entries, bound the rest.
106
+ if (gate.remindedSessions !== undefined) {
107
+ const now = Date.now()
108
+ const reminded = gate.remindedSessions
109
+ for (const session of Object.keys(reminded)) {
110
+ if (now - (reminded[session] ?? 0) > SESSION_STATE_TTL_MS) {
111
+ delete reminded[session]
112
+ changed = true
113
+ }
114
+ }
115
+ const sessions = Object.keys(reminded)
116
+ if (sessions.length > SESSION_STATE_CAP) {
117
+ sessions.sort((a, b) => (reminded[a] ?? 0) - (reminded[b] ?? 0))
118
+ for (const session of sessions.slice(0, sessions.length - SESSION_STATE_CAP)) {
119
+ delete reminded[session]
120
+ }
121
+ changed = true
122
+ }
123
+ if (Object.keys(reminded).length === 0) delete gate.remindedSessions
124
+ }
125
+ if (gate.failedSessions !== undefined) {
126
+ if (gate.failedSessions.length > SESSION_STATE_CAP) {
127
+ gate.failedSessions = gate.failedSessions.slice(-SESSION_STATE_CAP)
128
+ changed = true
129
+ }
130
+ if (gate.failedSessions.length === 0) delete gate.failedSessions
131
+ }
90
132
  // Policy is the single source of truth: a blocking gate that cannot block
91
133
  // is a leftover from an older policy and must be demoted.
92
134
  if (gate.status === "blocking" && !canBlock(gate.tool, gate.signature)) {