opencode-dejavu 2.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/index.ts +412 -0
- package/package.json +39 -0
- package/src/AGENTS.md +37 -0
- package/src/patterns.ts +333 -0
- package/src/store.ts +449 -0
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/** Override marker stripped before normalization so bypassed failures land on the original pattern. */
|
|
4
|
+
const OVERRIDE_MARKER = /#?\s*dejavu:proceed/gi
|
|
5
|
+
|
|
6
|
+
/** Agent commentary lines ("# probing the api...") carry no signal — strip them. */
|
|
7
|
+
const COMMENT_LINE = /(^|\n)[ \t]*#[^\n]*/g
|
|
8
|
+
|
|
9
|
+
// --- Secret scrubbing --------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal curated secret/infrastructure patterns (~90% of real-world leaks,
|
|
13
|
+
* zero deps). Applied to every signature and snippet BEFORE persistence.
|
|
14
|
+
*/
|
|
15
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
16
|
+
/sk-proj-\S*/gi, // OpenAI keys incl. fragmented PowerShell continuations ("sk-proj-\")
|
|
17
|
+
/sk-[a-zA-Z0-9_-]{20,}/g, // OpenAI / Anthropic style keys
|
|
18
|
+
/gh[pousr]_[A-Za-z0-9_]{36,}/g, // GitHub PATs
|
|
19
|
+
/github_pat_[A-Za-z0-9_]{22,}[A-Za-z0-9_]{59}/g, // GitHub fine-grained
|
|
20
|
+
/AKIA[A-Z0-9]{16}/g, // AWS access keys
|
|
21
|
+
/xox[baprs]-[0-9A-Za-z-]{10,}/g, // Slack tokens
|
|
22
|
+
/sk_(?:live|test)_[A-Za-z0-9]{24,}/g, // Stripe
|
|
23
|
+
/glpat-[A-Za-z0-9_-]{20,}/g, // GitLab
|
|
24
|
+
/npm_[A-Za-z0-9]{36}/g, // npm tokens
|
|
25
|
+
/PMAK-[A-Za-z0-9-]{20,}/g, // Postman
|
|
26
|
+
/gsk_[A-Za-z0-9]{20,}/g, // Groq
|
|
27
|
+
/Bearer\s+[A-Za-z0-9_.-]{20,}/gi, // bearer tokens
|
|
28
|
+
/\b(?:mongodb|postgres(?:ql)?|mysql|redis|amqp):\/\/[^:\s"']+:[^@\s"']+@[^\s"']+/gi, // db conn strings
|
|
29
|
+
/-----BEGIN\s+(?:[A-Z]+\s+)?PRIVATE KEY-----[\s\S]*?(?:-----END\s+(?:[A-Z]+\s+)?PRIVATE KEY-----|$)/g, // PEM private keys — full block incl. base64 body
|
|
30
|
+
/\bAIza[0-9A-Za-z_-]{35}/g, // Google API keys
|
|
31
|
+
/\b[A-Z][A-Z0-9_]{2,}=[A-Za-z0-9+=_-]{20,}/g, // .env-style KEY=<long-secret> assignments
|
|
32
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWTs
|
|
33
|
+
/\broot@[\w.-]+/gi, // ssh root@host — infrastructure exposure
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
export function scrubSecrets(text: string): string {
|
|
37
|
+
let s = text
|
|
38
|
+
for (const rule of SECRET_PATTERNS) {
|
|
39
|
+
s = s.replace(rule, "<redacted>")
|
|
40
|
+
}
|
|
41
|
+
return s
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Normalization -----------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a bash command into a stable signature.
|
|
48
|
+
* Paths, numbers, quoted strings, hashes and agent comments are abstracted
|
|
49
|
+
* away so that "same failure, different instance" collapses into one pattern.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeCommand(command: string): string {
|
|
52
|
+
let s = command.replace(COMMENT_LINE, "$1").toLowerCase()
|
|
53
|
+
s = s.replace(/[a-z]:[\\/][^\s"']+/gi, " <path> ")
|
|
54
|
+
s = s.replace(/(^|\s)\/[^\s"']+/g, "$1<path> ")
|
|
55
|
+
s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
|
|
56
|
+
s = s.replace(/\b[0-9a-f]{7,64}\b/gi, " <hash> ")
|
|
57
|
+
s = s.replace(/\b\d[\d.]*\b/g, " <n> ")
|
|
58
|
+
s = s.replace(/\s+/g, " ").trim()
|
|
59
|
+
return s
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sentry-style parameterization for free-form error text (event channel).
|
|
64
|
+
* Same root cause must collapse to one signature regardless of variable data.
|
|
65
|
+
* Order matters: quoted strings first, then specific tokens, numbers last.
|
|
66
|
+
*/
|
|
67
|
+
const PARAM_RULES: [RegExp, string][] = [
|
|
68
|
+
[/"[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'/g, "<str>"], // unrolled loop: no catastrophic backtracking
|
|
69
|
+
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<uuid>"],
|
|
70
|
+
[/\b[0-9a-f]{40}\b/gi, "<sha>"],
|
|
71
|
+
[/\b[0-9a-f]{32}\b/gi, "<md5>"],
|
|
72
|
+
[/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b/g, "<ip>"],
|
|
73
|
+
[/\bhttps?:\/\/[^\s"'<>]+/gi, "<url>"],
|
|
74
|
+
[/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "<email>"],
|
|
75
|
+
[/\b\d{4}-\d{2}-\d{2}([t ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(z|[+-]\d{2}:?\d{2})?)?/gi, "<date>"],
|
|
76
|
+
[/\b[a-z]:[\\/][^\s"'<>|]+/gi, "<path>"],
|
|
77
|
+
[/(^|\s)\/[^\s"'<>|]+/g, "$1<path>"],
|
|
78
|
+
[/\b[0-9a-f]{7,64}\b/gi, "<hash>"],
|
|
79
|
+
[/\b\d{2,}\b/g, "<n>"],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
export function parameterizeError(text: string): string {
|
|
83
|
+
let s = text.toLowerCase()
|
|
84
|
+
for (const [rule, token] of PARAM_RULES) {
|
|
85
|
+
s = s.replace(rule, token)
|
|
86
|
+
}
|
|
87
|
+
return s.replace(/\s+/g, " ").trim()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Intended non-zero exits / diagnostic detection --------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Verb patterns of diagnostic commands: their exit 1 is a NORMAL, intended
|
|
94
|
+
* outcome (no match / findings / failed tests during development), not a
|
|
95
|
+
* mistake. Used both for raw commands (exit-code allowlist) and normalized
|
|
96
|
+
* signatures (blocking policy), so there is one source of truth.
|
|
97
|
+
*/
|
|
98
|
+
const DIAGNOSTIC_VERBS: RegExp[] = [
|
|
99
|
+
/(^|[\s|;&:])(grep|rg|findstr|select-string)\b/i,
|
|
100
|
+
/\bgit grep\b/i,
|
|
101
|
+
/(^|[\s|;&:])diff\b/i,
|
|
102
|
+
/\b(pytest|jest|vitest|mocha|cucumbertest)\b/i,
|
|
103
|
+
/\bplaywright test\b/i,
|
|
104
|
+
/\bflutter (test|analyze)\b/i,
|
|
105
|
+
/\bdart (analyze|format|fix)\b/i,
|
|
106
|
+
/\bgradlew\b[^\n;|&]*(test|compilejava|compiletestjava)/i,
|
|
107
|
+
/\b(eslint|prettier --check)\b/i,
|
|
108
|
+
/\btsc\b/i,
|
|
109
|
+
/\bcurl\b/i,
|
|
110
|
+
/\bls\b/i,
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
export function isDiagnosticText(text: string): boolean {
|
|
114
|
+
return DIAGNOSTIC_VERBS.some((rule) => rule.test(text))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isDiagnosticSignature(signature: string): boolean {
|
|
118
|
+
return isDiagnosticText(signature)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** OpenCode normalizes non-zero exits to 1 in metadata, so discriminate by command shape. */
|
|
122
|
+
export function isIntendedNonzero(command: string, exitCode: number): boolean {
|
|
123
|
+
return exitCode === 1 && isDiagnosticText(command)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Blocking policy: only bash commands that are NOT diagnostics may ever
|
|
128
|
+
* become enforced gates. File probes and diagnostic queries are measured
|
|
129
|
+
* (watching) but never interrupt the agent — the data showed blocking them
|
|
130
|
+
* punishes normal work.
|
|
131
|
+
*/
|
|
132
|
+
export function canBlock(tool: string, signature: string): boolean {
|
|
133
|
+
return tool === "bash" && !isDiagnosticSignature(signature)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- Chain splitting ---------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Quote- and paren-aware split of a command chain: &&, ||, ;, | and newlines
|
|
140
|
+
* separate segments ONLY at paren depth 0. A gate on a single command must
|
|
141
|
+
* also fire when that command hides inside "git status && rm -rf /", but
|
|
142
|
+
* "(cd /tmp && ls)" stays one segment.
|
|
143
|
+
*/
|
|
144
|
+
export function splitChain(command: string): string[] {
|
|
145
|
+
const segments: string[] = []
|
|
146
|
+
let current = ""
|
|
147
|
+
let quote: string | null = null
|
|
148
|
+
let depth = 0
|
|
149
|
+
const flush = (): void => {
|
|
150
|
+
const trimmed = current.trim()
|
|
151
|
+
if (trimmed !== "") segments.push(trimmed)
|
|
152
|
+
current = ""
|
|
153
|
+
}
|
|
154
|
+
let i = 0
|
|
155
|
+
while (i < command.length) {
|
|
156
|
+
const ch = command.charAt(i)
|
|
157
|
+
const next = command.charAt(i + 1)
|
|
158
|
+
if (quote !== null) {
|
|
159
|
+
current += ch
|
|
160
|
+
if (ch === quote) quote = null
|
|
161
|
+
i += 1
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (ch === '"' || ch === "'") {
|
|
165
|
+
quote = ch
|
|
166
|
+
current += ch
|
|
167
|
+
i += 1
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (ch === "(") {
|
|
171
|
+
depth += 1
|
|
172
|
+
current += ch
|
|
173
|
+
i += 1
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (ch === ")") {
|
|
177
|
+
depth = Math.max(0, depth - 1)
|
|
178
|
+
current += ch
|
|
179
|
+
i += 1
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (depth === 0) {
|
|
183
|
+
if (ch === ";" || ch === "\n") {
|
|
184
|
+
flush()
|
|
185
|
+
i += 1
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
if (ch === "&" && next === "&") {
|
|
189
|
+
flush()
|
|
190
|
+
i += 2
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (ch === "|") {
|
|
194
|
+
flush()
|
|
195
|
+
i += next === "|" ? 2 : 1
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
current += ch
|
|
200
|
+
i += 1
|
|
201
|
+
}
|
|
202
|
+
flush()
|
|
203
|
+
return segments
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Per-segment signatures for a bash command (bypass protection for chains). */
|
|
207
|
+
export function bashSegmentSignatures(command: string): string[] {
|
|
208
|
+
const clean = command.replace(OVERRIDE_MARKER, "")
|
|
209
|
+
return splitChain(clean).map((segment) => `bash:${normalizeCommand(segment)}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Normalize a file path: keep basename + extension, drop directories. */
|
|
213
|
+
export function normalizeFilePath(filePath: string): string {
|
|
214
|
+
const unified = filePath.replace(/\\/g, "/")
|
|
215
|
+
const base = unified.split("/").pop() ?? unified
|
|
216
|
+
return base.toLowerCase()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Stable identity of a planned tool call for recurrence matching.
|
|
221
|
+
* For bash this is the WHOLE command; use bashSegmentSignatures() in
|
|
222
|
+
* addition when matching gates. Returns null for tools we do not track.
|
|
223
|
+
*/
|
|
224
|
+
export function callSignature(tool: string, args: Record<string, unknown>): string | null {
|
|
225
|
+
switch (tool) {
|
|
226
|
+
case "bash": {
|
|
227
|
+
const command = args.command
|
|
228
|
+
return typeof command === "string" && command.trim() !== ""
|
|
229
|
+
? `bash:${normalizeCommand(command.replace(OVERRIDE_MARKER, ""))}`
|
|
230
|
+
: null
|
|
231
|
+
}
|
|
232
|
+
case "read":
|
|
233
|
+
case "edit":
|
|
234
|
+
case "write": {
|
|
235
|
+
const filePath = args.filePath
|
|
236
|
+
return typeof filePath === "string" && filePath.trim() !== ""
|
|
237
|
+
? `${tool}:${normalizeFilePath(filePath)}`
|
|
238
|
+
: null
|
|
239
|
+
}
|
|
240
|
+
case "glob":
|
|
241
|
+
case "grep": {
|
|
242
|
+
const pattern = args.pattern
|
|
243
|
+
return typeof pattern === "string" && pattern.trim() !== ""
|
|
244
|
+
? `${tool}:${pattern.toLowerCase().replace(/\s+/g, " ").trim()}`
|
|
245
|
+
: null
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function patternKey(signature: string): string {
|
|
253
|
+
return createHash("sha1").update(signature).digest("hex").slice(0, 12)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Fuzzy matching ----------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export function levenshtein(a: string, b: string): number {
|
|
259
|
+
if (a === b) return 0
|
|
260
|
+
const m = a.length
|
|
261
|
+
const n = b.length
|
|
262
|
+
if (m === 0) return n
|
|
263
|
+
if (n === 0) return m
|
|
264
|
+
let prev = new Array<number>(n + 1)
|
|
265
|
+
let curr = new Array<number>(n + 1)
|
|
266
|
+
for (let j = 0; j <= n; j++) prev[j] = j
|
|
267
|
+
for (let i = 1; i <= m; i++) {
|
|
268
|
+
curr[0] = i
|
|
269
|
+
const ca = a.charAt(i - 1)
|
|
270
|
+
for (let j = 1; j <= n; j++) {
|
|
271
|
+
const cost = ca === b.charAt(j - 1) ? 0 : 1
|
|
272
|
+
const del = (prev[j] ?? 0) + 1
|
|
273
|
+
const ins = (curr[j - 1] ?? 0) + 1
|
|
274
|
+
const sub = (prev[j - 1] ?? 0) + cost
|
|
275
|
+
curr[j] = Math.min(del, ins, sub)
|
|
276
|
+
}
|
|
277
|
+
const tmp = prev
|
|
278
|
+
prev = curr
|
|
279
|
+
curr = tmp
|
|
280
|
+
}
|
|
281
|
+
return prev[n] ?? 0
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Near-duplicate match: normalized edit distance <= 30% AND absolute distance
|
|
286
|
+
* >= 3. Unlike token-set Jaccard, this does not collapse commands that merely
|
|
287
|
+
* share placeholder tokens; the absolute floor stops verb-level-different
|
|
288
|
+
* commands ("git push <str>" vs "git pull <str>" = distance 2) from merging.
|
|
289
|
+
*/
|
|
290
|
+
export function fuzzySimilar(a: string, b: string): boolean {
|
|
291
|
+
if (a === b) return true
|
|
292
|
+
const maxLen = Math.max(a.length, b.length)
|
|
293
|
+
if (maxLen === 0) return true
|
|
294
|
+
const distance = levenshtein(a, b)
|
|
295
|
+
return distance >= 3 && distance / maxLen <= 0.3
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- Failure detection -------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
export interface FailureDetection {
|
|
301
|
+
matched: boolean
|
|
302
|
+
snippet: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Conservative failure signatures scanned line-by-line in BASH output only.
|
|
307
|
+
* File-tool output is file CONTENT — scanning it for "TypeError" produced
|
|
308
|
+
* dozens of false gates on legitimate reads; file tools are covered by the
|
|
309
|
+
* event channel instead.
|
|
310
|
+
*/
|
|
311
|
+
const FAILURE_SIGNATURES: RegExp[] = [
|
|
312
|
+
/exit code:?\s*[1-9]\d*/i,
|
|
313
|
+
/\berror TS\d+\b/,
|
|
314
|
+
/\bENOENT\b|\bEACCES\b|\bEPERM\b/,
|
|
315
|
+
/command not found/i,
|
|
316
|
+
/is not recognized as an internal or external command/i,
|
|
317
|
+
/\b(SyntaxError|TypeError|ReferenceError|AssertionError)\b/,
|
|
318
|
+
/Tests:\s+\d+\s+failed/i,
|
|
319
|
+
/\bFAIL\s+\S/,
|
|
320
|
+
/thread '[^']*' panicked/,
|
|
321
|
+
/\bFATAL\b/,
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
export function detectFailure(outputText: string): FailureDetection {
|
|
325
|
+
for (const line of outputText.split("\n")) {
|
|
326
|
+
for (const signature of FAILURE_SIGNATURES) {
|
|
327
|
+
if (signature.test(line)) {
|
|
328
|
+
return { matched: true, snippet: line.trim().slice(0, 200) }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { matched: false, snippet: "" }
|
|
333
|
+
}
|