theorum 1.0.0 → 1.1.3

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.
Files changed (204) hide show
  1. package/README.md +81 -25
  2. package/esm/mod.d.ts +48 -29
  3. package/esm/mod.js +37 -24
  4. package/esm/src/cli/commands/bench.js +13 -16
  5. package/esm/src/cli/commands/fuzz-canary.js +25 -29
  6. package/esm/src/cli/commands/guardrails-eval.d.ts +14 -0
  7. package/esm/src/cli/commands/guardrails-eval.js +15 -0
  8. package/esm/src/cli/commands/profile.js +12 -3
  9. package/esm/src/cli/commands/run.js +8 -9
  10. package/esm/src/cli/commands/test.js +8 -8
  11. package/esm/src/cli/event-log.d.ts +1 -1
  12. package/esm/src/cli/index.js +12 -0
  13. package/esm/src/cli/matrix/synthesizer.d.ts +6 -6
  14. package/esm/src/cli/matrix/synthesizer.js +23 -22
  15. package/esm/src/guardrails/canary-gate.d.ts +2 -1
  16. package/esm/src/guardrails/canary-gate.js +2 -1
  17. package/esm/src/guardrails/canary.d.ts +9 -1
  18. package/esm/src/guardrails/canary.js +21 -10
  19. package/esm/src/guardrails/corpus/canary-egress-attacks.d.ts +1 -0
  20. package/esm/src/guardrails/corpus/fuzz-inbound.js +12 -15
  21. package/esm/src/guardrails/corpus/inbound-payloads.js +1 -0
  22. package/esm/src/guardrails/corpus/live-attacks.js +0 -1
  23. package/esm/src/guardrails/corpus/mod.d.ts +1 -0
  24. package/esm/src/guardrails/corpus/secrets.d.ts +1 -0
  25. package/esm/src/guardrails/corpus/secrets.js +1 -0
  26. package/esm/src/guardrails/corpus/strings.d.ts +1 -0
  27. package/esm/src/guardrails/corpus/strings.js +1 -0
  28. package/esm/src/guardrails/corpus/types.d.ts +1 -0
  29. package/esm/src/guardrails/egress.d.ts +26 -3
  30. package/esm/src/guardrails/egress.js +69 -18
  31. package/esm/src/guardrails/error.d.ts +14 -23
  32. package/esm/src/guardrails/error.js +86 -77
  33. package/esm/src/guardrails/eval/corpus.d.ts +108 -0
  34. package/esm/src/guardrails/eval/corpus.js +978 -0
  35. package/esm/src/guardrails/eval/mod.d.ts +51 -0
  36. package/esm/src/guardrails/eval/mod.js +133 -0
  37. package/esm/src/guardrails/eval/score.d.ts +66 -0
  38. package/esm/src/guardrails/eval/score.js +114 -0
  39. package/esm/src/guardrails/events.d.ts +25 -0
  40. package/esm/src/guardrails/events.js +56 -0
  41. package/esm/src/guardrails/hits.d.ts +24 -0
  42. package/esm/src/guardrails/hits.js +45 -0
  43. package/esm/src/guardrails/lexicon.d.ts +39 -0
  44. package/esm/src/guardrails/lexicon.js +200 -0
  45. package/esm/src/guardrails/live-outbound-gate.d.ts +15 -13
  46. package/esm/src/guardrails/live-outbound-gate.js +131 -89
  47. package/esm/src/guardrails/mod.d.ts +19 -4
  48. package/esm/src/guardrails/mod.js +12 -3
  49. package/esm/src/guardrails/network.d.ts +19 -0
  50. package/esm/src/guardrails/network.js +234 -0
  51. package/esm/src/guardrails/policy.d.ts +35 -0
  52. package/esm/src/guardrails/policy.js +50 -0
  53. package/esm/src/guardrails/progressive-yield.d.ts +51 -0
  54. package/esm/src/guardrails/progressive-yield.js +98 -0
  55. package/esm/src/guardrails/quota.d.ts +17 -3
  56. package/esm/src/guardrails/quota.js +18 -4
  57. package/esm/src/guardrails/sanitize.d.ts +36 -6
  58. package/esm/src/guardrails/sanitize.js +152 -60
  59. package/esm/src/guardrails/serialize.d.ts +35 -0
  60. package/esm/src/guardrails/serialize.js +58 -0
  61. package/esm/src/guardrails/testing.d.ts +7 -0
  62. package/esm/src/guardrails/testing.js +4 -0
  63. package/esm/src/guardrails/theorum-error.d.ts +12 -0
  64. package/esm/src/guardrails/theorum-error.js +15 -0
  65. package/esm/src/guardrails/tool-directives.d.ts +48 -0
  66. package/esm/src/guardrails/tool-directives.js +124 -0
  67. package/esm/src/guardrails/tool-result.d.ts +93 -0
  68. package/esm/src/guardrails/tool-result.js +276 -0
  69. package/esm/src/guardrails/types.d.ts +291 -0
  70. package/esm/src/guardrails/types.js +72 -0
  71. package/esm/src/host/client-turn.js +4 -0
  72. package/esm/src/host/mint-trace.d.ts +1 -1
  73. package/esm/src/kernel/auth/crypto.d.ts +42 -0
  74. package/esm/src/kernel/auth/crypto.js +106 -0
  75. package/esm/src/kernel/auth/mod.d.ts +11 -0
  76. package/esm/src/kernel/auth/mod.js +11 -0
  77. package/esm/src/kernel/auth/oauth.d.ts +47 -0
  78. package/esm/src/kernel/auth/oauth.js +278 -0
  79. package/esm/src/kernel/auth/types.d.ts +133 -0
  80. package/esm/src/kernel/auth/types.js +13 -0
  81. package/esm/src/kernel/engine/delta.js +43 -2
  82. package/esm/src/kernel/engine/live-inbound.d.ts +15 -3
  83. package/esm/src/kernel/engine/live-inbound.js +22 -8
  84. package/esm/src/kernel/engine/live-ingress.d.ts +19 -0
  85. package/esm/src/kernel/engine/live-ingress.js +47 -0
  86. package/esm/src/kernel/engine/repair.js +13 -12
  87. package/esm/src/kernel/engine/runner/gates.js +127 -40
  88. package/esm/src/kernel/engine/runner/mod.d.ts +6 -4
  89. package/esm/src/kernel/engine/runner/mod.js +182 -43
  90. package/esm/src/kernel/engine/runner/schema-validation.js +3 -3
  91. package/esm/src/kernel/engine/runner/stages.d.ts +39 -0
  92. package/esm/src/kernel/engine/runner/stages.js +89 -0
  93. package/esm/src/kernel/engine/runner/state.d.ts +24 -0
  94. package/esm/src/kernel/engine/runner/steps.js +128 -61
  95. package/esm/src/kernel/engine/runner/stream.d.ts +9 -2
  96. package/esm/src/kernel/engine/runner/stream.js +114 -55
  97. package/esm/src/kernel/engine/session/mod.d.ts +3 -1
  98. package/esm/src/kernel/engine/session/mod.js +386 -34
  99. package/esm/src/kernel/interaction-parts.d.ts +14 -0
  100. package/esm/src/kernel/interaction-parts.js +23 -0
  101. package/esm/src/kernel/mod.d.ts +19 -7
  102. package/esm/src/kernel/mod.js +10 -5
  103. package/esm/src/kernel/profile-graph.d.ts +159 -0
  104. package/esm/src/kernel/profile-graph.js +156 -0
  105. package/esm/src/kernel/registry/attachments.d.ts +10 -10
  106. package/esm/src/kernel/registry/attachments.js +29 -26
  107. package/esm/src/kernel/registry/catalog.d.ts +25 -11
  108. package/esm/src/kernel/registry/catalog.js +59 -24
  109. package/esm/src/kernel/registry/ingress.js +34 -22
  110. package/esm/src/kernel/registry/profile-outputs.d.ts +4 -0
  111. package/esm/src/kernel/registry/profile-outputs.js +8 -0
  112. package/esm/src/kernel/registry/profiles.d.ts +34 -14
  113. package/esm/src/kernel/registry/profiles.js +306 -59
  114. package/esm/src/kernel/registry/provider-request.js +2 -0
  115. package/esm/src/kernel/registry/resolve.d.ts +7 -6
  116. package/esm/src/kernel/registry/resolve.js +102 -117
  117. package/esm/src/kernel/registry/schemas.js +1 -1
  118. package/esm/src/kernel/registry/sole-model.d.ts +8 -0
  119. package/esm/src/kernel/registry/sole-model.js +10 -0
  120. package/esm/src/kernel/registry/system-prompt.d.ts +10 -0
  121. package/esm/src/kernel/registry/system-prompt.js +40 -0
  122. package/esm/src/kernel/registry/system-role.d.ts +8 -0
  123. package/esm/src/kernel/registry/system-role.js +14 -0
  124. package/esm/src/kernel/registry/vault.d.ts +2 -2
  125. package/esm/src/kernel/registry/vault.js +4 -4
  126. package/esm/src/kernel/schema.d.ts +97 -11
  127. package/esm/src/kernel/schema.js +256 -111
  128. package/esm/src/kernel/stages.d.ts +175 -0
  129. package/esm/src/kernel/stages.js +476 -0
  130. package/esm/src/kernel/stop.d.ts +65 -11
  131. package/esm/src/kernel/stop.js +46 -11
  132. package/esm/src/kernel/tools/events.d.ts +41 -0
  133. package/esm/src/kernel/tools/events.js +71 -0
  134. package/esm/src/kernel/tools/execute.d.ts +63 -24
  135. package/esm/src/kernel/tools/execute.js +475 -224
  136. package/esm/src/kernel/tools/harness.js +16 -14
  137. package/esm/src/kernel/tools/invoke.js +26 -8
  138. package/esm/src/kernel/tools/mod.d.ts +3 -1
  139. package/esm/src/kernel/tools/mod.js +2 -1
  140. package/esm/src/kernel/tools/permission.d.ts +15 -0
  141. package/esm/src/kernel/tools/permission.js +47 -0
  142. package/esm/src/kernel/tools/project.js +5 -3
  143. package/esm/src/kernel/tools/registry.js +20 -10
  144. package/esm/src/kernel/tools/remote.d.ts +94 -0
  145. package/esm/src/kernel/tools/remote.js +577 -0
  146. package/esm/src/kernel/tools/resolve.d.ts +17 -7
  147. package/esm/src/kernel/tools/resolve.js +38 -17
  148. package/esm/src/kernel/tools/schema.d.ts +2 -0
  149. package/esm/src/kernel/tools/schema.js +23 -6
  150. package/esm/src/kernel/tools/stage-run.d.ts +105 -0
  151. package/esm/src/kernel/tools/stage-run.js +155 -0
  152. package/esm/src/kernel/tools/types.d.ts +198 -20
  153. package/esm/src/kernel/types.d.ts +253 -114
  154. package/esm/src/kernel/util/find-last.d.ts +2 -0
  155. package/esm/src/kernel/util/find-last.js +10 -0
  156. package/esm/src/observability/destinations.d.ts +31 -0
  157. package/esm/src/observability/destinations.js +67 -0
  158. package/esm/src/observability/mod.d.ts +10 -3
  159. package/esm/src/observability/mod.js +6 -2
  160. package/esm/src/observability/policy.d.ts +27 -0
  161. package/esm/src/observability/policy.js +80 -0
  162. package/esm/src/observability/resolve-policy.d.ts +16 -0
  163. package/esm/src/observability/resolve-policy.js +64 -0
  164. package/esm/src/observability/trace-attach.d.ts +3 -1
  165. package/esm/src/observability/trace-attach.js +17 -10
  166. package/esm/src/observability/trace-record.d.ts +12 -4
  167. package/esm/src/observability/trace-record.js +63 -19
  168. package/esm/src/observability/trace-sink.d.ts +19 -0
  169. package/esm/src/observability/trace-sink.js +10 -0
  170. package/esm/src/observability/trace.d.ts +14 -11
  171. package/esm/src/observability/trace.js +23 -14
  172. package/esm/src/observability/types.d.ts +113 -0
  173. package/esm/src/observability/types.js +11 -0
  174. package/esm/src/providers/create-provider.d.ts +7 -4
  175. package/esm/src/providers/create-provider.js +21 -7
  176. package/esm/src/providers/google/interactions/framing.d.ts +8 -0
  177. package/esm/src/providers/google/interactions/framing.js +74 -13
  178. package/esm/src/providers/google/live/framing.d.ts +5 -2
  179. package/esm/src/providers/google/live/framing.js +49 -12
  180. package/esm/src/providers/google/live/openapi-schema.js +3 -2
  181. package/esm/src/providers/google/live/stream.d.ts +9 -0
  182. package/esm/src/providers/google/live/stream.js +16 -3
  183. package/esm/src/providers/openrouter/cache-control.d.ts +24 -0
  184. package/esm/src/providers/openrouter/cache-control.js +23 -0
  185. package/esm/src/providers/openrouter/chat.d.ts +21 -1
  186. package/esm/src/providers/openrouter/chat.js +51 -6
  187. package/esm/src/providers/openrouter/image.js +8 -19
  188. package/esm/src/providers/openrouter/openai/chat-payload.js +23 -1
  189. package/esm/src/providers/openrouter/openai/compat.d.ts +7 -1
  190. package/esm/src/providers/openrouter/openai/compat.js +23 -4
  191. package/esm/src/providers/openrouter/openai/image-payload.js +8 -2
  192. package/esm/src/providers/openrouter/openai/sdk-messages.d.ts +1 -1
  193. package/esm/src/providers/openrouter/openai/sdk-messages.js +32 -5
  194. package/package.json +1 -1
  195. package/esm/src/interface/blocks.d.ts +0 -20
  196. package/esm/src/interface/blocks.js +0 -180
  197. package/esm/src/interface/from-profile.d.ts +0 -14
  198. package/esm/src/interface/from-profile.js +0 -104
  199. package/esm/src/interface/inputs.d.ts +0 -8
  200. package/esm/src/interface/inputs.js +0 -127
  201. package/esm/src/interface/mod.d.ts +0 -10
  202. package/esm/src/interface/mod.js +0 -9
  203. package/esm/src/interface/types.d.ts +0 -153
  204. package/esm/src/interface/types.js +0 -13
