opencode-v2-guard 0.0.0-stage → 1.0.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/src/guard.ts ADDED
@@ -0,0 +1,431 @@
1
+ import { Error as ToolError } from "@opencode/plugin/promise/tool"
2
+ import type { Plugin } from "@opencode/plugin"
3
+ import { createHash } from "node:crypto"
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
5
+ import { homedir } from "node:os"
6
+ import { join } from "node:path"
7
+ import { activePatterns, type Pattern } from "./patterns.ts"
8
+ import { redact, scan } from "./scan.ts"
9
+ import { isPathBlocked, isPathExempt, isPathWriteProtected } from "./glob.ts"
10
+ import type { GuardConfig } from "./config.ts"
11
+ import { appendAudit, type AuditEvent } from "./audit.ts"
12
+ import { scanSkillDir } from "./skillcheck.ts"
13
+ import { checkInstall } from "./deps.ts"
14
+
15
+ type Ctx = Plugin.Context
16
+
17
+ const PATH_KEY = /^(filePath|path|file|directory|dir|folder|uri)$/i
18
+ const WRITE_TOOLS = new Set(["write", "edit", "patch"])
19
+ const SHELL_TOOLS = new Set(["shell", "bash"])
20
+ /** never rewrite these (matching anchors must stay byte-identical to the file) */
21
+ const SKIP_REDACT_KEYS = new Set(["oldString", "old_string", "oldText"])
22
+
23
+ function audit(cfg: GuardConfig, ev: AuditEvent): void {
24
+ if (!cfg.audit.enabled) return
25
+ appendAudit(cfg.audit.dir, ev)
26
+ }
27
+
28
+ function veto(message: string): never {
29
+ throw new ToolError({ message })
30
+ }
31
+
32
+ function isRecord(v: unknown): v is Record<string, unknown> {
33
+ return typeof v === "object" && v !== null && !Array.isArray(v)
34
+ }
35
+
36
+ function keepValue(key: string): boolean {
37
+ return SKIP_REDACT_KEYS.has(key) || PATH_KEY.test(key)
38
+ }
39
+
40
+ /**
41
+ * Recursively count (and optionally mask) secret matches in string values.
42
+ * Skips match-anchors and path-like keys (paths are policy-checked, not rewritten).
43
+ */
44
+ function deepRedact(value: unknown, patterns: readonly Pattern[], mutate: boolean, skipKey?: string): number {
45
+ if (typeof value === "string") {
46
+ if (skipKey && keepValue(skipKey)) return 0
47
+ return redact(value, patterns).matches.length
48
+ }
49
+ if (Array.isArray(value)) {
50
+ let n = 0
51
+ for (let i = 0; i < value.length; i++) n += deepRedact(value[i], patterns, mutate)
52
+ return n
53
+ }
54
+ if (isRecord(value)) {
55
+ let n = 0
56
+ for (const [k, v] of Object.entries(value)) {
57
+ if (typeof v === "string") {
58
+ if (!keepValue(k)) {
59
+ const r = redact(v, patterns)
60
+ if (r.matches.length > 0) {
61
+ if (mutate) value[k] = r.text
62
+ n += r.matches.length
63
+ }
64
+ }
65
+ } else {
66
+ n += deepRedact(v, patterns, mutate, k)
67
+ }
68
+ }
69
+ return n
70
+ }
71
+ return 0
72
+ }
73
+
74
+ /** Collect strings stored under path-like keys. */
75
+ function pathStrings(value: unknown, out: string[] = []): string[] {
76
+ if (Array.isArray(value)) {
77
+ for (const v of value) pathStrings(v, out)
78
+ return out
79
+ }
80
+ if (isRecord(value)) {
81
+ for (const [k, v] of Object.entries(value)) {
82
+ if (typeof v === "string" && PATH_KEY.test(k)) out.push(v)
83
+ else pathStrings(v, out)
84
+ }
85
+ }
86
+ return out
87
+ }
88
+
89
+ /** Extract target paths from unified-diff ---/+++ headers in patch text. */
90
+ function patchPaths(text: string): string[] {
91
+ const out: string[] = []
92
+ for (const line of text.split("\n")) {
93
+ const m = /^(?:---|\+\+\+)\s+"?([^\s"]+)"?/.exec(line)
94
+ if (m && m[1] !== "/dev/null") out.push(m[1].replace(/^[ab]\//, ""))
95
+ }
96
+ return out
97
+ }
98
+
99
+ function commandTokens(command: string): string[] {
100
+ return command
101
+ .split(/[\s"'`;&|()<>]+/)
102
+ .flatMap((t) => t.split("="))
103
+ .map((t) => t.replace(/^[<>]+/, ""))
104
+ .filter((t) => t.length > 0)
105
+ }
106
+
107
+ function checkCommandPaths(command: string, cfg: GuardConfig): string | undefined {
108
+ // limitation: ~, $VAR and shell globs are not expanded here — the runtime
109
+ // shell may still resolve them to blocked paths. Tracked as a known gap.
110
+ for (const token of commandTokens(command)) {
111
+ if (isPathBlocked(token, cfg)) return token
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ function categoriesOf(matches: readonly { category: string }[]): string[] {
117
+ return [...new Set(matches.map((m) => m.category))].sort()
118
+ }
119
+
120
+ function uriToPath(uri: string): string | undefined {
121
+ if (!uri.startsWith("file://")) return undefined
122
+ const raw = uri.slice("file://".length)
123
+ try {
124
+ return decodeURIComponent(raw)
125
+ } catch {
126
+ return raw
127
+ }
128
+ }
129
+
130
+ function trackHook(reg: Promise<{ dispose(): Promise<void> }>): void {
131
+ void reg.then(
132
+ (r) => tracked.push(r),
133
+ (err) => console.error("guard: hook registration failed", err),
134
+ )
135
+ }
136
+
137
+ const tracked: { dispose(): Promise<void> }[] = []
138
+
139
+ /** recent deps-audit dedup: tool.before and shell.create.before both see the same command */
140
+ const DEPS_AUDIT_TTL_MS = 2000
141
+ const depsAuditRecent = new Map<string, number>()
142
+
143
+ export function registerGuard(ctx: Ctx, cfg: GuardConfig): () => Promise<void> {
144
+ const patterns = activePatterns(cfg.disabledPatterns)
145
+ const now = () => new Date().toISOString()
146
+
147
+ // --- startup scan of installed skills (warn-only, never blocks plugin load) ---
148
+ if (cfg.scanSkillsOnLoad) {
149
+ try {
150
+ const dir = join(homedir(), ".config", "opencode", "skills")
151
+ const findings = scanSkillDir(dir, patterns, cfg.exemptSkills)
152
+ // hot-reload: skip re-audit when the findings summary is unchanged
153
+ const hash = createHash("sha256").update(JSON.stringify(findings)).digest("hex")
154
+ const marker = join(cfg.audit.dir, "guard-skillscan.state")
155
+ let prev: string | null = null
156
+ try {
157
+ prev = readFileSync(marker, "utf8")
158
+ } catch {
159
+ prev = null
160
+ }
161
+ if (prev !== hash) {
162
+ try {
163
+ mkdirSync(cfg.audit.dir, { recursive: true })
164
+ writeFileSync(marker, hash, "utf8")
165
+ } catch {
166
+ // state file is best-effort
167
+ }
168
+ for (const f of findings) {
169
+ audit(cfg, { ts: now(), kind: "skill", categories: f.categories, count: f.count, action: "warn" })
170
+ }
171
+ if (findings.length > 0) {
172
+ console.warn(`guard: ${findings.length} скилл(ов) с подозрительным содержимым: ${findings.map((f) => f.skill).join(", ")} — детали в аудите`)
173
+ }
174
+ }
175
+ } catch {
176
+ // never crash plugin load
177
+ }
178
+ }
179
+
180
+ // --- user prompts ---
181
+ if (cfg.scanPrompts) {
182
+ trackHook(
183
+ ctx.session.hook("prompt", (event) => {
184
+ const mutate = cfg.mode === "enforce"
185
+ const action: AuditEvent["action"] = mutate ? "redacted" : "warn"
186
+
187
+ // attachment path policy: a blocked file must not enter the context
188
+ const files = event.prompt.files ?? []
189
+ for (let i = files.length - 1; i >= 0; i--) {
190
+ const p = uriToPath(files[i].uri)
191
+ if (p && isPathBlocked(p, cfg)) {
192
+ if (mutate) files.splice(i, 1)
193
+ audit(cfg, { ts: now(), kind: "prompt", sessionID: event.sessionID, categories: ["path"], count: 1, action })
194
+ }
195
+ }
196
+
197
+ const text = event.prompt.text
198
+ if (!text) return
199
+ const r = redact(text, patterns)
200
+ if (r.matches.length === 0) return
201
+ if (mutate) {
202
+ event.prompt.text = r.text
203
+ // fix mention offsets (files/agents/skills) invalidated by the rewrite
204
+ const delta = (pos: number) =>
205
+ r.matches.filter((m) => m.start < pos).reduce((d, m) => d + (m.mask.length - (m.end - m.start)), 0)
206
+ const mentioned = [
207
+ ...(event.prompt.files ?? []),
208
+ ...((event.prompt as { agents?: { mention?: { start: number; end: number; text: string } | undefined }[] }).agents ?? []),
209
+ ...((event.prompt as { skills?: { mention?: { start: number; end: number; text: string } | undefined }[] }).skills ?? []),
210
+ ]
211
+ for (const item of mentioned) {
212
+ const m = item.mention
213
+ if (!m) continue
214
+ if (r.matches.some((x) => x.start < m.end && x.end > m.start)) {
215
+ item.mention = undefined
216
+ } else {
217
+ item.mention = { ...m, start: m.start + delta(m.start), end: m.end + delta(m.end) }
218
+ }
219
+ }
220
+ }
221
+ audit(cfg, { ts: now(), kind: "prompt", sessionID: event.sessionID, categories: categoriesOf(r.matches), count: r.matches.length, action })
222
+ }),
223
+ )
224
+ }
225
+
226
+ // --- tool calls ---
227
+ trackHook(
228
+ ctx.tool.hook("execute.before", (event) => {
229
+ const tool = event.tool
230
+ const input = event.input
231
+ const mutate = cfg.mode === "enforce"
232
+ const action: AuditEvent["action"] = mutate ? "blocked" : "warn"
233
+
234
+ // path policy (per path: isPathBlocked is exemption-aware)
235
+ const paths = pathStrings(input)
236
+ if (tool === "patch" && isRecord(input) && typeof input.patchText === "string") {
237
+ paths.push(...patchPaths(input.patchText))
238
+ }
239
+ for (const p of paths) {
240
+ if (isPathBlocked(p, cfg)) {
241
+ audit(cfg, { ts: now(), kind: "tool.before", tool, sessionID: event.sessionID, categories: ["path"], count: 1, action })
242
+ if (mutate) veto(`guard: доступ к '${p}' заблокирован — путь в списке запрещённых (секреты/ПДн). Попроси пользователя явно одобрить работу с этим файлом.`)
243
+ return
244
+ }
245
+ if (WRITE_TOOLS.has(tool) && isPathWriteProtected(p, cfg)) {
246
+ audit(cfg, { ts: now(), kind: "tool.before", tool, sessionID: event.sessionID, categories: ["path"], count: 1, action })
247
+ if (mutate) veto(`guard: запись в '${p}' заблокирована — путь защищён от изменений.`)
248
+ return
249
+ }
250
+ }
251
+
252
+ if (SHELL_TOOLS.has(tool) && isRecord(input) && typeof input.command === "string") {
253
+ const blockedPath = checkCommandPaths(input.command, cfg)
254
+ if (blockedPath) {
255
+ audit(cfg, { ts: now(), kind: "tool.before", tool, sessionID: event.sessionID, categories: ["path"], count: 1, action })
256
+ if (mutate) veto(`guard: команда обращается к '${blockedPath}' — доступ запрещён.`)
257
+ return
258
+ }
259
+ if (cfg.depsCheck) {
260
+ const verdict = checkInstall(input.command, cfg.blockedPackages, cfg.allowedPackages)
261
+ if (verdict) {
262
+ const cat = verdict.reason === "blocklist" ? "malicious-package" : "typosquat"
263
+ depsAuditRecent.set(input.command, Date.now())
264
+ if (depsAuditRecent.size > 100) depsAuditRecent.clear()
265
+ audit(cfg, { ts: now(), kind: "deps", tool, sessionID: event.sessionID, categories: [cat], count: 1, action })
266
+ if (mutate) {
267
+ const why =
268
+ verdict.reason === "blocklist"
269
+ ? "пакет в списке известных вредоносных (инциденты цепочки поставок)"
270
+ : `имя похоже на '${verdict.target}' (typosquat)`
271
+ veto(`guard: установка '${verdict.name}' заблокирована — ${why}. Если установка легитимна — попроси пользователя выполнить её вручную.`)
272
+ }
273
+ return
274
+ }
275
+ }
276
+ }
277
+
278
+ // secret redaction on mutating input (only when every involved path is exempt)
279
+ if (cfg.redactOnWrite) {
280
+ const allExempt = paths.length > 0 && paths.every((p) => isPathExempt(p, cfg))
281
+ if (!allExempt) {
282
+ const count = deepRedact(input, patterns, mutate)
283
+ if (count > 0) {
284
+ audit(cfg, { ts: now(), kind: "tool.before", tool, sessionID: event.sessionID, categories: ["secret"], count, action: mutate ? "redacted" : "warn" })
285
+ }
286
+ }
287
+ }
288
+ }),
289
+ )
290
+
291
+ // --- tool results (secrets must not flow into context/logs) ---
292
+ trackHook(
293
+ ctx.tool.hook("execute.after", (event) => {
294
+ const mutate = cfg.mode === "enforce"
295
+
296
+ if (event.status === "error") {
297
+ // bash providers often echo the failing command/stderr into the message
298
+ const msg = event.error?.message
299
+ if (typeof msg === "string") {
300
+ const r = redact(msg, patterns)
301
+ if (r.matches.length > 0) {
302
+ if (mutate) event.error = new ToolError({ message: r.text })
303
+ audit(cfg, { ts: now(), kind: "tool.after", tool: event.tool, sessionID: event.sessionID, categories: categoriesOf(r.matches), count: r.matches.length, action: mutate ? "redacted" : "warn" })
304
+ }
305
+ }
306
+ return
307
+ }
308
+
309
+ const result = event.result as { content?: unknown; output?: unknown; metadata?: unknown }
310
+ const cats = new Set<string>()
311
+ let n = 0
312
+
313
+ const scrubText = (s: string): string => {
314
+ const r = redact(s, patterns)
315
+ if (r.matches.length > 0) {
316
+ n += r.matches.length
317
+ for (const m of r.matches) cats.add(m.category)
318
+ return mutate ? r.text : s
319
+ }
320
+ return s
321
+ }
322
+
323
+ const content = result.content
324
+ if (typeof content === "string") {
325
+ result.content = scrubText(content)
326
+ } else if (Array.isArray(content)) {
327
+ for (const part of content) {
328
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
329
+ part.text = scrubText(part.text)
330
+ }
331
+ }
332
+ }
333
+
334
+ // content was already scrubbed above — scan the remainder (output/metadata)
335
+ // so matches are counted once
336
+ const rest = { ...result, content: undefined }
337
+ n += deepRedact(rest, patterns, mutate)
338
+ if (n > 0) {
339
+ audit(cfg, { ts: now(), kind: "tool.after", tool: event.tool, sessionID: event.sessionID, categories: [...cats].sort(), count: n, action: mutate ? "redacted" : "warn" })
340
+ }
341
+ }),
342
+ )
343
+
344
+ // --- shell backstop: every shell creation, including background shells ---
345
+ trackHook(
346
+ ctx.shell.hook("create.before", (event) => {
347
+ const mutate = cfg.mode === "enforce"
348
+ const blockedPath = checkCommandPaths(event.command, cfg)
349
+ if (blockedPath) {
350
+ // no throw here: shell hooks cannot veto, only tool.execute.before can.
351
+ // neutralize the command instead, then audit the guaranteed block.
352
+ if (mutate) {
353
+ const safe = blockedPath.replace(/["'`<>|&]/g, "")
354
+ event.command = `echo "guard: blocked access to '${safe}'" && exit 1`
355
+ }
356
+ audit(cfg, { ts: now(), kind: "shell", categories: ["path"], count: 1, action: mutate ? "blocked" : "warn" })
357
+ return
358
+ }
359
+ if (cfg.depsCheck) {
360
+ const verdict = checkInstall(event.command, cfg.blockedPackages, cfg.allowedPackages)
361
+ if (verdict) {
362
+ const cat = verdict.reason === "blocklist" ? "malicious-package" : "typosquat"
363
+ if (mutate) {
364
+ const safe = verdict.name.replace(/["'`<>|&]/g, "")
365
+ event.command = `echo "guard: install blocked '${safe}'" && exit 1`
366
+ }
367
+ const last = depsAuditRecent.get(event.command)
368
+ if (!last || Date.now() - last > DEPS_AUDIT_TTL_MS) {
369
+ audit(cfg, { ts: now(), kind: "deps", categories: [cat], count: 1, action: mutate ? "blocked" : "warn" })
370
+ }
371
+ return
372
+ }
373
+ }
374
+ if (!cfg.redactOnWrite) return
375
+ const r = redact(event.command, patterns)
376
+ if (r.matches.length > 0) {
377
+ if (mutate) event.command = r.text
378
+ audit(cfg, { ts: now(), kind: "shell", categories: categoriesOf(r.matches), count: r.matches.length, action: mutate ? "redacted" : "warn" })
379
+ }
380
+ for (const [k, v] of Object.entries(event.env)) {
381
+ if (typeof v !== "string") continue
382
+ const m = scan(v, patterns)
383
+ if (m.length > 0) {
384
+ if (mutate) event.env[k] = "<REDACTED:env>"
385
+ audit(cfg, { ts: now(), kind: "shell", categories: categoriesOf(m), count: m.length, action: mutate ? "redacted" : "warn" })
386
+ }
387
+ }
388
+ }),
389
+ )
390
+
391
+ // --- outbound context scrub (defense in depth) ---
392
+ if (cfg.scanOutbound) {
393
+ trackHook(
394
+ ctx.session.hook("context", (event) => {
395
+ const mutate = cfg.mode === "enforce"
396
+ let n = 0
397
+ const cats = new Set<string>()
398
+ const scrub = (s: string): string => {
399
+ const r = redact(s, patterns)
400
+ if (r.matches.length > 0) {
401
+ n += r.matches.length
402
+ for (const m of r.matches) cats.add(m.category)
403
+ return mutate ? r.text : s
404
+ }
405
+ return s
406
+ }
407
+ const system = event.system as Array<{ type: string; text?: string }>
408
+ for (const part of system) {
409
+ if (part.type === "text" && typeof part.text === "string") part.text = scrub(part.text)
410
+ }
411
+ for (const msg of event.messages) {
412
+ const m = msg as { content?: unknown }
413
+ if (typeof m.content === "string") {
414
+ m.content = scrub(m.content)
415
+ } else if (Array.isArray(m.content)) {
416
+ for (const p of m.content) {
417
+ if (isRecord(p) && p.type === "text" && typeof p.text === "string") {
418
+ p.text = scrub(p.text)
419
+ }
420
+ }
421
+ }
422
+ }
423
+ if (n > 0) audit(cfg, { ts: now(), kind: "context", categories: [...cats].sort(), count: n, action: mutate ? "redacted" : "warn" })
424
+ }),
425
+ )
426
+ }
427
+
428
+ return async () => {
429
+ for (const r of tracked.splice(0)) await r.dispose()
430
+ }
431
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { resolveConfig } from "./config.ts"
3
+ import { registerGuard } from "./guard.ts"
4
+
5
+ export default Plugin.define({
6
+ id: "guard",
7
+ setup(ctx) {
8
+ const cfg = resolveConfig(ctx.options)
9
+ return registerGuard(ctx, cfg)
10
+ },
11
+ })
@@ -0,0 +1,120 @@
1
+ export type PatternCategory =
2
+ | "api-key"
3
+ | "token"
4
+ | "private-key"
5
+ | "credential"
6
+ | "connection-string"
7
+ | "pii"
8
+ | "pii-name"
9
+ | "prompt-injection"
10
+
11
+ export interface Pattern {
12
+ /** stable id, used in audit and disabledPatterns */
13
+ id: string
14
+ category: PatternCategory
15
+ /** regex source; compiled lazily with flags + "gd" */
16
+ regex: string
17
+ flags?: string
18
+ /** capture group to mask; 0 = whole match (default) */
19
+ maskGroup?: number
20
+ /** keep last N chars visible in the mask (e.g. sk-…UvWx); omitted = full mask */
21
+ keepLast?: number
22
+ /** validate digit-only match with Luhn before accepting */
23
+ luhn?: boolean
24
+ /** arbitrary acceptance check; gets the raw span and its position in the full text */
25
+ validate?: (raw: string, text: string, start: number, end: number) => boolean
26
+ }
27
+
28
+ export const DEFAULT_PATTERNS: readonly Pattern[] = [
29
+ // --- private keys (whole block) ---
30
+ {
31
+ id: "private-key-block",
32
+ category: "private-key",
33
+ regex: "-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----[\\s\\S]*?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----",
34
+ },
35
+ // --- cloud / service API keys ---
36
+ { id: "stripe-key", category: "api-key", regex: "(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}", keepLast: 4 },
37
+ { id: "gitlab-pat", category: "api-key", regex: "glpat-[A-Za-z0-9_-]{20,}", keepLast: 4 },
38
+ { id: "telegram-bot-token", category: "api-key", regex: "\\d{8,10}:[A-Za-z0-9_-]{35}", keepLast: 4 },
39
+ { id: "slack-webhook", category: "api-key", regex: "https://hooks\\.slack\\.com/services/T[A-Za-z0-9_]+/B[A-Za-z0-9_]+/[A-Za-z0-9_]+", keepLast: 4 },
40
+ { id: "openai-key", category: "api-key", regex: "sk-[A-Za-z0-9_-]{10,}", keepLast: 4 },
41
+ { id: "github-token", category: "api-key", regex: "gh[pousr]_[A-Za-z0-9]{20,}", keepLast: 4 },
42
+ { id: "github-fine-pat", category: "api-key", regex: "github_pat_[A-Za-z0-9_]{20,}", keepLast: 4 },
43
+ { id: "slack-token", category: "api-key", regex: "xox[baprs]-[A-Za-z0-9-]{10,}", keepLast: 4 },
44
+ { id: "aws-access-key", category: "api-key", regex: "(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}", keepLast: 4 },
45
+ { id: "google-api-key", category: "api-key", regex: "AIza[0-9A-Za-z_-]{35}", keepLast: 4 },
46
+ { id: "google-oauth-token", category: "api-key", regex: "ya29\\.[A-Za-z0-9_-]+", keepLast: 4 },
47
+ { id: "huggingface-key", category: "api-key", regex: "hf_[A-Za-z0-9]{20,}", keepLast: 4 },
48
+ { id: "npm-token", category: "api-key", regex: "npm_[A-Za-z0-9]{20,}", keepLast: 4 },
49
+ // --- tokens ---
50
+ { id: "jwt", category: "token", regex: "eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}" },
51
+ { id: "bearer-token", category: "token", regex: "Bearer\\s+[A-Za-z0-9._~+/=-]{20,}", flags: "i" },
52
+ // --- credentials in URLs and connection strings ---
53
+ { id: "basic-auth-url", category: "credential", regex: "(?<=https?://)[^\\s/:]+:[^\\s@]+(?=@)" },
54
+ { id: "connection-string", category: "connection-string", regex: "(?:postgres(?:ql)?|mysql|mongodb(?:\\+srv)?|redis|amqp|mssql):\\/\\/[^\\s/:]+:[^\\s@]+@" },
55
+ // --- generic key=value credentials (value masked, key kept) ---
56
+ {
57
+ id: "credential-assignment",
58
+ category: "credential",
59
+ regex: "(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|refresh[_-]?token|client[_-]?secret)\\b\\s*[:=]\\s*['\"]?(?!<REDACTED:)([^\\s'\"]{4,})",
60
+ flags: "i",
61
+ maskGroup: 1,
62
+ },
63
+ // --- PII (RU) ---
64
+ { id: "inn", category: "pii", regex: "(?<![A-Za-zА-Яа-яЁё0-9])(?:инн|inn)(?![A-Za-zА-Яа-яЁё0-9])\\s*[:№]?\\s*(\\d{10}(?:\\d{2})?)", flags: "i", maskGroup: 1 },
65
+ { id: "snils", category: "pii", regex: "\\d{3}-\\d{3}-\\d{3}[ ]\\d{2}\\b" },
66
+ { id: "passport-ru", category: "pii", regex: "(?<=паспорт[\\s:№]{0,6})\\d{4}[\\s-]*№?\\s*\\d{6}" },
67
+ { id: "phone-ru", category: "pii", regex: "(?<!\\d)(?:\\+7|8)[\\s(-]*\\d{3}[\\s)-]*\\d{3}[\\s-]*\\d{2}[\\s-]*\\d{2}(?!\\d)" },
68
+ { id: "pii-email", category: "pii", regex: "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" },
69
+ { id: "bank-card", category: "pii", regex: "\\b\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}(?:[ -]?\\d{3})?\\b", luhn: true },
70
+ // --- ФИО: only with explicit context marker, name itself masked ---
71
+ {
72
+ id: "fio-context",
73
+ category: "pii-name",
74
+ regex: "(?:фио|фамилия\\s+имя\\s+отчество)\\s*[::]?\\s*([А-ЯЁ][а-яё]+(?:[- ][А-ЯЁ][а-яё]+){1,2})",
75
+ flags: "i",
76
+ maskGroup: 1,
77
+ },
78
+ // --- prompt injection (skill-guard) ---
79
+ {
80
+ id: "inj-override",
81
+ category: "prompt-injection",
82
+ regex:
83
+ "ignore\\s+(?:(?:all|any|the|your|of|and),?\\s+){0,3}(?:previous|prior|above|earlier)\\s+instructions" +
84
+ "|disregard\\s+(?:(?:all|any|the|your|of|and)\\s+){0,3}(?:previous|prior|above|earlier|everything)(?:\\s+instructions)?" +
85
+ "|forget\\s+(?:everything|all\\s+(?:previous\\s+)?instructions|what\\s+you\\s+(?:were\\s+)?(?:told|instructed))" +
86
+ "|ignore\\s+the\\s+above" +
87
+ "|from\\s+now\\s+on\\s+(?:you\\b|ignore|follow|obey)" +
88
+ "|игнорируй(?:те)?\\s+(?:[а-яё'-]+?\\s+){0,3}[а-яё'-]*?(?:инструкци[ийяюеё]|указани[ийяюеё]|сказанн[оыеёа]|изложенн[оыеёа])" +
89
+ "|забудь(?:те)?\\s+(?:(?:всё|все)\\s+инструкции|предыдущие(?:\\s+инструкции)?)" +
90
+ "|отныне\\s+ты",
91
+ flags: "i",
92
+ },
93
+ {
94
+ id: "inj-exfil-combo",
95
+ category: "prompt-injection",
96
+ regex:
97
+ "~[/\\\\](?:\\.ssh|\\.aws|\\.gnupg)[/\\\\]?[^\\s'\"\\\\]*" +
98
+ "|(?:[/\\\\]|(?<![\\w.]))\\.env\\b(?:\\.[A-Za-z0-9._-]+)?" +
99
+ "|\\bid_rsa\\b" +
100
+ "|\\bcredentials\\.(?:json|csv|ini|txt|ya?ml)\\b|[/\\\\]credentials\\b",
101
+ validate: (raw, text, start, end) => {
102
+ const window = text.slice(Math.max(0, start - 200), end + 200)
103
+ return /curl|wget|(?:^|\s)nc\s|netcat|exfiltrat|base64|send(?:ing)?\b|post(?:ing)?\b|fetch(?:ing)?\b|upload(?:ing)?\b|transfer(?:ing|red)?\b|отправь|отправить|выгрузи|выгрузить|залей|передай|пришли|скинь/i.test(window)
104
+ },
105
+ },
106
+ {
107
+ id: "inj-zerowidth",
108
+ category: "prompt-injection",
109
+ // U+200D (ZWJ) excluded: legit emoji/composite scripts; a lone BOM at position 0 is routine.
110
+ regex: "[\\u200B\\u200C\\u2060\\uFEFF\\u202E\\u202B]+",
111
+ validate: (raw, _text, start) => !(start === 0 && raw.length === 1 && raw.charCodeAt(0) === 0xfeff),
112
+ },
113
+ ]
114
+
115
+ /** patterns minus disabled ids; single source of truth for the active set */
116
+ export function activePatterns(disabledPatterns: readonly string[]): readonly Pattern[] {
117
+ if (disabledPatterns.length === 0) return DEFAULT_PATTERNS
118
+ const off = new Set(disabledPatterns)
119
+ return DEFAULT_PATTERNS.filter((p) => !off.has(p.id))
120
+ }