@openviking/dsh-memory-plugin 0.5.29 → 0.5.30
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/README.md +70 -10
- package/client.mjs +2 -11
- package/docs/DETAIL.md +61 -0
- package/docs/DETAIL.zh.md +61 -0
- package/index.mjs +3 -1
- package/locale/en.json +2 -1
- package/locale/zh.json +2 -1
- package/package.json +2 -1
- package/runtime.mjs +4 -13
- package/shared/config-schema.mjs +2 -31
- package/shared/pending-queue.mjs +1 -23
- package/shared/uri-guard.mjs +3 -1
- package/uri-guard.mjs +5 -0
- package/shared/redact.mjs +0 -522
- package/shared/secret-rules.mjs +0 -294
package/shared/redact.mjs
DELETED
|
@@ -1,522 +0,0 @@
|
|
|
1
|
-
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
-
/**
|
|
3
|
-
* Client-side secret redaction for captured transcripts.
|
|
4
|
-
*
|
|
5
|
-
* An agent that runs `cat .env` or prints a token puts that credential in the
|
|
6
|
-
* transcript the memory plugins capture. This module replaces such secrets
|
|
7
|
-
* before a payload leaves the machine — before the POST to OpenViking and
|
|
8
|
-
* before the local pending queue writes it to disk — so the server, its
|
|
9
|
-
* extractor and the memories it writes never see the plaintext.
|
|
10
|
-
*
|
|
11
|
-
* The rules are gitleaks' default rule set plus OpenViking's own key formats
|
|
12
|
-
* (lib/secret-rules.mjs, generated), and detection follows gitleaks: a
|
|
13
|
-
* case-insensitive keyword prefilter, the rule regex, the secret taken from
|
|
14
|
-
* the rule's `secretGroup` or else the first non-empty capture group, the
|
|
15
|
-
* Shannon entropy threshold, then the global and the rule's allowlists and
|
|
16
|
-
* stopwords. Only the secret span is replaced, so `API_KEY=sk-...` becomes
|
|
17
|
-
* `API_KEY={{ov_redacted:<rule>:<tag>}}` and the turn still reads as what
|
|
18
|
-
* happened.
|
|
19
|
-
*
|
|
20
|
-
* The tag is the first 8 hex characters of HMAC-SHA256(install key, secret):
|
|
21
|
-
* the same secret gets the same tag across turns and sessions on one machine,
|
|
22
|
-
* but the tag cannot be checked against a guessed secret without the key,
|
|
23
|
-
* which never leaves the machine.
|
|
24
|
-
*
|
|
25
|
-
* Failure policy: redaction fails closed per string. A scan that throws
|
|
26
|
-
* replaces the whole string with `{{ov_redacted:scan-failed:<tag>}}`; it never
|
|
27
|
-
* throws to the hook and never lets the string through unscanned. Text past
|
|
28
|
-
* `maxChars` (MAX_SCAN_CHARS by default) is not scanned either: it is cut at a
|
|
29
|
-
* whitespace boundary (before an unterminated `-----BEGIN` line) and replaced
|
|
30
|
-
* with `{{ov_redacted:scan-oversize:<tag>}}`. The same happens once one call
|
|
31
|
-
* has scanned `maxTotalChars` (MAX_SCAN_TOTAL_CHARS) across all its strings,
|
|
32
|
-
* which keeps a hook's redaction time bounded however large the batch.
|
|
33
|
-
* A missing or unwritable key file only costs tag stability (an in-process
|
|
34
|
-
* random key is used), never redaction itself.
|
|
35
|
-
*
|
|
36
|
-
* Redaction is idempotent: existing `{{ov_redacted:<rule>:<tag>}}`
|
|
37
|
-
* placeholders and well-formed `{{ov_privacy:...}}` references are never
|
|
38
|
-
* scanned or touched, so replaying a pending entry or redacting twice changes
|
|
39
|
-
* nothing.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
import { createHmac, randomBytes } from "node:crypto";
|
|
43
|
-
import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
44
|
-
import { dirname, join } from "node:path";
|
|
45
|
-
|
|
46
|
-
import SECRET_RULES, { GLOBAL_ALLOWLISTS, TIERS } from "./secret-rules.mjs";
|
|
47
|
-
import { stateDir } from "./workspace-identity.mjs";
|
|
48
|
-
|
|
49
|
-
/** Ordered, cumulative: each tier runs its own rules and every earlier tier's. */
|
|
50
|
-
export const REDACT_TIERS = Object.freeze([...TIERS]);
|
|
51
|
-
export const DEFAULT_REDACT_TIER = "standard";
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The most characters of one string that are scanned. It matches the
|
|
55
|
-
* largest tool output a plugin captures (`captureToolMaxChars`, at most
|
|
56
|
-
* 1,000,000), so nothing a plugin builds by default is cut.
|
|
57
|
-
*/
|
|
58
|
-
export const MAX_SCAN_CHARS = 1_000_000;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The most characters scanned in one redactText / redactPayload /
|
|
62
|
-
* redactPayloads call, summed over every string in it. Ordinary text scans at
|
|
63
|
-
* well under 1 s per MB, but keyword-dense data (hashes, base64) can take
|
|
64
|
-
* 10-20 s per MB, and a hook that times out re-sends the same turns next time.
|
|
65
|
-
*/
|
|
66
|
-
export const MAX_SCAN_TOTAL_CHARS = 2_000_000;
|
|
67
|
-
|
|
68
|
-
const OFF_WORDS = new Set(["off", "false", "0", "no"]);
|
|
69
|
-
const ON_WORDS = new Set(["on", "true", "1", "yes"]);
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Map a configured value onto a tier name. Anything that says "no" (`false`,
|
|
73
|
-
* `0`, `"off"`, any case) is `off`; a value naming no tier this build knows (a
|
|
74
|
-
* newer config, a typo) falls back to the default, so it can only redact more.
|
|
75
|
-
*/
|
|
76
|
-
export function normalizeRedactTier(value, fallback = DEFAULT_REDACT_TIER) {
|
|
77
|
-
const safeFallback = REDACT_TIERS.includes(fallback) ? fallback : DEFAULT_REDACT_TIER;
|
|
78
|
-
if (value === false || value === 0) return "off";
|
|
79
|
-
if (value === true || value === 1) return DEFAULT_REDACT_TIER;
|
|
80
|
-
if (typeof value !== "string") return safeFallback;
|
|
81
|
-
const word = value.trim().toLowerCase();
|
|
82
|
-
if (!word) return safeFallback;
|
|
83
|
-
if (OFF_WORDS.has(word)) return "off";
|
|
84
|
-
if (REDACT_TIERS.includes(word)) return word;
|
|
85
|
-
if (ON_WORDS.has(word)) return DEFAULT_REDACT_TIER;
|
|
86
|
-
return safeFallback;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
// Install key
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
-
const KEY_HEX_RE = /^[0-9a-f]{64}$/;
|
|
94
|
-
let cachedKey = null;
|
|
95
|
-
let processKey = null;
|
|
96
|
-
|
|
97
|
-
function readKeyFile(file) {
|
|
98
|
-
try {
|
|
99
|
-
const text = readFileSync(file, "utf8").trim();
|
|
100
|
-
return KEY_HEX_RE.test(text) ? Buffer.from(text, "hex") : null;
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Create the key file without exposing a half-written one: a private temp
|
|
108
|
-
* file is hard-linked into place, which fails if a concurrent hook got there
|
|
109
|
-
* first (its key is then the one read back). A garbage key file, or a
|
|
110
|
-
* filesystem without hard links, gets the temp file renamed over it.
|
|
111
|
-
*/
|
|
112
|
-
function createKeyFile(file) {
|
|
113
|
-
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
114
|
-
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
115
|
-
writeFileSync(tmp, `${randomBytes(32).toString("hex")}\n`, { mode: 0o600, flag: "wx" });
|
|
116
|
-
try {
|
|
117
|
-
linkSync(tmp, file);
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (error?.code !== "EEXIST" || !readKeyFile(file)) renameSync(tmp, file);
|
|
120
|
-
} finally {
|
|
121
|
-
try { unlinkSync(tmp); } catch { /* renamed into place */ }
|
|
122
|
-
}
|
|
123
|
-
return readKeyFile(file);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The per-install HMAC key: 32 random bytes in `<state dir>/redact/install-key`
|
|
128
|
-
* (dir 0700, file 0600). When it cannot be read or created, a random key for
|
|
129
|
-
* this process is used: tags then differ between processes, nothing else.
|
|
130
|
-
*/
|
|
131
|
-
export function loadInstallKey({ env = process.env } = {}) {
|
|
132
|
-
let file = "";
|
|
133
|
-
try {
|
|
134
|
-
file = join(stateDir(env), "redact", "install-key");
|
|
135
|
-
} catch {
|
|
136
|
-
// No state dir: process key.
|
|
137
|
-
}
|
|
138
|
-
if (cachedKey && cachedKey.file === file) return cachedKey.key;
|
|
139
|
-
let key = file ? readKeyFile(file) : null;
|
|
140
|
-
if (!key && file) {
|
|
141
|
-
try {
|
|
142
|
-
key = createKeyFile(file);
|
|
143
|
-
} catch {
|
|
144
|
-
key = null;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
key ||= processKey ||= randomBytes(32);
|
|
148
|
-
cachedKey = { file, key };
|
|
149
|
-
return key;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function placeholder(ruleId, key, secret) {
|
|
153
|
-
const tag = createHmac("sha256", key).update(String(secret), "utf8").digest("hex").slice(0, 8);
|
|
154
|
-
return `{{ov_redacted:${ruleId}:${tag}}}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
// Detection (gitleaks semantics)
|
|
159
|
-
// ---------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
function compileAllowlists(list) {
|
|
162
|
-
return (list || []).map((al) => ({
|
|
163
|
-
and: al.condition === "AND",
|
|
164
|
-
target: al.regexTarget || "secret",
|
|
165
|
-
regexes: (al.regexes || []).map((r) => new RegExp(r.regex, r.flags)),
|
|
166
|
-
stopwords: (al.stopwords || []).map((w) => String(w).toLowerCase()),
|
|
167
|
-
}));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const compiledCache = new Map();
|
|
171
|
-
|
|
172
|
-
/** Compile the rules once per process; throws if a rule does not compile. */
|
|
173
|
-
export function compileRuleSet(rules = SECRET_RULES, globalAllowlists = GLOBAL_ALLOWLISTS) {
|
|
174
|
-
const hit = compiledCache.get(rules);
|
|
175
|
-
if (hit && hit.globalsSource === globalAllowlists) return hit;
|
|
176
|
-
const set = {
|
|
177
|
-
globalsSource: globalAllowlists,
|
|
178
|
-
globals: compileAllowlists(globalAllowlists),
|
|
179
|
-
rules: rules.map((rule) => ({
|
|
180
|
-
id: rule.id,
|
|
181
|
-
tierIndex: REDACT_TIERS.indexOf(rule.tier || DEFAULT_REDACT_TIER),
|
|
182
|
-
keywords: (rule.keywords || []).map((k) => String(k).toLowerCase()),
|
|
183
|
-
entropy: Number(rule.entropy) || 0,
|
|
184
|
-
secretGroup: Number(rule.secretGroup) || 0,
|
|
185
|
-
scan: new RegExp(rule.regex, `${rule.flags || ""}g`),
|
|
186
|
-
groups: new RegExp(rule.regex, `${rule.flags || ""}d`),
|
|
187
|
-
allowlists: compileAllowlists(rule.allowlists),
|
|
188
|
-
generic: rule.id.includes("generic"),
|
|
189
|
-
})),
|
|
190
|
-
};
|
|
191
|
-
compiledCache.set(rules, set);
|
|
192
|
-
return set;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Shannon entropy as gitleaks computes it: symbol frequencies over code
|
|
197
|
-
* points, divided by the UTF-8 byte length (Go's `len(string)`).
|
|
198
|
-
*/
|
|
199
|
-
export function shannonEntropy(data) {
|
|
200
|
-
if (!data) return 0;
|
|
201
|
-
const counts = new Map();
|
|
202
|
-
let bytes = 0;
|
|
203
|
-
for (const ch of data) {
|
|
204
|
-
counts.set(ch, (counts.get(ch) || 0) + 1);
|
|
205
|
-
const cp = ch.codePointAt(0);
|
|
206
|
-
bytes += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
|
|
207
|
-
}
|
|
208
|
-
let entropy = 0;
|
|
209
|
-
for (const count of counts.values()) entropy -= (count / bytes) * Math.log2(count / bytes);
|
|
210
|
-
return entropy;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function isAllowed(allowlists, secret, match, line) {
|
|
214
|
-
const secretLower = secret.toLowerCase();
|
|
215
|
-
for (const al of allowlists) {
|
|
216
|
-
const target = al.target === "match" ? match : al.target === "line" ? line() : secret;
|
|
217
|
-
const checks = [];
|
|
218
|
-
if (al.regexes.length) checks.push(target !== "" && al.regexes.some((re) => re.test(target)));
|
|
219
|
-
if (al.stopwords.length) checks.push(al.stopwords.some((word) => secretLower.includes(word)));
|
|
220
|
-
if (al.and ? checks.every(Boolean) : checks.some(Boolean)) return true;
|
|
221
|
-
}
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* The secret spans gitleaks would report in `text`. The `gitleaks:allow`
|
|
227
|
-
* marker is deliberately not honoured: in a transcript it would be a way to
|
|
228
|
-
* opt a line out of redaction.
|
|
229
|
-
*/
|
|
230
|
-
function detect(text, rules, globals) {
|
|
231
|
-
const findings = [];
|
|
232
|
-
const lower = text.toLowerCase();
|
|
233
|
-
for (const rule of rules) {
|
|
234
|
-
if (rule.keywords.length && !rule.keywords.some((kw) => lower.includes(kw))) continue;
|
|
235
|
-
const re = rule.scan;
|
|
236
|
-
re.lastIndex = 0;
|
|
237
|
-
for (let m; (m = re.exec(text)) !== null;) {
|
|
238
|
-
if (m[0].length === 0) {
|
|
239
|
-
re.lastIndex++;
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
// gitleaks trims newlines off the match and re-runs the regex on it.
|
|
243
|
-
let start = m.index;
|
|
244
|
-
let end = m.index + m[0].length;
|
|
245
|
-
while (start < end && text.charCodeAt(start) === 10) start++;
|
|
246
|
-
while (end > start && text.charCodeAt(end - 1) === 10) end--;
|
|
247
|
-
const match = text.slice(start, end);
|
|
248
|
-
let span = [0, match.length];
|
|
249
|
-
const sub = match ? rule.groups.exec(match) : null;
|
|
250
|
-
if (sub && sub.length >= 2) {
|
|
251
|
-
if (rule.secretGroup > 0) span = sub.indices[rule.secretGroup] || [0, 0];
|
|
252
|
-
else span = sub.indices.slice(1).find((idx) => idx && idx[1] > idx[0]) || span;
|
|
253
|
-
}
|
|
254
|
-
const secret = match.slice(span[0], span[1]);
|
|
255
|
-
if (!secret) continue;
|
|
256
|
-
if (rule.entropy && shannonEntropy(secret) <= rule.entropy) continue;
|
|
257
|
-
const line = () => {
|
|
258
|
-
const from = text.lastIndexOf("\n", start - 1) + 1;
|
|
259
|
-
const to = text.indexOf("\n", end);
|
|
260
|
-
return text.slice(from, to < 0 ? text.length : to);
|
|
261
|
-
};
|
|
262
|
-
if (isAllowed(globals, secret, match, line) || isAllowed(rule.allowlists, secret, match, line)) continue;
|
|
263
|
-
findings.push({ start: start + span[0], end: start + span[1], id: rule.id, secret, generic: rule.generic });
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return findings;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// ---------------------------------------------------------------------------
|
|
270
|
-
// Redaction
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Spans that are already placeholders and are never scanned: exactly the
|
|
275
|
-
* placeholders this module writes, and OpenViking privacy references in the
|
|
276
|
-
* grammar the server parses (openviking/privacy/reference.py). Anything else
|
|
277
|
-
* that merely starts with `{{ov_` is scanned like any other text.
|
|
278
|
-
*/
|
|
279
|
-
const PROTECTED_RE = new RegExp(
|
|
280
|
-
[
|
|
281
|
-
"\\{\\{ov_redacted:[a-z0-9][a-z0-9-]{0,99}:[0-9a-f]{8}\\}\\}",
|
|
282
|
-
"\\{\\{[ \\t]*ov_privacy:(?:" +
|
|
283
|
-
"ambiguous" +
|
|
284
|
-
"|skill:[A-Za-z0-9_.@-]{1,255}:[^\\s:{}]{1,255}" +
|
|
285
|
-
"|[^\\s:{}]{1,255}:viking://~/privacy/[^/\\s{}]{1,255}/[^#\\s{}]{1,1024}#/values/[^\\s{}]{1,1024}" +
|
|
286
|
-
")[ \\t]*\\}\\}",
|
|
287
|
-
].join("|"),
|
|
288
|
-
"g",
|
|
289
|
-
);
|
|
290
|
-
// One pass is not always a fixed point: a placeholder can give the text after
|
|
291
|
-
// it a word boundary it lacked (`ghp_<36>AKIA<16>`: aws-access-token needs
|
|
292
|
-
// `\bAKIA`, which only exists once the GitHub token is replaced). Passes repeat
|
|
293
|
-
// until nothing changes, so a replayed entry is sent byte for byte; a string
|
|
294
|
-
// with no finding costs one pass.
|
|
295
|
-
const MAX_PASSES = 3;
|
|
296
|
-
|
|
297
|
-
/** The [start, end) stretches of `text` outside existing placeholders. */
|
|
298
|
-
function scannableSegments(text) {
|
|
299
|
-
const segments = [];
|
|
300
|
-
let last = 0;
|
|
301
|
-
for (const m of text.matchAll(PROTECTED_RE)) {
|
|
302
|
-
if (m.index > last) segments.push([last, m.index]);
|
|
303
|
-
last = m.index + m[0].length;
|
|
304
|
-
}
|
|
305
|
-
if (last < text.length) segments.push([last, text.length]);
|
|
306
|
-
return segments;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Where scanning stops in a string with more than `max` scannable characters:
|
|
311
|
-
* the `max`-th one, pulled back to whitespace within 4 KiB so a token is not
|
|
312
|
-
* split with its head left in the scanned part. A `-----BEGIN` block whose
|
|
313
|
-
* `-----END ...-----` line lies past the cut cannot match the private-key rule,
|
|
314
|
-
* so the cut moves to the start of the BEGIN line instead.
|
|
315
|
-
*/
|
|
316
|
-
function cutPoint(text, segments, max) {
|
|
317
|
-
let budget = max;
|
|
318
|
-
for (const [a, b] of segments) {
|
|
319
|
-
if (b - a <= budget) {
|
|
320
|
-
budget -= b - a;
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
const hard = a + budget;
|
|
324
|
-
const head = text.slice(a, hard).toLowerCase();
|
|
325
|
-
const begin = head.lastIndexOf("-----begin");
|
|
326
|
-
if (begin >= 0 && !/-----end[^\n]*-----/.test(head.slice(begin))) return a + head.lastIndexOf("\n", begin) + 1;
|
|
327
|
-
for (let k = hard; k > Math.max(a, hard - 4096); k--) if (/\s/.test(text[k - 1])) return k;
|
|
328
|
-
return hard;
|
|
329
|
-
}
|
|
330
|
-
return text.length;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Overlapping findings become one span, named by a provider rule over the generic one. */
|
|
334
|
-
function mergeFindings(findings) {
|
|
335
|
-
findings.sort((x, y) => x.start - y.start || y.end - x.end || Number(x.generic) - Number(y.generic));
|
|
336
|
-
const merged = [];
|
|
337
|
-
for (const f of findings) {
|
|
338
|
-
const last = merged[merged.length - 1];
|
|
339
|
-
if (last && f.start < last.end) {
|
|
340
|
-
last.end = Math.max(last.end, f.end);
|
|
341
|
-
if (last.rep.generic && !f.generic) last.rep = f;
|
|
342
|
-
} else {
|
|
343
|
-
merged.push({ start: f.start, end: f.end, rep: f });
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return merged;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* One scan-and-replace pass over `full`; `base` is where the value starts
|
|
351
|
-
* after a key prefix, and `max` how many characters of the value to scan.
|
|
352
|
-
* Returns the redacted value and how many of its characters were scanned.
|
|
353
|
-
*/
|
|
354
|
-
function redactOnce(full, base, ctx, max) {
|
|
355
|
-
const segments = scannableSegments(full);
|
|
356
|
-
const scannable = segments.reduce((n, [a, b]) => n + (b - a), 0);
|
|
357
|
-
const limit = scannable > base + max ? Math.max(base, cutPoint(full, segments, base + max)) : full.length;
|
|
358
|
-
const findings = [];
|
|
359
|
-
for (const [a, b] of segments) {
|
|
360
|
-
if (Math.min(b, limit) <= a) continue;
|
|
361
|
-
for (const f of detect(full.slice(a, Math.min(b, limit)), ctx.rules, ctx.globals)) {
|
|
362
|
-
const start = Math.max(a + f.start, base);
|
|
363
|
-
if (a + f.end > start) findings.push({ ...f, start, end: a + f.end });
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
let out = "";
|
|
367
|
-
let pos = base;
|
|
368
|
-
for (const span of mergeFindings(findings)) {
|
|
369
|
-
out += full.slice(pos, span.start) + placeholder(span.rep.id, ctx.key(), span.rep.secret);
|
|
370
|
-
pos = span.end;
|
|
371
|
-
}
|
|
372
|
-
out += full.slice(pos, limit);
|
|
373
|
-
if (limit < full.length) out += placeholder("scan-oversize", ctx.key(), full.slice(limit));
|
|
374
|
-
return { text: out, scanned: Math.max(0, Math.min(scannable, base + max) - base) };
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function makeContext(opts = {}) {
|
|
378
|
-
const tier = normalizeRedactTier(opts.tier ?? DEFAULT_REDACT_TIER);
|
|
379
|
-
let key = opts.key ? Buffer.from(opts.key) : null;
|
|
380
|
-
const ctx = {
|
|
381
|
-
tier,
|
|
382
|
-
rules: [],
|
|
383
|
-
globals: [],
|
|
384
|
-
maxChars: Number.isFinite(opts.maxChars) && opts.maxChars > 0 ? opts.maxChars : MAX_SCAN_CHARS,
|
|
385
|
-
// What is left of the call's scan budget; every string's first pass draws on it.
|
|
386
|
-
budget: Number.isFinite(opts.maxTotalChars) && opts.maxTotalChars > 0 ? opts.maxTotalChars : MAX_SCAN_TOTAL_CHARS,
|
|
387
|
-
key: () => (key ||= loadInstallKey(opts.env ? { env: opts.env } : undefined)),
|
|
388
|
-
};
|
|
389
|
-
if (tier === "off") return ctx;
|
|
390
|
-
try {
|
|
391
|
-
const set = compileRuleSet(opts.rules || SECRET_RULES, opts.globalAllowlists || GLOBAL_ALLOWLISTS);
|
|
392
|
-
const index = REDACT_TIERS.indexOf(tier);
|
|
393
|
-
ctx.rules = set.rules.filter((rule) => rule.tierIndex > 0 && rule.tierIndex <= index);
|
|
394
|
-
ctx.globals = set.globals;
|
|
395
|
-
} catch (error) {
|
|
396
|
-
ctx.broken = error;
|
|
397
|
-
}
|
|
398
|
-
return ctx;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function failClosed(text, ctx) {
|
|
402
|
-
try {
|
|
403
|
-
return placeholder("scan-failed", ctx.key(), text);
|
|
404
|
-
} catch {
|
|
405
|
-
return "{{ov_redacted:scan-failed:00000000}}";
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function redactStringIn(text, ctx, keyName = null) {
|
|
410
|
-
if (typeof text !== "string" || text === "") return text;
|
|
411
|
-
if (ctx.broken) return failClosed(text, ctx);
|
|
412
|
-
const prefix = keyName ? `${keyName}=` : "";
|
|
413
|
-
// Fixed for every pass, so a later pass never cuts what the first one scanned.
|
|
414
|
-
const max = Math.min(ctx.maxChars, ctx.budget);
|
|
415
|
-
try {
|
|
416
|
-
let current = text;
|
|
417
|
-
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
418
|
-
const { text: next, scanned } = redactOnce(prefix + current, prefix.length, ctx, max);
|
|
419
|
-
if (pass === 0) ctx.budget -= scanned;
|
|
420
|
-
if (next === current) return current;
|
|
421
|
-
current = next;
|
|
422
|
-
}
|
|
423
|
-
// Still changing after several passes: not a state to send.
|
|
424
|
-
return failClosed(text, ctx);
|
|
425
|
-
} catch {
|
|
426
|
-
return failClosed(text, ctx);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Redact the secrets in one string.
|
|
432
|
-
*
|
|
433
|
-
* @param {string} text
|
|
434
|
-
* @param {object} [opts]
|
|
435
|
-
* @param {string} [opts.tier="standard"] - one of REDACT_TIERS; "off" returns the input
|
|
436
|
-
* @param {string} [opts.keyName] - a field name to scan the value under, as `keyName=value`
|
|
437
|
-
* @param {number} [opts.maxChars] - scannable characters of one string before the rest is cut
|
|
438
|
-
* @param {number} [opts.maxTotalChars] - the same, summed over every string in the call
|
|
439
|
-
* @returns {string}
|
|
440
|
-
*/
|
|
441
|
-
export function redactText(text, opts = {}) {
|
|
442
|
-
const ctx = makeContext(opts);
|
|
443
|
-
if (ctx.tier === "off") return text;
|
|
444
|
-
return redactStringIn(text, ctx, opts.keyName || null);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Message and part fields that identify or describe rather than carry text.
|
|
448
|
-
const META_KEYS = new Set([
|
|
449
|
-
"role", "type", "id", "uri", "peer_id", "session_id", "created_at", "tool_id", "tool_name", "tool_status",
|
|
450
|
-
"context_type", "tool_output_mime_type", "tool_output_externalized_reason",
|
|
451
|
-
]);
|
|
452
|
-
const META_SUFFIX_RE = /_(?:id|uri|ref|sha256|at)$/;
|
|
453
|
-
const KEY_CONTEXT_RE = /^[A-Za-z_][\w.-]{0,63}$/;
|
|
454
|
-
|
|
455
|
-
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Redact every string under `value`. Inside an object each value is scanned
|
|
459
|
-
* as `<key>=<value>`, so a rule that needs a key-ish word (generic-api-key)
|
|
460
|
-
* sees `{ "API_TOKEN": "..." }` as it would see `API_TOKEN=...` in a file;
|
|
461
|
-
* only the value is replaced. Keys are scanned as text of their own.
|
|
462
|
-
*/
|
|
463
|
-
function redactDeep(value, ctx, keyName) {
|
|
464
|
-
if (typeof value === "string") return redactStringIn(value, ctx, keyName);
|
|
465
|
-
if (Array.isArray(value)) return value.map((item) => redactDeep(item, ctx, keyName));
|
|
466
|
-
if (!isObject(value)) return value;
|
|
467
|
-
const out = {};
|
|
468
|
-
for (const [k, v] of Object.entries(value)) {
|
|
469
|
-
const key = redactStringIn(k, ctx, null);
|
|
470
|
-
out[key] = redactDeep(v, ctx, key === k && KEY_CONTEXT_RE.test(k) ? k : null);
|
|
471
|
-
}
|
|
472
|
-
return out;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
/** A message or part: its fields, metadata copied and the rest redacted. */
|
|
476
|
-
function redactFields(value, ctx) {
|
|
477
|
-
if (!isObject(value)) return redactDeep(value, ctx, null);
|
|
478
|
-
const out = {};
|
|
479
|
-
for (const [k, v] of Object.entries(value)) {
|
|
480
|
-
if (k === "parts" && Array.isArray(v)) out[k] = v.map((part) => redactFields(part, ctx));
|
|
481
|
-
else if (META_KEYS.has(k) || META_SUFFIX_RE.test(k)) out[k] = v;
|
|
482
|
-
else out[k] = redactDeep(v, ctx, null);
|
|
483
|
-
}
|
|
484
|
-
return out;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Redact one session add-message payload — `{ role, content }` or
|
|
489
|
-
* `{ role, parts: [...] }` — and return a redacted copy. Text-bearing fields
|
|
490
|
-
* are scanned (`content`, `parts[].text`, `parts[].tool_output`, every leaf of
|
|
491
|
-
* `parts[].tool_input`, `abstract`, any other non-metadata field); ids, URIs,
|
|
492
|
-
* roles, statuses and timestamps are copied. The caller's object is never
|
|
493
|
-
* mutated.
|
|
494
|
-
*/
|
|
495
|
-
export function redactPayload(payload, opts = {}) {
|
|
496
|
-
const ctx = makeContext(opts);
|
|
497
|
-
if (ctx.tier === "off") return payload;
|
|
498
|
-
return redactPayloadIn(payload, ctx);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function redactPayloadIn(payload, ctx) {
|
|
502
|
-
if (payload == null) return payload;
|
|
503
|
-
if (typeof payload === "string") return redactStringIn(payload, ctx, null);
|
|
504
|
-
if (typeof payload !== "object") return payload;
|
|
505
|
-
// Walk the data the payload is on the wire (class instances, `toJSON`), as a
|
|
506
|
-
// copy; a payload that cannot be serialised could not be sent either.
|
|
507
|
-
let data;
|
|
508
|
-
try {
|
|
509
|
-
data = JSON.parse(JSON.stringify(payload));
|
|
510
|
-
} catch {
|
|
511
|
-
return failClosed("", ctx);
|
|
512
|
-
}
|
|
513
|
-
return redactFields(data, ctx);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/** redactPayload over a list, with one scan budget (maxTotalChars) for the whole list. */
|
|
517
|
-
export function redactPayloads(payloads, opts = {}) {
|
|
518
|
-
if (!Array.isArray(payloads)) return payloads;
|
|
519
|
-
const ctx = makeContext(opts);
|
|
520
|
-
if (ctx.tier === "off") return payloads;
|
|
521
|
-
return payloads.map((payload) => redactPayloadIn(payload, ctx));
|
|
522
|
-
}
|