opencode-dejavu 2.2.0 → 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 +34 -0
- package/README.md +2 -1
- package/index.ts +71 -64
- package/package.json +1 -1
- package/src/AGENTS.md +4 -1
- package/src/patterns.ts +30 -5
- package/src/store.ts +115 -22
- package/src/validate.ts +47 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
21
|
+
## 2.2.1 — 2026-08-24
|
|
22
|
+
|
|
23
|
+
### Fixed (adversarial-review round)
|
|
24
|
+
- Escalation order: the gate is written to the global store BEFORE being removed from the project store — a crash between the two writes leaves a duplicate (healed by migrate), never a hole.
|
|
25
|
+
- `dejavu:proceed` inside quoted strings no longer bypasses gates (`echo "dejavu:proceed" && gated-cmd` stays enforced); the marker is honored only outside quotes.
|
|
26
|
+
- Concurrent first-encounter race: calls dispatched in the same burst as a REMINDER (within 500ms) are reminded too instead of slipping through as a "retry".
|
|
27
|
+
- CRLF/CR commands normalize identically to LF; `splitChain` splits on CR — no more line-ending fragmentation.
|
|
28
|
+
- `normalizeCommand` is fully idempotent: quoted spans are parameterized BEFORE path rules (a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule only on a second pass), fingerprint payloads are trimmed, and already-parameterized payloads are never re-fingerprinted.
|
|
29
|
+
- Interpreter flags glued to their payload (`node -e"code"`) fingerprint identically to the spaced form.
|
|
30
|
+
- Session state maps: inner key sets are capped — long sessions no longer grow unbounded.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- Lock degradation (contention > 3s) emits a `degraded` log event — the only window where concurrent writes can lose updates is now visible.
|
|
34
|
+
- `test/property.ts` — property-based tests for the normalization pipeline (idempotency, no nested tokens, output bound, one-liner distinctness, marker neutrality, splitChain atomicity).
|
|
35
|
+
- `test/fuzz.ts` — seeded mutation fuzzer with a metamorphic oracle and case shrinking; both harnesses run in CI. The harnesses caught the idempotency, marker-neutrality, glued-flag and nested-token-detector bugs above before production did.
|
|
36
|
+
|
|
3
37
|
## 2.2.0 — 2026-08-23
|
|
4
38
|
|
|
5
39
|
### Added
|
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
|
|
|
66
66
|
- **Intended non-zero exits** — exit 1 from diagnostics is NOT a failure (that is their normal "found nothing / found issues" outcome). Exit ≥ 2 always counts.
|
|
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
|
-
- **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.
|
|
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,8 +23,10 @@ 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
|
-
/**
|
|
27
|
-
|
|
26
|
+
/** a "retry" arriving this soon after a reminder was dispatched concurrently with it
|
|
27
|
+
* (same tool-call burst) and never saw the reminder — it gets reminded as well.
|
|
28
|
+
* A true agent retry needs a full model turn (≥1s in practice), so 500ms separates both. */
|
|
29
|
+
const REMINDER_RACE_WINDOW_MS = 500
|
|
28
30
|
/** handled part IDs are capped FIFO-style */
|
|
29
31
|
const HANDLED_CAP = 5000
|
|
30
32
|
const HANDLED_KEEP = 2500
|
|
@@ -34,24 +36,6 @@ const PENDING_CAP = 1000
|
|
|
34
36
|
/** Sentinel: intentional gate/reminder throws (rethrown); our own bugs are swallowed. */
|
|
35
37
|
class GateSignal extends Error {}
|
|
36
38
|
|
|
37
|
-
function addToSetMap(map: Map<string, Set<string>>, outer: string, inner: string): void {
|
|
38
|
-
let set = map.get(outer)
|
|
39
|
-
if (!set) {
|
|
40
|
-
set = new Set()
|
|
41
|
-
map.set(outer, set)
|
|
42
|
-
}
|
|
43
|
-
set.add(inner)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Drop oldest entries (Map preserves insertion order) to bound memory. */
|
|
47
|
-
function capMap(map: Map<string, Set<string>>, cap: number): void {
|
|
48
|
-
while (map.size > cap) {
|
|
49
|
-
const oldest = map.keys().next()
|
|
50
|
-
if (oldest.done) break
|
|
51
|
-
map.delete(oldest.value)
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
39
|
function scrubbedArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
56
40
|
if (typeof args.command === "string") return { ...args, command: scrubSecrets(args.command) }
|
|
57
41
|
if (typeof args.pattern === "string") return { ...args, pattern: scrubSecrets(args.pattern) }
|
|
@@ -89,10 +73,6 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
89
73
|
: null
|
|
90
74
|
const stores = new Stores(globalStore, projectStore)
|
|
91
75
|
|
|
92
|
-
/** sessions in which a gate key was already reminded about */
|
|
93
|
-
const reminded = new Map<string, Set<string>>()
|
|
94
|
-
/** sessions in which a reminded pattern failed again — next attempt is blocked */
|
|
95
|
-
const failedAfterReminder = new Map<string, Set<string>>()
|
|
96
76
|
/** callID -> signature fallback when the after-hook does not receive args */
|
|
97
77
|
const pendingCalls = new Map<string, string>()
|
|
98
78
|
/** message part IDs already counted as tool-level errors */
|
|
@@ -169,7 +149,10 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
169
149
|
const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
|
|
170
150
|
|
|
171
151
|
// Explicit escape hatch — checked only in the actionable text field,
|
|
172
|
-
// with word boundaries, so unrelated args cannot bypass gates.
|
|
152
|
+
// with word boundaries, so unrelated args cannot bypass gates. Quoted
|
|
153
|
+
// spans are stripped first: `echo "dejavu:proceed" && gated-cmd` must
|
|
154
|
+
// NOT bypass the gate on the chained command — the marker is a
|
|
155
|
+
// comment-style annotation, not data.
|
|
173
156
|
const commandText =
|
|
174
157
|
typeof rawArgs.command === "string"
|
|
175
158
|
? rawArgs.command
|
|
@@ -178,33 +161,50 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
178
161
|
: typeof rawArgs.filePath === "string"
|
|
179
162
|
? rawArgs.filePath
|
|
180
163
|
: ""
|
|
181
|
-
if (/\bdejavu:proceed\b/.test(commandText)) {
|
|
164
|
+
if (/\bdejavu:proceed\b/.test(commandText.replace(/"[^"]*"|'[^']*'/g, " "))) {
|
|
182
165
|
await stores.logAll({ type: "override", key: gate.key, tool: gate.tool, session, project: directory })
|
|
183
166
|
return
|
|
184
167
|
}
|
|
185
168
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
188
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
}
|
|
205
203
|
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
208
208
|
} catch (error) {
|
|
209
209
|
if (error instanceof GateSignal) throw error
|
|
210
210
|
// Our own bugs must never break the user's tool calls.
|
|
@@ -288,21 +288,29 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
288
288
|
await logClient("info", `dejavu: gate went global — "${result.gate.signature}"`)
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
await
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
})
|
|
306
314
|
} catch {
|
|
307
315
|
// detection failures must never break the tool pipeline
|
|
308
316
|
}
|
|
@@ -312,12 +320,11 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
312
320
|
try {
|
|
313
321
|
const type = (event as { type?: unknown }).type
|
|
314
322
|
|
|
315
|
-
// Free per-session state when a session is deleted.
|
|
323
|
+
// Free the persisted per-session state when a session is deleted.
|
|
316
324
|
if (type === "session.deleted") {
|
|
317
325
|
const props = (event as { properties?: unknown }).properties as { sessionID?: unknown } | undefined
|
|
318
326
|
if (typeof props?.sessionID === "string") {
|
|
319
|
-
|
|
320
|
-
failedAfterReminder.delete(props.sessionID)
|
|
327
|
+
stores.forgetSession(props.sessionID).catch(() => {})
|
|
321
328
|
}
|
|
322
329
|
return
|
|
323
330
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-dejavu",
|
|
3
|
-
"version": "2.
|
|
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
|
@@ -24,13 +24,16 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
|
|
|
24
24
|
## INVARIANTS (do not break)
|
|
25
25
|
|
|
26
26
|
- Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
|
|
27
|
+
- Rule order in `normalizeCommand` matters too: quoted strings are parameterized BEFORE path rules — a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule on a second pass (idempotency); interpreter payload hashing runs while the payload is still raw
|
|
27
28
|
- `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
|
|
28
29
|
- `canBlock(tool, sig)` = bash && non-diagnostic && not a bare one-liner shape — the ONLY path to `blocking`; probe tools use `PROMOTE_COUNT_PROBE` and never block
|
|
29
30
|
- `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
|
|
30
31
|
- Lock order is always project → global, gates → index (see `recordFailure` escalation) — reversing deadlocks; the log lock is separate and leaf-level
|
|
31
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
|
+
- 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
|
|
32
34
|
- Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
|
|
33
|
-
-
|
|
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
|
|
34
37
|
- Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
|
|
35
38
|
- Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
|
|
36
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
|
@@ -51,16 +51,24 @@ export function scrubSecrets(text: string): string {
|
|
|
51
51
|
* Secrets are scrubbed before hashing so they neither persist nor fragment.
|
|
52
52
|
*/
|
|
53
53
|
const INTERPRETER_ONELINER =
|
|
54
|
-
/(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s
|
|
54
|
+
/(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s*/i
|
|
55
55
|
|
|
56
56
|
function hashInterpreterPayload(command: string): string {
|
|
57
57
|
const match = INTERPRETER_ONELINER.exec(command)
|
|
58
58
|
if (!match) return command
|
|
59
59
|
const payload = command.slice(match.index + match[0].length)
|
|
60
60
|
if (payload.trim() === "") return command
|
|
61
|
+
// Already fingerprinted (re-normalization) — keep the existing token so
|
|
62
|
+
// normalizeCommand stays idempotent.
|
|
63
|
+
if (/^<code:[0-9a-f]+>$/.test(payload.trim())) return command
|
|
64
|
+
// Already-parameterized placeholders are data, not code — never hash them
|
|
65
|
+
// (idempotency: a second pass must not fingerprint a <str>).
|
|
66
|
+
if (/^(?:<(?:str|path|n|hash|uuid|sha|md5|ip|url|email|date)>\s*)+$/.test(payload.trim())) return command
|
|
61
67
|
// For whole (unchained) commands the payload runs to end of string; chain
|
|
62
68
|
// segments are normalized separately, so segment keys stay exact.
|
|
63
|
-
|
|
69
|
+
// Trim before hashing: trailing whitespace (e.g. a stripped override marker)
|
|
70
|
+
// is not part of the code's identity.
|
|
71
|
+
const fingerprint = createHash("sha1").update(scrubSecrets(payload.trim())).digest("hex").slice(0, 8)
|
|
64
72
|
return `${command.slice(0, match.index + match[0].length)}<code:${fingerprint}>`
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -70,11 +78,18 @@ function hashInterpreterPayload(command: string): string {
|
|
|
70
78
|
* away so that "same failure, different instance" collapses into one pattern.
|
|
71
79
|
*/
|
|
72
80
|
export function normalizeCommand(command: string): string {
|
|
73
|
-
|
|
81
|
+
// CRLF/CR commands (Windows pastes, agent multi-line) normalize to LF —
|
|
82
|
+
// otherwise the same command fragments across line-ending styles.
|
|
83
|
+
let s = command.replace(/\r\n?/g, "\n")
|
|
84
|
+
s = s.replace(COMMENT_LINE, "$1").toLowerCase()
|
|
74
85
|
s = hashInterpreterPayload(s)
|
|
86
|
+
// Quoted spans come out FIRST: they are data, and removing them before the
|
|
87
|
+
// path rules keeps normalization idempotent — a <str> replacement inserts
|
|
88
|
+
// spaces that would otherwise expose an adjacent "/" to the path rule only
|
|
89
|
+
// on a second pass.
|
|
90
|
+
s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
|
|
75
91
|
s = s.replace(/[a-z]:[\\/][^\s"']+/gi, " <path> ")
|
|
76
92
|
s = s.replace(/(^|\s)\/[^\s"']+/g, "$1<path> ")
|
|
77
|
-
s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
|
|
78
93
|
// lookbehind: never re-parameterize the <code:...> fingerprint hex
|
|
79
94
|
s = s.replace(/(?<!<code:)\b[0-9a-f]{7,64}\b/gi, " <hash> ")
|
|
80
95
|
s = s.replace(/(?<!<code:)\b\d[\d.]*\b/g, " <n> ")
|
|
@@ -213,7 +228,7 @@ export function splitChain(command: string): string[] {
|
|
|
213
228
|
continue
|
|
214
229
|
}
|
|
215
230
|
if (depth === 0) {
|
|
216
|
-
if (ch === ";" || ch === "\n") {
|
|
231
|
+
if (ch === ";" || ch === "\n" || ch === "\r") {
|
|
217
232
|
flush()
|
|
218
233
|
i += 1
|
|
219
234
|
continue
|
|
@@ -317,6 +332,11 @@ export function levenshtein(a: string, b: string): number {
|
|
|
317
332
|
/** Code fingerprints are IDENTITY, not data — they must match exactly. */
|
|
318
333
|
const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
|
|
319
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
|
+
|
|
320
340
|
/**
|
|
321
341
|
* Near-duplicate match: normalized edit distance <= 30% AND absolute distance
|
|
322
342
|
* >= 3. Unlike token-set Jaccard, this does not collapse commands that merely
|
|
@@ -335,6 +355,11 @@ export function fuzzySimilar(a: string, b: string): boolean {
|
|
|
335
355
|
}
|
|
336
356
|
const maxLen = Math.max(a.length, b.length)
|
|
337
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
|
|
338
363
|
const distance = levenshtein(a, b)
|
|
339
364
|
return distance >= 3 && distance / maxLen <= 0.3
|
|
340
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.
|
|
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 {
|
|
@@ -62,6 +68,7 @@ export type LogEventType =
|
|
|
62
68
|
| "init"
|
|
63
69
|
| "repaired"
|
|
64
70
|
| "quarantined"
|
|
71
|
+
| "degraded"
|
|
65
72
|
|
|
66
73
|
export interface LogEvent {
|
|
67
74
|
type: LogEventType
|
|
@@ -83,8 +90,12 @@ export interface LogEvent {
|
|
|
83
90
|
const MAX_SESSIONS = 50
|
|
84
91
|
const MAX_PROJECTS = 20
|
|
85
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
|
|
86
95
|
const LOG_ROTATE_KEEP_LINES = 1000
|
|
87
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
|
|
88
99
|
|
|
89
100
|
/** failures required before a pattern becomes an enforced gate */
|
|
90
101
|
export const PROMOTE_COUNT = 3
|
|
@@ -137,7 +148,7 @@ const LOCK_WAIT_MS = 3000
|
|
|
137
148
|
* degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
|
|
138
149
|
* critical section runs unlocked rather than hanging the tool pipeline.
|
|
139
150
|
*/
|
|
140
|
-
async function withLock<T>(lockTarget: string, fn: () => Promise<T
|
|
151
|
+
async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?: () => void): Promise<T> {
|
|
141
152
|
const lock = `${lockTarget}.lock`
|
|
142
153
|
await mkdir(ntPath(dirname(lock)), { recursive: true })
|
|
143
154
|
const started = Date.now()
|
|
@@ -157,7 +168,11 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T>
|
|
|
157
168
|
} catch {
|
|
158
169
|
continue // lock vanished between attempts
|
|
159
170
|
}
|
|
160
|
-
if (Date.now() - started > LOCK_WAIT_MS)
|
|
171
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
172
|
+
// The only window where concurrent writes can lose updates — make it visible.
|
|
173
|
+
if (onDegrade) onDegrade()
|
|
174
|
+
break
|
|
175
|
+
}
|
|
161
176
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
162
177
|
}
|
|
163
178
|
}
|
|
@@ -175,6 +190,10 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T>
|
|
|
175
190
|
export class GateStore {
|
|
176
191
|
private gates: Gate[] | null = null
|
|
177
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
|
|
178
197
|
private index: IndexFile | null = null
|
|
179
198
|
private indexMtimeMs = 0
|
|
180
199
|
|
|
@@ -194,7 +213,9 @@ export class GateStore {
|
|
|
194
213
|
|
|
195
214
|
/** Run a load→mutate→save section under the store's exclusive lock. */
|
|
196
215
|
async runLocked<T>(fn: () => Promise<T>): Promise<T> {
|
|
197
|
-
return withLock(this.gatesPath, fn)
|
|
216
|
+
return withLock(this.gatesPath, fn, () => {
|
|
217
|
+
this.log({ type: "degraded", key: "gates.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
|
|
218
|
+
})
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
/**
|
|
@@ -203,9 +224,16 @@ export class GateStore {
|
|
|
203
224
|
* dropped, repairable ones coerced — enforcement never sees raw state.
|
|
204
225
|
*/
|
|
205
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
|
+
}
|
|
206
233
|
try {
|
|
207
234
|
const info = await stat(ntPath(this.gatesPath))
|
|
208
235
|
if (!force && this.gates !== null && info.mtimeMs === this.mtimeMs) {
|
|
236
|
+
this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
|
|
209
237
|
return this.gates
|
|
210
238
|
}
|
|
211
239
|
const raw = await readFile(ntPath(this.gatesPath), "utf8")
|
|
@@ -219,15 +247,39 @@ export class GateStore {
|
|
|
219
247
|
gates.push(gate)
|
|
220
248
|
}
|
|
221
249
|
this.gates = gates
|
|
250
|
+
this.keyIndex = new Map(gates.map((g) => [g.key, g]))
|
|
251
|
+
this.blockingCache = gates.filter((g) => g.status === "blocking")
|
|
222
252
|
this.mtimeMs = info.mtimeMs
|
|
253
|
+
this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
|
|
223
254
|
return this.gates
|
|
224
255
|
} catch {
|
|
225
256
|
// missing or unreadable gates.json — treat as an empty store
|
|
226
|
-
if (this.gates === null)
|
|
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
|
|
227
263
|
return this.gates
|
|
228
264
|
}
|
|
229
265
|
}
|
|
230
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
|
+
|
|
231
283
|
async save(): Promise<void> {
|
|
232
284
|
if (this.gates === null) return
|
|
233
285
|
await mkdir(ntPath(this.dir), { recursive: true })
|
|
@@ -238,6 +290,9 @@ export class GateStore {
|
|
|
238
290
|
} catch {
|
|
239
291
|
// mtime refresh is best-effort
|
|
240
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
|
|
241
296
|
}
|
|
242
297
|
|
|
243
298
|
/** Cross-project pattern index; meaningful only on the global store. */
|
|
@@ -273,7 +328,9 @@ export class GateStore {
|
|
|
273
328
|
|
|
274
329
|
/** Run an index load→mutate→save section under the index's own lock. */
|
|
275
330
|
async runLockedIndex<T>(fn: () => Promise<T>): Promise<T> {
|
|
276
|
-
return withLock(this.indexPath, fn)
|
|
331
|
+
return withLock(this.indexPath, fn, () => {
|
|
332
|
+
this.log({ type: "degraded", key: "index.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
|
|
333
|
+
})
|
|
277
334
|
}
|
|
278
335
|
|
|
279
336
|
/**
|
|
@@ -303,15 +360,19 @@ export class GateStore {
|
|
|
303
360
|
extract(keys: Set<string>): Gate[] {
|
|
304
361
|
if (this.gates === null) return []
|
|
305
362
|
const removed = this.gates.filter((g) => keys.has(g.key))
|
|
306
|
-
if (removed.length > 0)
|
|
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
|
+
}
|
|
307
368
|
return removed
|
|
308
369
|
}
|
|
309
370
|
|
|
310
|
-
async rotateLog(): Promise<void> {
|
|
371
|
+
async rotateLog(rotateBytes: number = LOG_ROTATE_BYTES): Promise<void> {
|
|
311
372
|
await withLock(this.logPath, async () => {
|
|
312
373
|
try {
|
|
313
374
|
const info = await stat(ntPath(this.logPath))
|
|
314
|
-
if (info.size <
|
|
375
|
+
if (info.size < rotateBytes) return
|
|
315
376
|
const raw = await readFile(ntPath(this.logPath), "utf8")
|
|
316
377
|
const lines = raw.split("\n").filter((l) => l.trim() !== "")
|
|
317
378
|
const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
|
|
@@ -462,7 +523,8 @@ export class Stores {
|
|
|
462
523
|
/** True if a pattern with this key exists in any scope (chain attribution). */
|
|
463
524
|
async hasKey(key: string): Promise<boolean> {
|
|
464
525
|
for (const store of this.scopes()) {
|
|
465
|
-
|
|
526
|
+
await store.load()
|
|
527
|
+
if (store.byKey(key) !== undefined) return true
|
|
466
528
|
}
|
|
467
529
|
return false
|
|
468
530
|
}
|
|
@@ -473,13 +535,15 @@ export class Stores {
|
|
|
473
535
|
signature: string,
|
|
474
536
|
): Promise<{ gate: Gate; store: GateStore; via: "exact" | "fuzzy" } | null> {
|
|
475
537
|
for (const store of this.scopes()) {
|
|
476
|
-
|
|
538
|
+
await store.load()
|
|
539
|
+
const exact = store.byKey(key)
|
|
477
540
|
if (exact) return { gate: exact, store, via: "exact" }
|
|
478
541
|
}
|
|
542
|
+
// Over-long signatures match exactly only — see FUZZY_MAX_LEN.
|
|
543
|
+
if (signature.length > FUZZY_MAX_LEN) return null
|
|
479
544
|
let best: { gate: Gate; store: GateStore; score: number } | null = null
|
|
480
545
|
for (const store of this.scopes()) {
|
|
481
|
-
for (const gate of
|
|
482
|
-
if (gate.status !== "blocking") continue
|
|
546
|
+
for (const gate of store.blockingOnly()) {
|
|
483
547
|
if (!fuzzySimilar(signature, gate.signature)) continue
|
|
484
548
|
const score = Math.abs(signature.length - gate.signature.length)
|
|
485
549
|
if (best === null || score < best.score) best = { gate, store, score }
|
|
@@ -492,9 +556,8 @@ export class Stores {
|
|
|
492
556
|
async blockingGates(): Promise<Gate[]> {
|
|
493
557
|
const result: Gate[] = []
|
|
494
558
|
for (const store of this.scopes()) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
559
|
+
await store.load()
|
|
560
|
+
for (const gate of store.blockingOnly()) result.push(gate)
|
|
498
561
|
}
|
|
499
562
|
return result.sort((a, b) => b.count - a.count)
|
|
500
563
|
}
|
|
@@ -532,7 +595,31 @@ export class Stores {
|
|
|
532
595
|
|
|
533
596
|
async rotateLogs(): Promise<void> {
|
|
534
597
|
for (const store of this.scopes()) {
|
|
535
|
-
|
|
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
|
+
})
|
|
536
623
|
}
|
|
537
624
|
}
|
|
538
625
|
|
|
@@ -658,7 +745,11 @@ export class Stores {
|
|
|
658
745
|
const index = await this.globalStore.loadIndex(true)
|
|
659
746
|
let pruned = 0
|
|
660
747
|
for (const key of Object.keys(index.keys)) {
|
|
661
|
-
|
|
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) {
|
|
662
753
|
delete index.keys[key]
|
|
663
754
|
pruned += 1
|
|
664
755
|
}
|
|
@@ -776,9 +867,8 @@ export class Stores {
|
|
|
776
867
|
|
|
777
868
|
let wentGlobal = false
|
|
778
869
|
if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
await store.save()
|
|
870
|
+
// Global FIRST, then remove the local copy: a crash between the two
|
|
871
|
+
// writes must leave a duplicate (healed by migrate), never a hole.
|
|
782
872
|
await this.globalStore.runLocked(async () => {
|
|
783
873
|
const globalGates = await this.globalStore.load(true)
|
|
784
874
|
const existing = globalGates.find((g) => g.key === moved.key)
|
|
@@ -789,6 +879,9 @@ export class Stores {
|
|
|
789
879
|
}
|
|
790
880
|
await this.globalStore.save()
|
|
791
881
|
})
|
|
882
|
+
const idx = gates.findIndex((g) => g.key === moved.key)
|
|
883
|
+
if (idx >= 0) gates.splice(idx, 1)
|
|
884
|
+
await store.save()
|
|
792
885
|
wentGlobal = true
|
|
793
886
|
}
|
|
794
887
|
|
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)) {
|
|
@@ -97,11 +139,11 @@ export function repairGate(gate: Gate): boolean {
|
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
/**
|
|
100
|
-
* Corruption fingerprint of
|
|
101
|
-
* (
|
|
102
|
-
*
|
|
103
|
-
*
|
|
142
|
+
* Corruption fingerprint of a placeholder re-parameterized inside another
|
|
143
|
+
* token (`<code: <n> >` — a fingerprint eaten by the number rule). Only the
|
|
144
|
+
* `<code:` token carries nested content, so the check is scoped to it —
|
|
145
|
+
* shell text like heredoc `<<eof:` must NOT trip the detector.
|
|
104
146
|
*/
|
|
105
147
|
export function hasNestedTokens(signature: string): boolean {
|
|
106
|
-
return /<
|
|
148
|
+
return /<code:\s*</.test(signature)
|
|
107
149
|
}
|