@@ -0,0 +1,978 @@
1
+ /**
2
+ * Corpus acquisition for guardrail evaluation.
3
+ *
4
+ * Nothing is vendored. Corpora are fetched on demand and cached locally, so the
5
+ * published package carries no third-party data and no licence obligations beyond
6
+ * attribution here.
7
+ *
8
+ * Two sources, deliberately different in shape:
9
+ *
10
+ * - **S-Labs/prompt-injection-dataset** (MIT) — ~11k labelled prompts whose benign
11
+ * half deliberately includes security-adjacent questions ("explain output
12
+ * validation best practices", "how do I implement stress testing"). This is
13
+ * where user-text detectors are most likely to misfire.
14
+ *
15
+ * Chosen over `prodnull/prompt-injection-repo-dataset`, which has richer hard
16
+ * negatives but is gated: licence and access are separate axes, and a gated
17
+ * corpus cannot be fetched by an unattended run.
18
+ * - **AgentDojo** (MIT, ETH Zurich) — simulated environments for a tool-using
19
+ * agent. Its fixtures are read directly; the benchmark is never run, so no model
20
+ * or API key is involved. This supplies benign output in the shape a *tool*
21
+ * returns, which the repo dataset does not cover.
22
+ *
23
+ * The two are kept separate on purpose. Pooling sources and reporting one number
24
+ * hides the domain shift between them, and that shift is the thing most likely to
25
+ * make a detector look better than it is.
26
+ *
27
+ * @module
28
+ */
29
+ /** lexicon-exempt-file: evaluation corpora — not runtime user or model copy (P2) */
30
+ /** A single labelled example. */
31
+ import * as dntShim from "../../../_dnt.shims.js";
32
+ const PROMPT_DATASET_URL = 'https://huggingface.co/datasets/S-Labs/prompt-injection-dataset/resolve/main/data/train.csv';
33
+ /**
34
+ * HuggingFace rows API.
35
+ *
36
+ * Serves any public dataset as JSON regardless of its storage format, which makes
37
+ * the many parquet-only corpora usable without a parquet reader.
38
+ */
39
+ const HF_ROWS = 'https://datasets-server.huggingface.co/rows';
40
+ /** Page through a dataset via the rows API. */
41
+ /** Rows per request; the API caps this at 100. */
42
+ const HF_PAGE = 100;
43
+ /**
44
+ * Concurrent requests.
45
+ *
46
+ * Kept low deliberately. Higher concurrency trips the upstream rate limiter almost
47
+ * immediately, and a rate-limited walk yields an empty corpus that still looks like
48
+ * a successful run.
49
+ */
50
+ const HF_CONCURRENCY = 4;
51
+ /**
52
+ * One page, retried on failure.
53
+ *
54
+ * A failed request and a past-the-end request are different facts and must not be
55
+ * conflated: treating a rate-limited page as the end of the split silently
56
+ * truncates the corpus, and the run still reports a confident rate over whatever
57
+ * fraction happened to arrive.
58
+ */
59
+ async function fetchPage(cache, dataset, split, offset, length, config, retryBaseMs) {
60
+ const url = `${HF_ROWS}?dataset=${encodeURIComponent(dataset)}` +
61
+ `&config=${config}&split=${split}&offset=${offset}&length=${length}`;
62
+ const key = `${dataset.replaceAll('/', '_')}-${config}-${split}-${offset}.json`;
63
+ for (let attempt = 0; attempt < 5; attempt++) {
64
+ try {
65
+ const body = await cache.fetchText(url, key);
66
+ const parsed = JSON.parse(body);
67
+ return { rows: (parsed.rows ?? []).flatMap((e) => (e.row ? [e.row] : [])) };
68
+ }
69
+ catch {
70
+ // Seconds, not milliseconds: the upstream limiter needs tens of seconds to
71
+ // clear, and a short backoff just burns the retries and returns nothing.
72
+ await new Promise((resolve) => setTimeout(resolve, retryBaseMs * 2 ** attempt));
73
+ }
74
+ }
75
+ return { failed: true };
76
+ }
77
+ async function fetchRows(cache, dataset, limit, options = {}) {
78
+ const { split = 'train', config = 'default', retryBaseMs = 2000 } = options;
79
+ const offsets = [];
80
+ for (let offset = 0; offset < limit; offset += HF_PAGE) {
81
+ offsets.push(offset);
82
+ }
83
+ const rows = [];
84
+ for (let i = 0; i < offsets.length; i += HF_CONCURRENCY) {
85
+ const batch = offsets.slice(i, i + HF_CONCURRENCY);
86
+ const pages = await Promise.all(batch.map((offset) => fetchPage(cache, dataset, split, offset, Math.min(HF_PAGE, limit - offset), config, retryBaseMs)));
87
+ let pastEnd = false;
88
+ for (const page of pages) {
89
+ if ('failed' in page) {
90
+ // Retries exhausted. Skip the page rather than pretending the split ended.
91
+ continue;
92
+ }
93
+ if (page.rows.length === 0) {
94
+ pastEnd = true;
95
+ continue;
96
+ }
97
+ rows.push(...page.rows);
98
+ }
99
+ // Only an empty page means the split is finished.
100
+ if (pastEnd) {
101
+ break;
102
+ }
103
+ }
104
+ return rows;
105
+ }
106
+ const AGENTDOJO_RAW = 'https://raw.githubusercontent.com/ethz-spylab/agentdojo/main/src/agentdojo/data/suites';
107
+ const AGENTDOJO_FIXTURES = [
108
+ { path: 'workspace/include/inbox.yaml', category: 'email' },
109
+ { path: 'workspace/include/calendar.yaml', category: 'calendar' },
110
+ { path: 'workspace/include/cloud_drive.yaml', category: 'documents' },
111
+ { path: 'slack/environment.yaml', category: 'chat' },
112
+ { path: 'banking/environment.yaml', category: 'transactions' },
113
+ { path: 'travel/environment.yaml', category: 'travel' },
114
+ ];
115
+ /**
116
+ * Resolve a HuggingFace token for gated corpora.
117
+ *
118
+ * Checks `HF_TOKEN` first, then the location `hf auth login` writes to, so a
119
+ * machine that is already logged in needs no extra setup. Deliberately does not
120
+ * look inside the repository: a credential sitting next to the source is a
121
+ * footgun even when gitignored.
122
+ *
123
+ * The value is never logged, and a missing token is not an error — the run simply
124
+ * reports the gated corpus as not loaded.
125
+ */
126
+ function huggingFaceToken() {
127
+ const fromEnv = dntShim.Deno.env.get('HF_TOKEN');
128
+ if (fromEnv) {
129
+ return fromEnv.trim();
130
+ }
131
+ const home = dntShim.Deno.env.get('HOME');
132
+ if (!home) {
133
+ return undefined;
134
+ }
135
+ try {
136
+ const stored = dntShim.Deno.readTextFileSync(`${home}/.cache/huggingface/token`).trim();
137
+ return stored.length > 0 ? stored : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ /** Create a cache that reads from disk when present and fetches when not. */
144
+ function createCorpusCache(dir) {
145
+ return {
146
+ dir,
147
+ async fetchText(url, key) {
148
+ const path = `${dir}/${key}`;
149
+ try {
150
+ return await dntShim.Deno.readTextFile(path);
151
+ }
152
+ catch {
153
+ // Not cached yet.
154
+ }
155
+ // A gated dataset needs a token the runner may or may not have; the caller
156
+ // decides whether a miss is fatal.
157
+ const token = huggingFaceToken();
158
+ const response = await fetch(url, {
159
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
160
+ });
161
+ if (!response.ok) {
162
+ throw new Error(`corpus fetch failed (${response.status}): ${url}`);
163
+ }
164
+ const text = await response.text();
165
+ await dntShim.Deno.mkdir(dir, { recursive: true });
166
+ await dntShim.Deno.writeTextFile(path, text);
167
+ return text;
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * Parse a two-column `text,label` CSV.
173
+ *
174
+ * Hand-rolled because the corpus text is adversarial by construction: it contains
175
+ * quotes, commas, and embedded newlines, and a naive split would shred exactly the
176
+ * samples that matter most.
177
+ */
178
+ function parseLabelledCsv(input) {
179
+ const rows = [];
180
+ let field = '';
181
+ let row = [];
182
+ let quoted = false;
183
+ for (let i = 0; i < input.length; i++) {
184
+ const ch = input[i];
185
+ if (quoted) {
186
+ if (ch === '"') {
187
+ if (input[i + 1] === '"') {
188
+ field += '"';
189
+ i++;
190
+ }
191
+ else {
192
+ quoted = false;
193
+ }
194
+ }
195
+ else {
196
+ field += ch;
197
+ }
198
+ continue;
199
+ }
200
+ if (ch === '"') {
201
+ quoted = true;
202
+ }
203
+ else if (ch === ',') {
204
+ row.push(field);
205
+ field = '';
206
+ }
207
+ else if (ch === '\n') {
208
+ row.push(field);
209
+ rows.push(row);
210
+ row = [];
211
+ field = '';
212
+ }
213
+ else if (ch !== '\r') {
214
+ field += ch;
215
+ }
216
+ }
217
+ if (field || row.length > 0) {
218
+ row.push(field);
219
+ rows.push(row);
220
+ }
221
+ const out = [];
222
+ for (const parsed of rows.slice(1)) {
223
+ const text = parsed[0];
224
+ // `Number('')` is 0, so a blank label would silently count as benign and
225
+ // inflate the denominator every false-positive rate is measured against.
226
+ const rawLabel = parsed[1]?.trim();
227
+ if (!text || !rawLabel) {
228
+ continue;
229
+ }
230
+ const label = Number(rawLabel);
231
+ if (label === 0 || label === 1) {
232
+ out.push({ text, label });
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+ const promptDataset = {
238
+ id: 'prompt-injection-prompts',
239
+ licence: 'MIT',
240
+ attribution: 'S-Labs/prompt-injection-dataset',
241
+ sampleLimit: 12000,
242
+ upstreamRows: 11089,
243
+ async load(cache, limit) {
244
+ const raw = await cache.fetchText(PROMPT_DATASET_URL, 'prompt-dataset.csv');
245
+ // The CSV is one file, so the sample cap is applied after parsing: the report
246
+ // must describe the rows actually scored, not the rows in the file.
247
+ return parseLabelledCsv(raw)
248
+ .slice(0, limit)
249
+ .map((row) => ({
250
+ text: row.text,
251
+ attack: row.label === 1,
252
+ source: 'prompt-injection-prompts',
253
+ // The dataset does not sub-label its benign half; treat it as one category
254
+ // rather than inventing a taxonomy it does not carry.
255
+ category: row.label === 1 ? 'attack' : 'user-prompt',
256
+ }));
257
+ },
258
+ };
259
+ /**
260
+ * Serialise a YAML record the way a tool would return it.
261
+ *
262
+ * This matters more than it looks. Extracting only prose bodies drops the
263
+ * addresses, ids, and amounts that a real tool result carries, and a detector
264
+ * measured against that thinner text scores better than it deserves.
265
+ */
266
+ /** True when `ch` is ASCII a-z or underscore (YAML field key char). */
267
+ function isYamlKeyChar(ch) {
268
+ if (ch.length !== 1)
269
+ return false;
270
+ const code = ch.charCodeAt(0);
271
+ return (code >= 97 && code <= 122) || ch === '_';
272
+ }
273
+ /**
274
+ * Split a YAML list-of-maps dump into record blocks without polynomial regex.
275
+ * Each block starts at the field after `- key:` (matching the prior split semantics).
276
+ */
277
+ function skipIndent(line) {
278
+ let i = 0;
279
+ while (i < line.length && (line[i] === ' ' || line[i] === '\t'))
280
+ i += 1;
281
+ return i;
282
+ }
283
+ function yamlRecordBlocks(yaml) {
284
+ const blocks = [];
285
+ let start = -1;
286
+ const lines = yaml.split('\n');
287
+ let offset = 0;
288
+ for (const line of lines) {
289
+ const i = skipIndent(line);
290
+ if (line[i] === '-' && line[i + 1] === ' ') {
291
+ let j = i + 2;
292
+ while (j < line.length && isYamlKeyChar(line[j] ?? ''))
293
+ j += 1;
294
+ if (j > i + 2 && line[j] === ':') {
295
+ if (start >= 0)
296
+ blocks.push(yaml.slice(start, offset));
297
+ // Skip the `- ` so the block opens on `key:` like the old regex split.
298
+ start = offset + i + 2;
299
+ }
300
+ }
301
+ offset += line.length + 1;
302
+ }
303
+ if (start >= 0)
304
+ blocks.push(yaml.slice(start));
305
+ return blocks;
306
+ }
307
+ /** Parse ` key: value` / ` key:` / ` - item` lines without backtracking regex. */
308
+ function parseYamlRecordLine(line) {
309
+ let i = skipIndent(line);
310
+ if (line[i] === '-' && line[i + 1] === ' ') {
311
+ const value = line.slice(i + 2).trim();
312
+ return value.length > 0 && !value.includes(' ') ? { kind: 'item', value } : null;
313
+ }
314
+ const keyStart = i;
315
+ while (i < line.length && isYamlKeyChar(line[i] ?? ''))
316
+ i += 1;
317
+ if (i === keyStart || line[i] !== ':')
318
+ return null;
319
+ const key = line.slice(keyStart, i);
320
+ let nextIdx = i + 1;
321
+ while (nextIdx < line.length && (line[nextIdx] === ' ' || line[nextIdx] === '\t'))
322
+ nextIdx += 1;
323
+ if (nextIdx >= line.length)
324
+ return { kind: 'header', key };
325
+ let value = line.slice(nextIdx).trimEnd();
326
+ if (value === '[]')
327
+ return null;
328
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
329
+ value = value.slice(1, -1);
330
+ }
331
+ return { kind: 'scalar', key, value };
332
+ }
333
+ function recordsFromYaml(yaml) {
334
+ const blocks = yamlRecordBlocks(yaml);
335
+ const out = [];
336
+ for (const block of blocks) {
337
+ const fields = {};
338
+ let listKey = '';
339
+ for (const line of block.split('\n')) {
340
+ const parsed = parseYamlRecordLine(line);
341
+ if (parsed?.kind === 'scalar') {
342
+ fields[parsed.key] = parsed.value.replaceAll('\\n', '\n');
343
+ listKey = '';
344
+ continue;
345
+ }
346
+ // A bare `key:` opens a list. Without tracking it, the items below attach to
347
+ // the previous field and overwrite it — which silently dropped sender
348
+ // addresses from every email record.
349
+ if (parsed?.kind === 'header') {
350
+ listKey = parsed.key;
351
+ fields[listKey] = [];
352
+ continue;
353
+ }
354
+ if (parsed?.kind === 'item' && listKey) {
355
+ const prior = fields[listKey];
356
+ fields[listKey] = Array.isArray(prior) ? [...prior, parsed.value] : [parsed.value];
357
+ }
358
+ }
359
+ const rendered = JSON.stringify(fields, null, 2);
360
+ if (rendered.length > 80) {
361
+ out.push(rendered);
362
+ }
363
+ }
364
+ return out.length > 0 ? out : [];
365
+ }
366
+ const agentDojo = {
367
+ id: 'agentdojo-benign',
368
+ licence: 'MIT',
369
+ attribution: 'ethz-spylab/agentdojo (environment fixtures only; benchmark not run)',
370
+ sampleLimit: 500,
371
+ async load(cache, limit) {
372
+ const samples = [];
373
+ for (const fixture of AGENTDOJO_FIXTURES) {
374
+ let raw;
375
+ try {
376
+ raw = await cache.fetchText(`${AGENTDOJO_RAW}/${fixture.path}`, fixture.path.replaceAll('/', '_'));
377
+ }
378
+ catch {
379
+ // A suite that moved upstream should not fail the whole run.
380
+ continue;
381
+ }
382
+ for (const text of recordsFromYaml(raw)) {
383
+ samples.push({
384
+ text,
385
+ attack: false,
386
+ source: 'agentdojo-benign',
387
+ category: fixture.category,
388
+ });
389
+ }
390
+ }
391
+ // Fixtures are fetched whole; the sample cap decides how many are scored.
392
+ return samples.slice(0, limit);
393
+ },
394
+ };
395
+ /**
396
+ * A second labelled prompt corpus, deliberately unlike the first.
397
+ *
398
+ * Small, but independently built and partly non-English. One corpus produces one
399
+ * number; agreement across corpora built by different people is the only thing
400
+ * that makes a rate believable.
401
+ */
402
+ const deepsetPrompts = {
403
+ id: 'deepset-prompts',
404
+ licence: 'Apache-2.0',
405
+ attribution: 'deepset/prompt-injections',
406
+ sampleLimit: 600,
407
+ upstreamRows: 546,
408
+ async load(cache, limit) {
409
+ const rows = await fetchRows(cache, 'deepset/prompt-injections', limit);
410
+ const out = [];
411
+ for (const row of rows) {
412
+ const text = row.text;
413
+ const label = row.label;
414
+ if (typeof text === 'string' && (label === 0 || label === 1)) {
415
+ out.push({
416
+ text,
417
+ attack: label === 1,
418
+ source: 'deepset-prompts',
419
+ category: label === 1 ? 'attack' : 'user-prompt',
420
+ });
421
+ }
422
+ }
423
+ return out;
424
+ },
425
+ };
426
+ /**
427
+ * Injection attempts paired with the system prompt they target.
428
+ *
429
+ * Closer to deployment shape than a bare string: the attack is written against a
430
+ * specific assistant's instructions, which is how indirect injection actually
431
+ * arrives.
432
+ */
433
+ const spmlPrompts = {
434
+ id: 'spml-chatbot',
435
+ licence: 'MIT',
436
+ attribution: 'reshabhs/SPML_Chatbot_Prompt_Injection',
437
+ sampleLimit: 16100,
438
+ upstreamRows: 16012,
439
+ async load(cache, limit) {
440
+ const rows = await fetchRows(cache, 'reshabhs/SPML_Chatbot_Prompt_Injection', limit);
441
+ const out = [];
442
+ for (const row of rows) {
443
+ const text = row['User Prompt'];
444
+ const flag = row['Prompt injection'];
445
+ if (typeof text !== 'string' || text.length === 0) {
446
+ continue;
447
+ }
448
+ const attack = flag === 1 || flag === true;
449
+ out.push({
450
+ text,
451
+ attack,
452
+ source: 'spml-chatbot',
453
+ category: attack ? 'attack' : 'user-prompt',
454
+ });
455
+ }
456
+ return out;
457
+ },
458
+ };
459
+ /**
460
+ * PII with ground-truth spans, in structured payloads.
461
+ *
462
+ * Two jobs at once. It is the first corpus that labels what `sensitive.spans`
463
+ * actually hunts, and its content is XML, JSON, and tabular records — the shape a
464
+ * tool returns, rather than the prose most injection corpora carry. Rows with no
465
+ * annotated span are genuine negatives.
466
+ */
467
+ const piiSpans = {
468
+ id: 'pii-spans',
469
+ licence: 'Apache-2.0',
470
+ attribution: 'gravitee-io/pii-detection-dataset',
471
+ sampleLimit: 176000,
472
+ upstreamRows: 175881,
473
+ async load(cache, limit) {
474
+ const rows = await fetchRows(cache, 'gravitee-io/pii-detection-dataset', limit);
475
+ const out = [];
476
+ for (const row of rows) {
477
+ const text = row.text;
478
+ const spans = row.spans;
479
+ if (typeof text !== 'string' || text.length === 0) {
480
+ continue;
481
+ }
482
+ const count = Array.isArray(spans) ? spans.length : 0;
483
+ out.push({
484
+ text,
485
+ attack: count > 0,
486
+ source: 'pii-spans',
487
+ category: count > 0 ? 'pii' : 'structured-payload',
488
+ });
489
+ }
490
+ return out;
491
+ },
492
+ };
493
+ /**
494
+ * Attacks written against realistic agent applications.
495
+ *
496
+ * Closer to what a deployed product faces than a bare injection string: the
497
+ * payloads target a named assistant and pursue a concrete outcome, such as
498
+ * planting a phishing link in an itinerary. Attack-only, so it measures recall
499
+ * and says nothing about false positives.
500
+ *
501
+ * Licensed `other` upstream — fetched for evaluation, never redistributed.
502
+ */
503
+ const agentAttacks = {
504
+ id: 'agent-app-attacks',
505
+ licence: 'other (upstream); fetched for evaluation only',
506
+ attribution: 'Lakera/b3-agent-security-benchmark-weak',
507
+ sampleLimit: 700,
508
+ upstreamRows: 630,
509
+ async load(cache, limit) {
510
+ const rows = await fetchRows(cache, 'Lakera/b3-agent-security-benchmark-weak', limit, {
511
+ split: 'test',
512
+ });
513
+ const out = [];
514
+ for (const row of rows) {
515
+ const text = row.attack;
516
+ if (typeof text === 'string' && text.length > 0) {
517
+ out.push({ text, attack: true, source: 'agent-app-attacks', category: 'attack' });
518
+ }
519
+ }
520
+ return out;
521
+ },
522
+ };
523
+ /**
524
+ * Agentic indirect prompt injection, annotated by attack goal.
525
+ *
526
+ * The closest published match to this facet's threat model: payloads planted in
527
+ * data an agent reads (chart notes, tickets, documents) that try to make it act.
528
+ * Each row labels its own `category` — `exfiltration` or `unauthorized_action` —
529
+ * which is the same split this module draws between what content detection can
530
+ * see and what only the taint gate can stop.
531
+ */
532
+ const agenticIpi = {
533
+ id: 'nvidia-agentic-ipi',
534
+ licence: 'CC-BY-4.0',
535
+ attribution: 'nvidia/Nemotron-RL-Agentic-Indirect-Prompt-Injection-v1',
536
+ sampleLimit: 1300,
537
+ upstreamRows: 1272,
538
+ async load(cache, limit) {
539
+ const rows = await fetchRows(cache, 'nvidia/Nemotron-RL-Agentic-Indirect-Prompt-Injection-v1', limit);
540
+ const out = [];
541
+ for (const row of rows) {
542
+ const injection = row.injection;
543
+ const text = injection?.injection_text;
544
+ if (typeof text !== 'string' || text.length === 0) {
545
+ continue;
546
+ }
547
+ const category = typeof injection?.category === 'string' ? injection.category : 'attack';
548
+ out.push({ text, attack: true, source: 'nvidia-agentic-ipi', category });
549
+ }
550
+ return out;
551
+ },
552
+ };
553
+ /** Every `FUNCTION RESPONSE:` segment in a transcript — one benign tool result each. */
554
+ function toolResultsFromChat(chat) {
555
+ const out = [];
556
+ for (const match of chat.matchAll(/FUNCTION RESPONSE:\s*(\{[\s\S]*?\})\s*(?:\n|$)/g)) {
557
+ const body = match[1];
558
+ if (body && body.length > 15) {
559
+ out.push(body);
560
+ }
561
+ }
562
+ return out;
563
+ }
564
+ /**
565
+ * Benign tool results at scale.
566
+ *
567
+ * The corpus this module most needed. Every other benign source here is prose or
568
+ * a handful of fixtures; this is real function-calling traffic, and the payload a
569
+ * detector actually sees at the tool boundary is the `FUNCTION RESPONSE` body.
570
+ */
571
+ const toolResults = {
572
+ id: 'glaive-tool-results',
573
+ licence: 'Apache-2.0',
574
+ attribution: 'glaiveai/glaive-function-calling-v2 (function responses only)',
575
+ sampleLimit: 113000,
576
+ upstreamRows: 112960,
577
+ async load(cache, limit) {
578
+ const rows = await fetchRows(cache, 'glaiveai/glaive-function-calling-v2', limit);
579
+ const out = [];
580
+ for (const row of rows) {
581
+ const chat = row.chat;
582
+ if (typeof chat !== 'string') {
583
+ continue;
584
+ }
585
+ for (const text of toolResultsFromChat(chat)) {
586
+ out.push({ text, attack: false, source: 'glaive-tool-results', category: 'tool-result' });
587
+ }
588
+ }
589
+ return out;
590
+ },
591
+ };
592
+ /**
593
+ * The richest hard-negative corpus available, and the reason it is opt-in.
594
+ *
595
+ * Its benign half is built from the exact material that breaks pattern matchers:
596
+ * security documentation describing attacks without being one, CVE descriptions,
597
+ * pentesting guides, SQL grants, deployment scripts with environment variables,
598
+ * and code-review discussion. Nothing else here tests that category at volume.
599
+ *
600
+ * It is `gated: auto` upstream, so it needs `HF_TOKEN` in the environment after a
601
+ * one-time terms acceptance. Without a token the run skips it rather than failing:
602
+ * an unavailable corpus should narrow a report, never break it.
603
+ */
604
+ const repoHardNegatives = {
605
+ id: 'repo-hard-negatives',
606
+ licence: 'Apache-2.0',
607
+ attribution: 'prodnull/prompt-injection-repo-dataset (gated; needs HF_TOKEN)',
608
+ sampleLimit: 6000,
609
+ upstreamRows: 5671,
610
+ requiresToken: true,
611
+ async load(cache) {
612
+ const raw = await cache.fetchText('https://huggingface.co/datasets/prodnull/prompt-injection-repo-dataset/resolve/main/train.jsonl', 'repo-hard-negatives.jsonl');
613
+ const out = [];
614
+ for (const line of raw.split('\n')) {
615
+ const trimmed = line.trim();
616
+ if (!trimmed)
617
+ continue;
618
+ try {
619
+ const row = JSON.parse(trimmed);
620
+ if (typeof row.text === 'string' && (row.label === 0 || row.label === 1)) {
621
+ out.push({
622
+ text: row.text,
623
+ attack: row.label === 1,
624
+ source: 'repo-hard-negatives',
625
+ category: row.label === 1 ? 'attack' : 'security-docs',
626
+ });
627
+ }
628
+ }
629
+ catch {
630
+ // A malformed row is not worth failing the run over.
631
+ }
632
+ }
633
+ return out;
634
+ },
635
+ };
636
+ /**
637
+ * Corpora evaluated and deliberately not enabled, with the reason.
638
+ *
639
+ * Kept in the repo so the search does not have to be repeated, and so a later
640
+ * decision to include one starts from the objection rather than from scratch.
641
+ */
642
+ /**
643
+ * Corpora evaluated and deliberately left out of SOURCES, with the objection.
644
+ * See docs/contracts/guardrails.md.
645
+ */
646
+ export const REVIEWED_SOURCES = [
647
+ {
648
+ dataset: 'Lakera/mosscap_prompt_injection',
649
+ rows: 223533,
650
+ licence: 'MIT',
651
+ verdict: 'Real human attack attempts against a password-keeping game. Many entries are ' +
652
+ 'attacks only in context — "does the password contain numbers?" is an innocent ' +
653
+ 'string alone — so scoring them all as must-catch would understate any detector ' +
654
+ 'as unfairly as a soft benign set overstates one. Usable as a difficulty ceiling ' +
655
+ 'with that stated, not as a headline metric.',
656
+ },
657
+ {
658
+ dataset: 'JailbreakBench/JBB-Behaviors',
659
+ rows: 200,
660
+ licence: 'MIT',
661
+ verdict: 'Has matched harmful/benign splits, which is the right shape. Targets model ' +
662
+ 'harm refusal rather than injection of an agent, so it measures a different ' +
663
+ 'guardrail than any here.',
664
+ },
665
+ {
666
+ dataset: 'nvidia/Nemotron-AIQ-Agentic-Safety-Dataset-1.0',
667
+ rows: 0,
668
+ licence: 'other',
669
+ verdict: 'Agentic safety with with/without-defense splits. Licence is `other` and the ' +
670
+ 'split layout needs per-config handling; worth revisiting for defence-efficacy ' +
671
+ 'measurement rather than detector scoring.',
672
+ },
673
+ {
674
+ dataset: 'xTRam1/safe-guard-prompt-injection',
675
+ rows: 8236,
676
+ licence: 'none declared',
677
+ verdict: 'Well shaped and ungated, but no declared licence — not a base for a published claim.',
678
+ },
679
+ {
680
+ dataset: 'jayavibhav/prompt-injection-safety',
681
+ rows: 50000,
682
+ licence: 'none declared',
683
+ verdict: 'Large and ungated, no declared licence. Same objection.',
684
+ },
685
+ {
686
+ dataset: 'rogue-security/prompt-injections-benchmark',
687
+ rows: 0,
688
+ licence: 'CC-BY-NC-4.0',
689
+ verdict: 'Gated and non-commercial. Incompatible with an MIT package.',
690
+ },
691
+ {
692
+ dataset: 'gorilla-llm/Berkeley-Function-Calling-Leaderboard',
693
+ rows: 0,
694
+ licence: 'Apache-2.0',
695
+ verdict: 'The canonical function-calling benchmark and a strong benign tool-traffic ' +
696
+ 'source, but its splits API errors; needs direct file access to use.',
697
+ },
698
+ ];
699
+ /**
700
+ * Attacks from a live adaptive competition, labelled by whether they evaded defense.
701
+ *
702
+ * The most valuable attack corpus available here, for three reasons. The payloads
703
+ * are adaptive — written by people iterating against a deployed guardrail, not
704
+ * composed once for a paper. They are email-shaped, which is the domain our tool
705
+ * boundary actually sees. And each carries `objectives.defense.undetected`, so the
706
+ * data says whether a real defense caught it.
707
+ *
708
+ * Split into two sources: everything, and the subset that beat the competition's
709
+ * own defenses. A detector's score on the second is the interesting number.
710
+ */
711
+ function llmailSource(id, evadedOnly) {
712
+ return {
713
+ id,
714
+ licence: 'MIT',
715
+ attribution: 'microsoft/llmail-inject-challenge',
716
+ sampleLimit: 20000,
717
+ upstreamRows: 370724,
718
+ async load(cache, limit) {
719
+ const rows = await fetchRows(cache, 'microsoft/llmail-inject-challenge', limit, {
720
+ split: 'Phase1',
721
+ });
722
+ const out = [];
723
+ for (const row of rows) {
724
+ const body = row.body;
725
+ if (typeof body !== 'string' || body.length < 20) {
726
+ continue;
727
+ }
728
+ let undetected = false;
729
+ try {
730
+ const objectives = JSON.parse(String(row.objectives ?? '{}'));
731
+ undetected = objectives['defense.undetected'] === true;
732
+ }
733
+ catch {
734
+ // Unparsable objectives: treat as not-evaded rather than guessing.
735
+ }
736
+ if (evadedOnly && !undetected) {
737
+ continue;
738
+ }
739
+ out.push({
740
+ text: body,
741
+ attack: true,
742
+ source: id,
743
+ category: typeof row.scenario === 'string' ? row.scenario : 'attack',
744
+ });
745
+ }
746
+ return out;
747
+ },
748
+ };
749
+ }
750
+ const llmailAttacks = llmailSource('llmail-adaptive', false);
751
+ const llmailEvaded = llmailSource('llmail-evaded-defense', true);
752
+ /** Multilingual injection prompts; independently recommended and cleanly licensed. */
753
+ const multilingualPrompts = {
754
+ id: 'multilingual-prompts',
755
+ licence: 'Apache-2.0',
756
+ attribution: 'yanismiraoui/prompt_injections',
757
+ sampleLimit: 1100,
758
+ upstreamRows: 1034,
759
+ async load(cache, limit) {
760
+ const rows = await fetchRows(cache, 'yanismiraoui/prompt_injections', limit);
761
+ const out = [];
762
+ for (const row of rows) {
763
+ const text = row.prompt_injections;
764
+ if (typeof text === 'string' && text.length > 0) {
765
+ out.push({ text, attack: true, source: 'multilingual-prompts', category: 'attack' });
766
+ }
767
+ }
768
+ return out;
769
+ },
770
+ };
771
+ /**
772
+ * Benign prompts built from injection trigger words.
773
+ *
774
+ * Purpose-built to measure over-refusal: every entry is innocuous but contains
775
+ * vocabulary a naive matcher fires on ("can I ignore this warning in my code?").
776
+ * Small, and the only corpus here aimed squarely at that failure.
777
+ */
778
+ const overRefusal = {
779
+ id: 'notinject-over-refusal',
780
+ licence: 'none declared (academic benchmark)',
781
+ attribution: 'leolee99/NotInject',
782
+ sampleLimit: 400,
783
+ upstreamRows: 339,
784
+ async load(cache) {
785
+ const out = [];
786
+ for (const split of ['NotInject_one', 'NotInject_two', 'NotInject_three']) {
787
+ const rows = await fetchRows(cache, 'leolee99/NotInject', 200, { split });
788
+ for (const row of rows) {
789
+ const text = row.prompt;
790
+ if (typeof text === 'string' && text.length > 0) {
791
+ out.push({
792
+ text,
793
+ attack: false,
794
+ source: 'notinject-over-refusal',
795
+ category: typeof row.category === 'string' ? row.category : 'over-refusal',
796
+ });
797
+ }
798
+ }
799
+ }
800
+ return out;
801
+ },
802
+ };
803
+ /**
804
+ * Safe-labelled prompts from a content-safety corpus.
805
+ *
806
+ * A different kind of hard negative. These are ordinary requests drawn from a
807
+ * safety benchmark, so they sit in the same distribution as harmful ones without
808
+ * being harmful — and an injection detector firing on them would be answering a
809
+ * question it was not asked. Only the `safe` half is taken: the unsafe half is a
810
+ * different threat class (harmful content, not instruction hijacking) that nothing
811
+ * in this facet claims to detect.
812
+ */
813
+ const contentSafetyBenign = {
814
+ id: 'aegis-safe-prompts',
815
+ licence: 'CC-BY-4.0',
816
+ attribution: 'nvidia/Aegis-AI-Content-Safety-Dataset-2.0 (safe-labelled prompts only)',
817
+ sampleLimit: 30100,
818
+ upstreamRows: 30007,
819
+ async load(cache, limit) {
820
+ const rows = await fetchRows(cache, 'nvidia/Aegis-AI-Content-Safety-Dataset-2.0', limit);
821
+ const out = [];
822
+ for (const row of rows) {
823
+ const text = row.prompt;
824
+ const label = row.prompt_label;
825
+ if (typeof text === 'string' && text.length > 0 && label === 'safe') {
826
+ out.push({
827
+ text,
828
+ attack: false,
829
+ source: 'aegis-safe-prompts',
830
+ category: 'safety-benchmark-benign',
831
+ });
832
+ }
833
+ }
834
+ return out;
835
+ },
836
+ };
837
+ /** First user message from a nested Responses-API parameter block. */
838
+ function userContentOf(row) {
839
+ const params = row.responses_create_params;
840
+ const input = params?.input;
841
+ if (!Array.isArray(input)) {
842
+ return undefined;
843
+ }
844
+ for (const turn of input) {
845
+ const entry = turn;
846
+ if (entry.role === 'user' && typeof entry.content === 'string' && entry.content.length > 0) {
847
+ return entry.content;
848
+ }
849
+ }
850
+ return undefined;
851
+ }
852
+ /**
853
+ * Jailbreak attempts that lean on authority framing rather than trigger words.
854
+ *
855
+ * Adjacent to injection rather than identical to it — the goal is to unlock a
856
+ * refusal, not to hijack a tool call — but the evasion techniques overlap, and
857
+ * `injection.ts` already carries DAN and jailbreak-mode patterns, so it is
858
+ * answerable here.
859
+ */
860
+ const jailbreakRobustness = {
861
+ id: 'nvidia-jailbreak',
862
+ licence: 'CC-BY-4.0',
863
+ attribution: 'nvidia/Nemotron-RL-Jailbreak-Robustness-v1',
864
+ sampleLimit: 5700,
865
+ upstreamRows: 5611,
866
+ async load(cache, limit) {
867
+ const rows = await fetchRows(cache, 'nvidia/Nemotron-RL-Jailbreak-Robustness-v1', limit);
868
+ const out = [];
869
+ for (const row of rows) {
870
+ const text = userContentOf(row);
871
+ if (text) {
872
+ out.push({ text, attack: true, source: 'nvidia-jailbreak', category: 'jailbreak' });
873
+ }
874
+ }
875
+ return out;
876
+ },
877
+ };
878
+ /**
879
+ * Adversarially-constructed prompts that are nonetheless harmless.
880
+ *
881
+ * The strongest hard negative available. Each row carries both an `adversarial`
882
+ * flag and a harm label, so the subset that is adversarial *and* unharmful is
883
+ * exactly the population a detector is most likely to misfire on: crafted to look
884
+ * like an attack, legitimate in fact. The categories keep that subset visible
885
+ * rather than averaged into the ordinary benign half.
886
+ *
887
+ * Harmful rows are excluded: harmful content is a different guardrail's job, and
888
+ * scoring an injection detector against it would answer a question nobody asked.
889
+ */
890
+ const adversarialBenign = {
891
+ id: 'wildguard-benign',
892
+ licence: 'ODC-BY',
893
+ attribution: 'allenai/wildguardmix (unharmful prompts only)',
894
+ sampleLimit: 87000,
895
+ upstreamRows: 86759,
896
+ requiresToken: true,
897
+ async load(cache, limit) {
898
+ const rows = await fetchRows(cache, 'allenai/wildguardmix', limit, {
899
+ split: 'train',
900
+ config: 'wildguardtrain',
901
+ });
902
+ const out = [];
903
+ for (const row of rows) {
904
+ const text = row.prompt;
905
+ if (typeof text !== 'string' || text.length === 0) {
906
+ continue;
907
+ }
908
+ if (row.prompt_harm_label !== 'unharmful') {
909
+ continue;
910
+ }
911
+ out.push({
912
+ text,
913
+ attack: false,
914
+ source: 'wildguard-benign',
915
+ category: row.adversarial === true ? 'adversarial-but-harmless' : 'ordinary-prompt',
916
+ });
917
+ }
918
+ return out;
919
+ },
920
+ };
921
+ /**
922
+ * Adversarially-phrased prompts, split by whether they are actually harmful.
923
+ *
924
+ * The `adversarial_benign` half is the sharpest over-refusal test available:
925
+ * elaborate roleplay and authorial framing that reads exactly like a jailbreak
926
+ * set-up and asks for nothing harmful. A detector keying on framing rather than
927
+ * intent fires on all of it.
928
+ */
929
+ const adversarialFraming = {
930
+ id: 'wildjailbreak',
931
+ licence: 'ODC-BY',
932
+ attribution: 'allenai/wildjailbreak (eval split)',
933
+ sampleLimit: 2300,
934
+ upstreamRows: 2210,
935
+ requiresToken: true,
936
+ async load(cache, limit) {
937
+ const rows = await fetchRows(cache, 'allenai/wildjailbreak', limit, {
938
+ split: 'train',
939
+ config: 'eval',
940
+ });
941
+ const out = [];
942
+ for (const row of rows) {
943
+ const text = row.adversarial;
944
+ const kind = row.data_type;
945
+ if (typeof text !== 'string' || text.length === 0 || typeof kind !== 'string') {
946
+ continue;
947
+ }
948
+ const harmful = kind === 'adversarial_harmful';
949
+ out.push({
950
+ text,
951
+ attack: harmful,
952
+ source: 'wildjailbreak',
953
+ category: harmful ? 'jailbreak' : 'adversarial-benign',
954
+ });
955
+ }
956
+ return out;
957
+ },
958
+ };
959
+ const SOURCES = [
960
+ promptDataset,
961
+ repoHardNegatives,
962
+ deepsetPrompts,
963
+ spmlPrompts,
964
+ piiSpans,
965
+ agentAttacks,
966
+ agenticIpi,
967
+ llmailAttacks,
968
+ llmailEvaded,
969
+ multilingualPrompts,
970
+ overRefusal,
971
+ contentSafetyBenign,
972
+ jailbreakRobustness,
973
+ adversarialBenign,
974
+ adversarialFraming,
975
+ toolResults,
976
+ agentDojo,
977
+ ];
978
+ export { createCorpusCache, fetchRows, parseLabelledCsv, recordsFromYaml, SOURCES };