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.
@@ -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
+ }