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/LICENSE +21 -0
- package/README.md +227 -3
- package/docs/deps-spec.md +104 -0
- package/docs/skill-guard-spec.md +120 -0
- package/package.json +45 -4
- package/src/audit.ts +26 -0
- package/src/config.ts +81 -0
- package/src/deps.ts +223 -0
- package/src/glob.ts +84 -0
- package/src/guard.ts +431 -0
- package/src/index.ts +11 -0
- package/src/patterns.ts +120 -0
- package/src/scan.ts +252 -0
- package/src/skillcheck.ts +44 -0
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
|
+
})
|
package/src/patterns.ts
ADDED
|
@@ -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
|
+
}
|