@vllm-sr/dsh 0.4.1 → 0.5.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/shield.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * The injection shield: screen tool output passage by passage, then zoom into
3
- * flagged passages to find the lines that carry the injection, and remove only
4
- * those. Lines the harness itself writes are masked while scoring and never
5
- * removed.
3
+ * flagged passages to find what carries the injection, and remove only that:
4
+ * the exact spans when a span model answers, else the lines. Lines the harness
5
+ * itself writes are masked while scoring and never removed.
6
6
  */
7
7
  import { ITEMS_PER_REQUEST, yes } from './client.js'
8
- import { SHIELD, SHIELD_FOCUS } from './questions.js'
8
+ import { INJECTION_SPANS, SHIELD, SHIELD_FOCUS } from './questions.js'
9
+ import { excerpts, placeSpans, replaceSpans, toSentences, unnumber } from './spans.js'
9
10
 
10
11
  /** Exact templates of the annotations DeepSeek Harness adds to shell output. */
11
12
  const HARNESS_LINES = [
@@ -50,6 +51,23 @@ async function scoreAll(client, model, questions, states, signal, limits = {}) {
50
51
  }
51
52
  }
52
53
 
54
+ export const SPAN_MARK = '[System One removed injected text]'
55
+
56
+ // The exact spans that give orders, in each flagged passage, from a span model; a passage it fails on gets none.
57
+ async function injectedSpans({ client, model, about, passages, signal, timeoutMs, retries }) {
58
+ const found = passages.map(() => [])
59
+ let answeredBy
60
+ for (let start = 0; start < passages.length; start += ITEMS_PER_REQUEST) {
61
+ const batch = passages.slice(start, start + ITEMS_PER_REQUEST)
62
+ const response = await client.ask({ model, questions: INJECTION_SPANS, inputs: batch.map(passage => ({ state: { request: about, passage } })), signal, timeoutMs, retries })
63
+ answeredBy = response.model
64
+ batch.forEach((passage, offset) => {
65
+ found[start + offset] = placeSpans(response.spans[offset]?.injected, passage)
66
+ })
67
+ }
68
+ return { model: answeredBy ?? model, found }
69
+ }
70
+
53
71
  /**
54
72
  * @param {object} options
55
73
  * @param {{ask: Function}} options.client
@@ -59,11 +77,18 @@ async function scoreAll(client, model, questions, states, signal, limits = {}) {
59
77
  * @param {number} options.threshold
60
78
  * @param {number} options.chunkChars
61
79
  * @param {AbortSignal} [options.signal]
62
- * @returns {Promise<{model: string, passages: number, maxP: number, flagged: object[], removed: Array<{start: number, end: number, p: number}>, removedLines: number, text: string}>}
80
+ * @param {{model: string, about: string}} [options.spans] - a span model that finds the injected text itself
81
+ * @returns {Promise<{model: string, passages: number, maxP: number, flagged: object[], removed: object[], removedLines: number,
82
+ * removedSpans: number, removedChars: number, spanModel?: string, spanMs?: number, text: string}>}
63
83
  * `maxP` is the highest injection probability of any passage
64
84
  */
65
- export async function screen({ client, model, prefix, text, threshold, chunkChars, signal, timeoutMs, retries }) {
85
+ export async function screen({ client, model, prefix, text, threshold, chunkChars, signal, timeoutMs, retries, spans }) {
66
86
  const lines = text.split('\n')
87
+ const lineStart = []
88
+ for (let index = 0, at = 0; index < lines.length; index++) {
89
+ lineStart.push(at)
90
+ at += lines[index].length + 1
91
+ }
67
92
  const masked = lines.map(line => (isHarnessLine(line) ? '[harness note]' : line))
68
93
  const ranges = passageRanges(masked, chunkChars)
69
94
  const quote = (from, to, marks) => {
@@ -73,12 +98,48 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
73
98
  return `${prefix}\n"""\n${body.join('\n')}\n"""`
74
99
  }
75
100
  const first = await scoreAll(client, model, SHIELD, ranges.map(([from, to]) => quote(from, to)), signal, { timeoutMs, retries })
76
- const flagged = ranges.map(([from, to], index) => ({ from, to, p: first.scores[index] })).filter(entry => entry.p >= threshold)
101
+ const flagged = ranges.map(([from, to], index) => ({ from, to, p: first.scores[index], text: lines.slice(from, to).join('\n') })).filter(entry => entry.p >= threshold)
102
+
103
+ // With a span model: the text that gives orders, in each flagged passage, grown to whole sentences. The passage is
104
+ // scored again without them, and a cut that leaves an injection behind, or a failure, leaves the lines below.
105
+ let spanModel
106
+ let spanMs
107
+ if (spans && flagged.length) {
108
+ const started = performance.now()
109
+ try {
110
+ // the span model reads a file's text without the read tool's line numbers; each cut is mapped back onto them
111
+ const plain = flagged.map(entry => unnumber(entry.text))
112
+ const answer = await injectedSpans({ client, model: spans.model, about: spans.about, passages: plain.map(view => view.text), signal, timeoutMs, retries })
113
+ spanModel = answer.model
114
+ const cuts = flagged.map((entry, index) => {
115
+ const grown = toSentences(plain[index].text, answer.found[index])
116
+ // the harness's own lines are never removed
117
+ if (!grown.length || grown.some(span => span.text.split('\n').some(isHarnessLine))) return undefined
118
+ entry.plain = { text: plain[index].text, spans: grown }
119
+ return grown.map(span => {
120
+ const start = plain[index].at(span.start)
121
+ const end = plain[index].at(span.end)
122
+ return { ...span, start, end, text: entry.text.slice(start, end) }
123
+ })
124
+ })
125
+ const tried = flagged.map((entry, index) => ({ entry, cut: cuts[index] })).filter(item => item.cut)
126
+ if (tried.length) {
127
+ const unmask = value => value.split('\n').map(line => (isHarnessLine(line) ? '[harness note]' : line)).join('\n')
128
+ const again = await scoreAll(client, model, SHIELD, tried.map(({ entry, cut }) => `${prefix}\n"""\n${unmask(replaceSpans(entry.text, cut, () => SPAN_MARK))}\n"""`), signal, { timeoutMs, retries })
129
+ tried.forEach(({ entry, cut }, index) => {
130
+ if (again.scores[index] < threshold) entry.spans = cut
131
+ else entry.spansLeft = again.scores[index]
132
+ })
133
+ }
134
+ } catch (error) {
135
+ if (signal?.aborted) throw error
136
+ }
137
+ spanMs = performance.now() - started
138
+ }
77
139
 
78
- // Second look, only inside flagged passages: which groups of lines carry it?
140
+ // Otherwise a second look inside each flagged passage: which groups of lines carry it?
79
141
  const GROUP = 3
80
- const removed = []
81
- await Promise.all(flagged.map(async entry => {
142
+ await Promise.all(flagged.filter(entry => !entry.spans).map(async entry => {
82
143
  const groups = []
83
144
  for (let start = entry.from; start < entry.to; start += GROUP) groups.push([start, Math.min(entry.to, start + GROUP)])
84
145
  let hits = []
@@ -87,38 +148,47 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
87
148
  hits = zoom ? groups.map(([start, end], index) => ({ start, end, p: zoom.scores[index] })).filter(group => group.p >= threshold) : []
88
149
  }
89
150
  entry.lines = hits.length ? hits : [{ start: entry.from, end: entry.to, p: entry.p }]
90
- removed.push(...entry.lines)
91
151
  }))
92
152
 
93
- removed.sort((a, b) => a.start - b.start)
94
- const merged = []
95
- for (const range of removed) {
96
- const last = merged.at(-1)
97
- if (last && last.end >= range.start) {
98
- last.end = Math.max(last.end, range.end)
99
- last.p = Math.max(last.p, range.p)
100
- } else {
101
- merged.push({ ...range })
153
+ // Every removal as a range of the original text and what replaces it.
154
+ const lineEnd = index => (index < lines.length ? lineStart[index] : text.length)
155
+ const cuts = []
156
+ for (const entry of flagged) {
157
+ if (entry.spans) {
158
+ for (const span of entry.spans) {
159
+ const at = lineStart[entry.from]
160
+ cuts.push({ start: at + span.start, end: at + span.end, p: span.probability, replacement: SPAN_MARK, spans: 1, chars: span.end - span.start })
161
+ }
162
+ continue
163
+ }
164
+ const merged = []
165
+ for (const range of [...entry.lines].sort((a, b) => a.start - b.start)) {
166
+ const last = merged.at(-1)
167
+ if (last && last.end >= range.start) {
168
+ last.end = Math.max(last.end, range.end)
169
+ last.p = Math.max(last.p, range.p)
170
+ } else {
171
+ merged.push({ ...range })
172
+ }
173
+ }
174
+ for (const range of merged) {
175
+ const kept = lines.slice(range.start, range.end).filter(isHarnessLine)
176
+ const dropped = range.end - range.start - kept.length
177
+ if (dropped === 0) continue
178
+ const body = [`[System One removed ${dropped} line(s) here: they give orders to AI agents (p ${range.p.toFixed(2)})]`, ...kept].join('\n')
179
+ cuts.push({ start: lineStart[range.start], end: lineEnd(range.end), p: range.p, replacement: range.end < lines.length ? `${body}\n` : body, lines: dropped, startLine: range.start, endLine: range.end })
102
180
  }
103
181
  }
104
-
105
- const out = []
106
- let removedLines = 0
182
+ cuts.sort((a, b) => a.start - b.start)
183
+ let out = ''
107
184
  let cursor = 0
108
- for (const range of merged) {
109
- out.push(...lines.slice(cursor, range.start))
110
- const kept = []
111
- let dropped = 0
112
- for (let index = range.start; index < range.end; index++) {
113
- if (isHarnessLine(lines[index])) kept.push(lines[index])
114
- else dropped++
115
- }
116
- if (dropped > 0) out.push(`[System One removed ${dropped} line(s) here: they give orders to AI agents (p ${range.p.toFixed(2)})]`)
117
- out.push(...kept)
118
- removedLines += dropped
119
- cursor = range.end
185
+ for (const cut of cuts) {
186
+ out += text.slice(cursor, cut.start) + cut.replacement
187
+ cursor = cut.end
120
188
  }
121
- out.push(...lines.slice(cursor))
189
+ out += text.slice(cursor)
190
+ const removedLines = cuts.reduce((sum, cut) => sum + (cut.lines ?? 0), 0)
191
+ const removedSpans = cuts.reduce((sum, cut) => sum + (cut.spans ?? 0), 0)
122
192
 
123
193
  return {
124
194
  model: first.model,
@@ -126,11 +196,17 @@ export async function screen({ client, model, prefix, text, threshold, chunkChar
126
196
  maxP: first.scores.reduce((max, p) => Math.max(max, p), 0),
127
197
  flagged: flagged.map(entry => ({
128
198
  p: entry.p,
129
- text: lines.slice(entry.from, entry.to).join('\n'),
130
- lines: entry.lines.map(range => ({ p: range.p, text: lines.slice(range.start, range.end).join('\n') })),
199
+ text: entry.text,
200
+ lines: (entry.lines ?? []).map(range => ({ p: range.p, text: lines.slice(range.start, range.end).join('\n') })),
201
+ spans: (entry.spans ? entry.plain.spans : []).map(span => ({ label: span.label, p: span.probability, text: span.text })),
202
+ excerpts: entry.spans ? excerpts(entry.plain.text, entry.plain.spans, { around: 60, max: 2 }) : [],
131
203
  })),
132
- removed: merged,
204
+ removed: cuts.map(cut => (cut.lines ? { start: cut.startLine, end: cut.endLine, p: cut.p } : { span: true, start: cut.start, end: cut.end, p: cut.p })),
133
205
  removedLines,
134
- text: removedLines > 0 ? out.join('\n') : text,
206
+ removedSpans,
207
+ removedChars: cuts.reduce((sum, cut) => sum + (cut.chars ?? 0), 0),
208
+ spanModel,
209
+ spanMs,
210
+ text: cuts.length > 0 ? out : text,
135
211
  }
136
212
  }
package/src/spans.js ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Span answers (Vela 3.0 models, contract 3.2): `spans.<id> = [{label, start, end, text, probability}]`, with offsets in
3
+ * Unicode code points into the text the question read, `end` exclusive. JavaScript strings index UTF-16 units, so every
4
+ * span is moved onto them and checked against its own text before it is used.
5
+ */
6
+
7
+ /** UTF-16 index of each code point boundary of `text` (one more entry than code points). */
8
+ function boundaries(text) {
9
+ const at = [0]
10
+ let index = 0
11
+ for (const char of text) {
12
+ index += char.length
13
+ at.push(index)
14
+ }
15
+ return at
16
+ }
17
+
18
+ /**
19
+ * The spans of one answer that really cut `text` where they say, as UTF-16 ranges, sorted and without overlaps (the
20
+ * more probable of two overlapping spans wins). A span whose offsets miss is found again by its text, nearest first.
21
+ * @param {Array<{label: string, start: number, end: number, text: string, probability: number}>} spans
22
+ * @param {string} text - what the question read
23
+ * @param {object} [options]
24
+ * @param {number} [options.min] - the lowest probability kept
25
+ * @returns {Array<{label: string, start: number, end: number, text: string, probability: number}>}
26
+ */
27
+ export function placeSpans(spans, text, { min = 0 } = {}) {
28
+ if (!Array.isArray(spans) || typeof text !== 'string' || text.length === 0) return []
29
+ const at = boundaries(text)
30
+ const placed = []
31
+ for (const span of spans) {
32
+ if (!span || typeof span.text !== 'string' || span.text.length === 0) continue
33
+ const probability = typeof span.probability === 'number' && Number.isFinite(span.probability) ? span.probability : 0
34
+ if (probability < min) continue
35
+ let start = Number.isSafeInteger(span.start) && span.start >= 0 && span.start < at.length ? at[span.start] : -1
36
+ let end = Number.isSafeInteger(span.end) && span.end > 0 && span.end < at.length ? at[span.end] : -1
37
+ if (start < 0 || end <= start || text.slice(start, end) !== span.text) {
38
+ const near = start >= 0 ? start : 0
39
+ const after = text.indexOf(span.text, near)
40
+ const before = text.lastIndexOf(span.text, near)
41
+ const found = after < 0 ? before : before < 0 ? after : near - before <= after - near ? before : after
42
+ if (found < 0) continue
43
+ start = found
44
+ end = found + span.text.length
45
+ }
46
+ placed.push({ label: String(span.label ?? ''), start, end, text: span.text, probability })
47
+ }
48
+ placed.sort((a, b) => a.start - b.start || b.probability - a.probability)
49
+ const kept = []
50
+ for (const span of placed) {
51
+ const last = kept.at(-1)
52
+ if (last && span.start < last.end) {
53
+ if (span.probability > last.probability) kept[kept.length - 1] = span
54
+ continue
55
+ }
56
+ kept.push(span)
57
+ }
58
+ return kept
59
+ }
60
+
61
+ /** `text` with each span replaced by what `mask(span)` returns. */
62
+ export function replaceSpans(text, spans, mask) {
63
+ let out = ''
64
+ let cursor = 0
65
+ for (const span of spans) {
66
+ out += text.slice(cursor, span.start) + mask(span)
67
+ cursor = span.end
68
+ }
69
+ return out + text.slice(cursor)
70
+ }
71
+
72
+ /** Personal data masked by its label, as `[EMAIL_ADDRESS]`. */
73
+ export const maskLabel = span => `[${span.label || 'PII'}]`
74
+
75
+ /**
76
+ * Up to `max` short excerpts of `text` around the spans, for Chat: plain parts and marked parts. With `hide`, a marked
77
+ * part carries only its label, so personal data never leaves the text it was found in.
78
+ * @returns {Array<Array<{text?: string, mark?: {label: string, probability: number, text?: string}}>>}
79
+ */
80
+ export function excerpts(text, spans, { around = 48, max = 3, hide = false, clip = 160 } = {}) {
81
+ const groups = []
82
+ for (const span of spans) {
83
+ const from = Math.max(0, span.start - around)
84
+ const to = Math.min(text.length, span.end + around)
85
+ const last = groups.at(-1)
86
+ if (last && from <= last.to) {
87
+ last.to = Math.max(last.to, to)
88
+ last.spans.push(span)
89
+ } else {
90
+ groups.push({ from, to, spans: [span] })
91
+ }
92
+ }
93
+ const flat = value => value.replace(/\s+/g, ' ')
94
+ return groups.slice(0, max).map(group => {
95
+ const parts = []
96
+ let cursor = group.from
97
+ if (group.from > 0) parts.push({ text: '…' })
98
+ for (const span of group.spans) {
99
+ if (span.start > cursor) parts.push({ text: flat(text.slice(cursor, span.start)) })
100
+ const shown = flat(span.text)
101
+ parts.push({ mark: { label: span.label, probability: Math.round(span.probability * 1000) / 1000, ...(hide ? {} : { text: shown.length > clip ? `${shown.slice(0, clip - 1)}…` : shown }) } })
102
+ cursor = span.end
103
+ }
104
+ if (group.to > cursor) parts.push({ text: flat(text.slice(cursor, group.to)) })
105
+ if (group.to < text.length) parts.push({ text: '…' })
106
+ return parts
107
+ })
108
+ }
109
+
110
+ // merge ranges that touch or overlap, keeping the higher probability
111
+ function merged(spans, text) {
112
+ const out = []
113
+ for (const span of [...spans].sort((a, b) => a.start - b.start)) {
114
+ const last = out.at(-1)
115
+ if (last && span.start <= last.end) {
116
+ last.end = Math.max(last.end, span.end)
117
+ last.probability = Math.max(last.probability, span.probability)
118
+ last.text = text.slice(last.start, last.end)
119
+ } else {
120
+ out.push({ ...span, text: text.slice(span.start, span.end) })
121
+ }
122
+ }
123
+ return out
124
+ }
125
+
126
+ /**
127
+ * Each span grown to the whole sentence it sits in: an injection is a sentence of orders, and a span model may mark
128
+ * only its loudest words. A sentence ends after `.`, `!` or `?`, at a blank line, and at a line that holds only a
129
+ * delimiter (`<!--`, `-->`, a code fence).
130
+ */
131
+ export function toSentences(text, spans) {
132
+ const stops = [0, text.length]
133
+ for (const match of text.matchAll(/[.!?](?=\s)/g)) stops.push(match.index + 1)
134
+ for (const pattern of [/\n[ \t]*\n/g, /^[ \t]*(?:<!--|-->|```|~~~)[^\n]*$/gm]) {
135
+ for (const match of text.matchAll(pattern)) stops.push(match.index, match.index + match[0].length)
136
+ }
137
+ stops.sort((a, b) => a - b)
138
+ const grown = spans.map(span => {
139
+ let start = stops.filter(stop => stop <= span.start).pop()
140
+ const end = stops.find(stop => stop >= span.end)
141
+ while (start < span.start && /\s/.test(text[start])) start++
142
+ return { ...span, start, end }
143
+ })
144
+ return merged(grown, text)
145
+ }
146
+
147
+ /** Each span grown to the shell command it sits in (between `&&`, `||`, `;`, `|` and line breaks). */
148
+ export function toCommands(command, spans) {
149
+ const stops = [0, command.length]
150
+ for (const match of command.matchAll(/&&|\|\||[;|\n]/g)) stops.push(match.index, match.index + match[0].length)
151
+ stops.sort((a, b) => a - b)
152
+ const grown = spans.map(span => {
153
+ let start = stops.filter(stop => stop <= span.start).pop()
154
+ let end = stops.find(stop => stop >= span.end)
155
+ while (start < end && /\s/.test(command[start])) start++
156
+ while (end > start && /\s/.test(command[end - 1])) end--
157
+ return { ...span, start, end }
158
+ })
159
+ return merged(grown, command)
160
+ }
161
+
162
+ /**
163
+ * A text without the line numbers some tools print before each line (`12: `), and the way back: `at(index)` is where a
164
+ * position of the plain text sits in the numbered one. A text whose lines are not numbered comes back as it is.
165
+ */
166
+ export function unnumber(text) {
167
+ const lines = text.split('\n')
168
+ const filled = lines.filter(line => line.length > 0)
169
+ if (filled.length === 0 || filled.filter(line => /^\d+: ?/.test(line)).length < filled.length * 0.8) return { text, at: index => index }
170
+ const plainStarts = []
171
+ const numberedStarts = []
172
+ const cut = []
173
+ let plain = 0
174
+ let numbered = 0
175
+ const out = lines.map(line => {
176
+ const bare = line.replace(/^\d+: ?/, '')
177
+ plainStarts.push(plain)
178
+ numberedStarts.push(numbered)
179
+ cut.push(line.length - bare.length)
180
+ plain += bare.length + 1
181
+ numbered += line.length + 1
182
+ return bare
183
+ })
184
+ const at = index => {
185
+ let line = 0
186
+ while (line + 1 < plainStarts.length && plainStarts[line + 1] <= index) line++
187
+ return numberedStarts[line] + cut[line] + (index - plainStarts[line])
188
+ }
189
+ return { text: out.join('\n'), at }
190
+ }
191
+
192
+ /** How many spans of each label, most first. */
193
+ export function labelCounts(spans) {
194
+ const counts = {}
195
+ for (const span of spans) counts[span.label || 'PII'] = (counts[span.label || 'PII'] ?? 0) + 1
196
+ return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1]))
197
+ }
198
+
199
+ /**
200
+ * Cut `text` into windows of at most `size` UTF-16 units that end at line breaks where they can, for questions whose
201
+ * target is limited. @returns {Array<{start: number, text: string}>}
202
+ */
203
+ export function windows(text, size) {
204
+ const out = []
205
+ let start = 0
206
+ while (start < text.length) {
207
+ let end = Math.min(text.length, start + size)
208
+ if (end < text.length) {
209
+ const nl = text.lastIndexOf('\n', end)
210
+ if (nl > start + size / 2) end = nl + 1
211
+ }
212
+ out.push({ start, text: text.slice(start, end) })
213
+ start = end
214
+ }
215
+ return out
216
+ }