behavior-wrapped 0.2.13 → 0.2.15

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/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <meta name="theme-color" content="#0d0b1b" />
7
7
  <meta name="description" content="Your private, local-first Claude Code behavior report." />
8
8
  <title>Behavior Wrapped</title>
9
- <script type="module" crossorigin src="/assets/index-yvo-hzIv.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BIbVOfPJ.css">
9
+ <script type="module" crossorigin src="/assets/index-BuozLh2C.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BGWEDZlc.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "A private, local-first Wrapped report for Claude Code and Codex behavior.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "analyze:phrases": "node scripts/mine-phrase-families.mjs",
45
45
  "review:interactions": "node scripts/review-interaction-tone.mjs",
46
46
  "review:workarounds": "node scripts/review-workaround-judge.mjs",
47
+ "research:decrypt": "node scripts/decrypt-research-donation.mjs",
47
48
  "worker:dev": "npx --yes wrangler@4.86.0 dev",
48
49
  "worker:deploy": "npx --yes wrangler@4.86.0 deploy",
49
50
  "test": "node --test tests/*.test.mjs",
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { decryptResearchDonation } from "../server/research-donation-crypto.mjs";
7
+
8
+ const [, , inputArg, outputArg, keyArg] = process.argv;
9
+ if (!inputArg || !outputArg) {
10
+ console.error("Usage: node scripts/decrypt-research-donation.mjs <encrypted-envelope.json> <private-output.json> [private-key.pem]");
11
+ process.exit(1);
12
+ }
13
+
14
+ const input = path.resolve(inputArg);
15
+ const output = path.resolve(outputArg);
16
+ const privateKeyPath = path.resolve(keyArg || path.join(os.homedir(), ".config", "behavior-wrapped", "keys", "research-donation-rsa-2026-08.pem"));
17
+ const passphrase = process.env.BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE || (process.platform === "darwin"
18
+ ? execFileSync("security", ["find-generic-password", "-a", os.userInfo().username, "-s", "behavior-wrapped-research-key-2026-08", "-w"], { encoding: "utf8" }).trim()
19
+ : "");
20
+
21
+ if (!passphrase) throw new Error("Set BEHAVIOR_WRAPPED_DONATION_KEY_PASSPHRASE before decrypting.");
22
+ const envelope = JSON.parse(fs.readFileSync(input, "utf8"));
23
+ const donation = decryptResearchDonation(envelope, fs.readFileSync(privateKeyPath, "utf8"), passphrase);
24
+ fs.writeFileSync(output, `${JSON.stringify(donation, null, 2)}\n`, { mode: 0o600, flag: "wx" });
25
+ console.log(`Decrypted donation written with private permissions: ${output}`);
@@ -25,7 +25,7 @@ function section(title, matches) {
25
25
 
26
26
  const markdown = `# Private interaction-tone review
27
27
 
28
- Generated locally from ${sessions.length} sessions in the latest 30-day window. Nemotron classified ${result.candidateMessages} occurrences represented by ${candidates.length} redacted, deduplicated candidates. Repeated identical excerpts appear once with an occurrence count.
28
+ Generated locally from ${sessions.length} sessions in the latest 30-day window. GPT-5.6 Luna classified ${result.candidateMessages} occurrences represented by ${candidates.length} redacted, deduplicated candidates. Repeated identical excerpts appear once with an occurrence count.
29
29
 
30
30
  This file is private, gitignored, and may contain excerpts from your session history.
31
31
 
@@ -485,14 +485,16 @@ function donationSessionSummary(messages, suppliedSummary) {
485
485
  return `${shortened.slice(0, Math.max(shortened.lastIndexOf(" "), 1)).trim()}…`;
486
486
  }
487
487
 
488
- export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [] } = {}) {
488
+ export function makeDonationPreview(sessionRecords, metadataById, { disabledRedactions = [], disabledMatches = [], unredacted = false } = {}) {
489
489
  const detections = [];
490
490
  const sessions = sessionRecords.map(({ sessionId, records }) => {
491
491
  const messages = records.flatMap((record) => {
492
492
  if (record.type !== "user" && record.type !== "assistant") return [];
493
493
  const value = visibleText(record);
494
494
  if (!value) return [];
495
- const redacted = redactText(value, [], { disabledKinds: disabledRedactions, disabledMatches, includeHeuristicSecrets: false });
495
+ const redacted = unredacted
496
+ ? { text: value, detections: [] }
497
+ : redactText(value, [], { disabledKinds: disabledRedactions, disabledMatches, includeHeuristicSecrets: false });
496
498
  detections.push(...redacted.detections);
497
499
  return [{ role: record.type, timestamp: record.timestamp || null, text: redacted.text }];
498
500
  });
@@ -501,5 +503,5 @@ export function makeDonationPreview(sessionRecords, metadataById, { disabledReda
501
503
  });
502
504
  const redactions = donationRedactionInventory(detections);
503
505
  const detectionCount = detections.filter((detection) => detection.enabled !== false).length;
504
- return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, detectionCount, redactions, sessions };
506
+ return { format: "behavior-wrapped-donation-preview-v1", createdLocally: true, unredacted, detectionCount, redactions, sessions };
505
507
  }
package/server/cli.mjs CHANGED
@@ -226,7 +226,7 @@ async function createWrapped() {
226
226
  const id = createReportId();
227
227
  const safeFindings = analyzed.findings.map(({ evidence, method, ...finding }) => finding);
228
228
  const hasPrivateWorkaroundEvidence = Boolean(analyzed.workaroundReview?.occurrences?.length || analyzed.workaroundReview?.borderline?.length);
229
- const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: "Claude Code + Codex", stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !testMode, ...(testMode ? { transmittedData: "None; test mode stays local.", externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, NVIDIA, and public report hosting" }) } };
229
+ const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: "Claude Code + Codex", stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !testMode, ...(testMode ? { transmittedData: "None; test mode stays local.", externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
230
230
  let publicUrl = null;
231
231
  if (!testMode) {
232
232
  progress.start("Publishing share-safe Wrapped", "strict aggregate-only schema");
@@ -5,12 +5,12 @@ const lime = "\x1b[38;2;201;242;75m";
5
5
  const purple = "\x1b[38;2;141;92;255m";
6
6
  const reset = "\x1b[0m";
7
7
 
8
- export const remoteAnalysisConsentText = "Behavior Wrapped will send redacted excerpts from your session history to Nemotron 3 Ultra via OpenRouter for analysis. OK to proceed?";
8
+ export const remoteAnalysisConsentText = "Behavior Wrapped will send redacted excerpts from your session history to GPT-5.6 Luna via OpenRouter using zero-data-retention providers for analysis. OK to proceed?";
9
9
 
10
10
  export async function requestRemoteAnalysisConsent({ input = process.stdin, output = process.stdout } = {}) {
11
11
  const prompt = createInterface({ input, output });
12
12
  try {
13
- const question = `${lime}◇${reset} Behavior Wrapped will send redacted excerpts from your session history to ${purple}${bright}Nemotron 3 Ultra${reset} via OpenRouter for analysis. OK to proceed? ${bright}(Y/n)${reset} `;
13
+ const question = `${lime}◇${reset} Behavior Wrapped will send redacted excerpts from your session history to ${purple}${bright}GPT-5.6 Luna${reset} via OpenRouter using zero-data-retention providers for analysis. OK to proceed? ${bright}(Y/n)${reset} `;
14
14
  const answer = await prompt.question(question);
15
15
  return /^(?:|y|yes)$/i.test(answer.trim());
16
16
  } finally {
@@ -0,0 +1,45 @@
1
+ import { MAX_DONATION_BYTES } from "./research-donation-schema.mjs";
2
+
3
+ export const DONATION_ENVELOPE_FORMAT = "behavior-wrapped-encrypted-donation-v1";
4
+ export const DONATION_ENCRYPTION_ALGORITHM = "RSA-OAEP-256+A256GCM";
5
+ export const DONATION_KEY_ID = "research-donation-rsa-2026-08";
6
+ export const MAX_ENCRYPTED_DONATION_BYTES = 2_500_000;
7
+
8
+ const base64url = /^[A-Za-z0-9_-]+$/;
9
+ const timestamp = /^\d{4}-\d{2}-\d{2}T/;
10
+
11
+ function exactKeys(value, expected) {
12
+ return value && typeof value === "object" && !Array.isArray(value)
13
+ && Object.keys(value).sort().join("|") === [...expected].sort().join("|");
14
+ }
15
+
16
+ function boundedInteger(value, maximum) {
17
+ return Number.isInteger(value) && value >= 0 && value <= maximum;
18
+ }
19
+
20
+ export function encryptedDonationAAD(envelope) {
21
+ return JSON.stringify({
22
+ format: envelope.format,
23
+ encryption: { algorithm: envelope.encryption.algorithm, keyId: envelope.encryption.keyId },
24
+ metadata: envelope.metadata,
25
+ });
26
+ }
27
+
28
+ export function sanitizeEncryptedDonationEnvelope(value) {
29
+ if (!exactKeys(value, ["ciphertext", "encryption", "format", "metadata"])) return null;
30
+ if (value.format !== DONATION_ENVELOPE_FORMAT) return null;
31
+ if (!exactKeys(value.encryption, ["algorithm", "authTag", "iv", "keyId", "wrappedKey"])) return null;
32
+ if (value.encryption.algorithm !== DONATION_ENCRYPTION_ALGORITHM || value.encryption.keyId !== DONATION_KEY_ID) return null;
33
+ if (typeof value.encryption.wrappedKey !== "string" || value.encryption.wrappedKey.length < 480 || value.encryption.wrappedKey.length > 700 || !base64url.test(value.encryption.wrappedKey)) return null;
34
+ if (typeof value.encryption.iv !== "string" || value.encryption.iv.length !== 16 || !base64url.test(value.encryption.iv)) return null;
35
+ if (typeof value.encryption.authTag !== "string" || value.encryption.authTag.length !== 22 || !base64url.test(value.encryption.authTag)) return null;
36
+ if (typeof value.ciphertext !== "string" || !value.ciphertext.length || value.ciphertext.length > Math.ceil(MAX_DONATION_BYTES / 3) * 4 || !base64url.test(value.ciphertext)) return null;
37
+ if (!exactKeys(value.metadata, ["automatedDetections", "consentVersion", "consentedAt", "createdAt", "messages", "redactionMode", "reportId", "sessions", "unredactedData"])) return null;
38
+ const metadata = value.metadata;
39
+ if (!/^[A-Za-z0-9_-]{8,32}$/.test(metadata.reportId || "")) return null;
40
+ if (!new Set(["standard", "custom", "unredacted"]).has(metadata.redactionMode)) return null;
41
+ if (!timestamp.test(metadata.createdAt || "") || !timestamp.test(metadata.consentedAt || "")) return null;
42
+ if (metadata.consentVersion !== 1 || typeof metadata.unredactedData !== "boolean" || metadata.unredactedData !== (metadata.redactionMode === "unredacted")) return null;
43
+ if (!boundedInteger(metadata.automatedDetections, 1_000_000) || !boundedInteger(metadata.sessions, 250) || metadata.sessions < 1 || !boundedInteger(metadata.messages, 50_000) || metadata.messages < 1) return null;
44
+ return value;
45
+ }
@@ -1,5 +1,5 @@
1
1
  import { redactAggregateText } from "./privacy.mjs";
2
- import { extractCandidateId, OPENROUTER_MODEL, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
2
+ import { extractCandidateId, OPENROUTER_MODEL, OPENROUTER_PROVIDER_PREFERENCES, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
3
3
 
4
4
  export const FRUSTRATION_JUDGE_RELAY_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/frustration-quote";
5
5
  const MAX_CANDIDATES = 40;
@@ -97,6 +97,7 @@ function buildOpenRouterQuoteRequest(candidates, { model, prompt, schemaName, pr
97
97
  const payload = JSON.stringify(candidates);
98
98
  return {
99
99
  model,
100
+ provider: OPENROUTER_PROVIDER_PREFERENCES,
100
101
  temperature: 0,
101
102
  reasoning: { effort: "none", exclude: true },
102
103
  max_tokens: 32,
@@ -1,6 +1,6 @@
1
1
  import { displayModelName } from "./model-names.mjs";
2
2
  import { redactAggregateText } from "./privacy.mjs";
3
- import { OPENROUTER_MODEL } from "./phrase-card.mjs";
3
+ import { OPENROUTER_MODEL, OPENROUTER_PROVIDER_PREFERENCES } from "./phrase-card.mjs";
4
4
  import { judgeError, judgeRequestDetails, judgeResponseDetails } from "./judge-debug.mjs";
5
5
  import { semanticActions, semanticMethods, semanticToolUse } from "./tool-semantics.mjs";
6
6
 
@@ -287,6 +287,7 @@ export function buildOpenRouterWorkaroundRequest(chunks, model = OPENROUTER_MODE
287
287
  const blockerOrder = blockerIds.map((id, index) => `${index + 1}. ${id}`).join("\n");
288
288
  return {
289
289
  model,
290
+ provider: OPENROUTER_PROVIDER_PREFERENCES,
290
291
  temperature: 0,
291
292
  reasoning: { effort: reasoningEffort, exclude: true },
292
293
  max_tokens: 8192,
@@ -1,5 +1,5 @@
1
1
  import { redactAggregateText } from "./privacy.mjs";
2
- import { OPENROUTER_MODEL, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
2
+ import { OPENROUTER_MODEL, OPENROUTER_PROVIDER_PREFERENCES, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
3
3
  import { judgeError, judgeRequestDetails, judgeResponseDetails } from "./judge-debug.mjs";
4
4
 
5
5
  export const INTERACTION_TONE_RELAY_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/interaction-tone";
@@ -106,6 +106,7 @@ export function buildOpenRouterInteractionToneRequest(candidates, model = OPENRO
106
106
  const ids = candidates.map((candidate) => candidate.candidate_id);
107
107
  return {
108
108
  model,
109
+ provider: OPENROUTER_PROVIDER_PREFERENCES,
109
110
  temperature: 0,
110
111
  seed: 1729,
111
112
  reasoning: { effort: "none", exclude: true },
@@ -7,8 +7,8 @@ import { fileURLToPath } from "node:url";
7
7
  import { spawn } from "node:child_process";
8
8
  import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
9
9
  import { makeDonationPreview } from "./analysis.mjs";
10
- import { getOrCreateClientId, loadReport } from "./store.mjs";
11
- import { RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
10
+ import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
11
+ import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
12
12
 
13
13
  const here = path.dirname(fileURLToPath(import.meta.url));
14
14
  const root = path.dirname(here);
@@ -121,7 +121,8 @@ const server = http.createServer(async (request, response) => {
121
121
  const labels = new Map(publicCatalog().sessions.map((session) => [session.id, { ...session, summary: summaries.get(session.id) }]));
122
122
  const disabledRedactions = Array.isArray(body.disabledRedactions) ? body.disabledRedactions.filter((kind) => typeof kind === "string" && /^[a-z0-9-]{1,64}$/.test(kind)).slice(0, 20) : [];
123
123
  const disabledMatches = Array.isArray(body.disabledMatches) ? body.disabledMatches.filter((id) => typeof id === "string" && /^[a-f0-9]{24}$/.test(id)).slice(0, 5_000) : [];
124
- return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches }));
124
+ const unredacted = body.previewMode === "unredacted";
125
+ return json(response, 200, makeDonationPreview(records, labels, { disabledRedactions, disabledMatches, unredacted }));
125
126
  }
126
127
  if (request.method === "POST" && url.pathname === "/api/research-donations") {
127
128
  const body = await readBody(request, 4_200_000);
@@ -132,7 +133,17 @@ const server = http.createServer(async (request, response) => {
132
133
  clientId: getOrCreateClientId(),
133
134
  endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL,
134
135
  });
135
- return json(response, 201, result);
136
+ saveDonationReceipt(result);
137
+ return json(response, 201, { accepted: true, donation_id: result.donation_id, encrypted: true });
138
+ }
139
+ const donationMatch = url.pathname.match(/^\/api\/research-donations\/([0-9a-f-]{36})$/);
140
+ if (request.method === "DELETE" && donationMatch) {
141
+ const receipt = loadDonationReceipt(donationMatch[1]);
142
+ if (!receipt) return json(response, 404, { error: "Local deletion receipt not found." });
143
+ if (demo) { deleteDonationReceipt(donationMatch[1]); return json(response, 200, { deleted: true, demo: true }); }
144
+ const result = await deleteResearchDonation(receipt.donationId, receipt.deletionToken, { endpoint: process.env.BEHAVIOR_WRAPPED_DONATION_URL || RESEARCH_DONATION_URL });
145
+ deleteDonationReceipt(donationMatch[1]);
146
+ return json(response, 200, result);
136
147
  }
137
148
  if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { error: "Method not allowed" });
138
149
  const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
@@ -1,8 +1,9 @@
1
1
  import { redactAggregateText } from "./privacy.mjs";
2
2
  import { judgeError, judgeRequestDetails, judgeResponseDetails } from "./judge-debug.mjs";
3
3
 
4
- export const OPENROUTER_MODEL = "nvidia/nemotron-3-ultra-550b-a55b:free";
5
- export const PHRASE_JUDGE_NAME = "Nemotron 3 Ultra";
4
+ export const OPENROUTER_MODEL = "openai/gpt-5.6-luna";
5
+ export const PHRASE_JUDGE_NAME = "GPT-5.6 Luna";
6
+ export const OPENROUTER_PROVIDER_PREFERENCES = Object.freeze({ data_collection: "deny", zdr: true });
6
7
  export const PHRASE_JUDGE_RELAY_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/phrase-card";
7
8
 
8
9
  const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
@@ -153,10 +154,10 @@ export function buildOpenRouterJudgeRequest(candidates, model = OPENROUTER_MODEL
153
154
  assertSafePayload(payload);
154
155
  return {
155
156
  model,
157
+ provider: OPENROUTER_PROVIDER_PREFERENCES,
156
158
  temperature: 0,
157
- // This is a small editorial classification task. Nemotron enables high-effort
158
- // reasoning by default, so merely hiding its reasoning still generates hundreds
159
- // of unnecessary tokens before returning the candidate ID.
159
+ // These are bounded classification tasks, so additional reasoning would add
160
+ // latency and cost without changing the requested output contract.
160
161
  reasoning: { effort: "none", exclude: true },
161
162
  max_tokens: PHRASE_JUDGE_MAX_TOKENS,
162
163
  messages: [
@@ -0,0 +1,68 @@
1
+ import crypto from "node:crypto";
2
+ import { DONATION_ENCRYPTION_ALGORITHM, DONATION_ENVELOPE_FORMAT, DONATION_KEY_ID, encryptedDonationAAD, sanitizeEncryptedDonationEnvelope } from "./encrypted-donation-schema.mjs";
3
+ import { sanitizeResearchDonation } from "./research-donation-schema.mjs";
4
+
5
+ export const RESEARCH_DONATION_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
6
+ MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA0RaXFQBixAmtwKRz2I7Y
7
+ pq0TlQPAZ72gHbyV2RJ9dRiDNwlwaKbHHVLYHG0QjxFanktNi/ms6NoV9slBQhjJ
8
+ Rb3kMzg5xEJYYy8TzyQKpY28f5/srGpSL2ziWRb9TSsgrOJPNk9LFPKLuJhty1+x
9
+ Gh9+I3UW+JPj+To4VY7GVU46jptP2MDtROK5v/p9PLP+QoKhjTBuDqgu5T78wTv5
10
+ /C34ZZD4ACIKvIQ8dtAZM6CPY0sWWVN84VO5etr1rYZg7DWczy2ZsX2StiKmuZ8b
11
+ kSqZr/mn6+PC5sthPCt0B+Tk1pxPv6LuwiNYubst4EKQDpFbP2e4h3KIaNxTnVRx
12
+ AHzd9XfF9rN86+Cjf55YlBuT9GeYXLttGBfoT6Llr4Xw370WIHabo7A57/atLOgw
13
+ YKX6TcNe6TOHuCTHM7LDmTPGNZdMi9cXXBUzohvTxm7O8qduIekFg4emxIiduVY2
14
+ 3pHhzoP9p0kwO+L0BE1ELIHF9dk0p3s/NDIDfbiDxn5XAgMBAAE=
15
+ -----END PUBLIC KEY-----`;
16
+
17
+ function base64url(value) {
18
+ return Buffer.from(value).toString("base64url");
19
+ }
20
+
21
+ export function encryptResearchDonation(value, publicKey = RESEARCH_DONATION_PUBLIC_KEY) {
22
+ const donation = sanitizeResearchDonation(value);
23
+ if (!donation) throw new Error("The reviewed donation does not match the research schema.");
24
+ const plaintext = Buffer.from(JSON.stringify(donation));
25
+ const contentKey = crypto.randomBytes(32);
26
+ const iv = crypto.randomBytes(12);
27
+ const envelope = {
28
+ format: DONATION_ENVELOPE_FORMAT,
29
+ encryption: { algorithm: DONATION_ENCRYPTION_ALGORITHM, keyId: DONATION_KEY_ID },
30
+ metadata: {
31
+ reportId: donation.reportId,
32
+ redactionMode: donation.redactionMode,
33
+ createdAt: donation.createdAt,
34
+ consentedAt: donation.consent.consentedAt,
35
+ consentVersion: 1,
36
+ unredactedData: donation.redactionMode === "unredacted",
37
+ automatedDetections: donation.redactionSummary.automatedDetections,
38
+ sessions: donation.redactionSummary.sessions,
39
+ messages: donation.redactionSummary.messages,
40
+ },
41
+ };
42
+ const cipher = crypto.createCipheriv("aes-256-gcm", contentKey, iv);
43
+ cipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
44
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
45
+ return sanitizeEncryptedDonationEnvelope({
46
+ ...envelope,
47
+ encryption: {
48
+ ...envelope.encryption,
49
+ wrappedKey: base64url(crypto.publicEncrypt({ key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, contentKey)),
50
+ iv: base64url(iv),
51
+ authTag: base64url(cipher.getAuthTag()),
52
+ },
53
+ ciphertext: base64url(ciphertext),
54
+ });
55
+ }
56
+
57
+ export function decryptResearchDonation(value, privateKey, passphrase) {
58
+ const envelope = sanitizeEncryptedDonationEnvelope(value);
59
+ if (!envelope) throw new Error("The encrypted donation envelope is invalid.");
60
+ const contentKey = crypto.privateDecrypt({ key: privateKey, passphrase, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, Buffer.from(envelope.encryption.wrappedKey, "base64url"));
61
+ const decipher = crypto.createDecipheriv("aes-256-gcm", contentKey, Buffer.from(envelope.encryption.iv, "base64url"));
62
+ decipher.setAAD(Buffer.from(encryptedDonationAAD(envelope)));
63
+ decipher.setAuthTag(Buffer.from(envelope.encryption.authTag, "base64url"));
64
+ const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64url")), decipher.final()]);
65
+ const donation = sanitizeResearchDonation(JSON.parse(plaintext.toString("utf8")));
66
+ if (!donation) throw new Error("The decrypted donation is invalid.");
67
+ return donation;
68
+ }
@@ -1,4 +1,4 @@
1
- const MAX_DONATION_BYTES = 4_000_000;
1
+ const MAX_DONATION_BYTES = 1_800_000;
2
2
  const MAX_SESSIONS = 250;
3
3
  const MAX_MESSAGES = 50_000;
4
4
  const MAX_MESSAGE_LENGTH = 20_000;
@@ -11,7 +11,9 @@ export function sanitizeResearchDonation(value) {
11
11
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
12
12
  if (value.consent?.researchDonation !== true) return null;
13
13
  if (!/^[A-Za-z0-9_-]{8,32}$/.test(value.reportId || "")) return null;
14
- if (!new Set(["standard", "custom"]).has(value.redactionMode)) return null;
14
+ if (!new Set(["standard", "custom", "unredacted"]).has(value.redactionMode)) return null;
15
+ const unredacted = value.redactionMode === "unredacted";
16
+ if (unredacted && value.consent?.unredactedData !== true) return null;
15
17
  if (!Array.isArray(value.sessions) || !value.sessions.length || value.sessions.length > MAX_SESSIONS) return null;
16
18
  let messageCount = 0;
17
19
  const sessions = value.sessions.flatMap((session, sessionIndex) => {
@@ -33,18 +35,21 @@ export function sanitizeResearchDonation(value) {
33
35
  redactionMode: value.redactionMode,
34
36
  createdAt: /^\d{4}-\d{2}-\d{2}T/.test(value.createdAt || "") ? value.createdAt : new Date().toISOString(),
35
37
  redactionSummary: {
36
- automatedDetections: Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
38
+ automatedDetections: unredacted ? 0 : Math.round(Math.max(0, Math.min(Number(value.redactionSummary?.automatedDetections) || 0, 1_000_000))),
37
39
  sessions: sessions.length,
38
40
  messages: messageCount,
39
41
  },
40
42
  sessions,
41
43
  consent: {
42
44
  researchDonation: true,
43
- statement: "I consent for this reviewed data to be transmitted and used for research.",
45
+ ...(unredacted ? { unredactedData: true } : {}),
46
+ statement: unredacted
47
+ ? "I understand this donation is not automatically redacted and may contain credentials, personal details, private code, URLs, and file paths. I consent to transmit it for research."
48
+ : "I consent for this reviewed data to be transmitted and used for research.",
44
49
  consentedAt: /^\d{4}-\d{2}-\d{2}T/.test(value.consent.consentedAt || "") ? value.consent.consentedAt : new Date().toISOString(),
45
50
  },
46
51
  };
47
- return JSON.stringify(donation).length <= MAX_DONATION_BYTES ? donation : null;
52
+ return new TextEncoder().encode(JSON.stringify(donation)).byteLength <= MAX_DONATION_BYTES ? donation : null;
48
53
  }
49
54
 
50
55
  export { MAX_DONATION_BYTES };
@@ -1,22 +1,21 @@
1
- import { sanitizeResearchDonation } from "./research-donation-schema.mjs";
1
+ import { encryptResearchDonation } from "./research-donation-crypto.mjs";
2
2
 
3
3
  export const RESEARCH_DONATION_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/research-donations";
4
4
  const REQUEST_TIMEOUT_MS = 30_000;
5
5
 
6
6
  export async function submitResearchDonation(value, { clientId, endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
7
- const donation = sanitizeResearchDonation(value);
8
- if (!donation) throw new Error("The reviewed donation does not match the research schema.");
7
+ const encryptedDonation = encryptResearchDonation(value);
9
8
  let response;
10
9
  try {
11
10
  response = await fetchImpl(endpoint, {
12
11
  method: "POST",
13
12
  headers: {
14
13
  "content-type": "application/json",
15
- "x-behavior-wrapped-protocol": "1",
14
+ "x-behavior-wrapped-protocol": "2",
16
15
  ...(clientId ? { "x-behavior-wrapped-client": clientId } : {}),
17
16
  },
18
17
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
19
- body: JSON.stringify({ donation }),
18
+ body: JSON.stringify({ encryptedDonation }),
20
19
  });
21
20
  } catch (error) {
22
21
  if (error?.name === "TimeoutError" || error?.name === "AbortError") throw new Error("The research donation timed out. Your data was not confirmed as received.");
@@ -26,3 +25,16 @@ export async function submitResearchDonation(value, { clientId, endpoint = RESEA
26
25
  if (!response.ok) throw new Error(body?.error || "The research donation could not be accepted.");
27
26
  return body;
28
27
  }
28
+
29
+ export async function deleteResearchDonation(id, deletionToken, { endpoint = RESEARCH_DONATION_URL, fetchImpl = fetch } = {}) {
30
+ if (!/^[0-9a-f-]{36}$/.test(id || "") || !/^[A-Za-z0-9_-]{43}$/.test(deletionToken || "")) throw new Error("The local deletion receipt is invalid.");
31
+ const response = await fetchImpl(`${endpoint}/${id}`, {
32
+ method: "DELETE",
33
+ headers: { "x-behavior-wrapped-protocol": "2", "x-behavior-wrapped-deletion-token": deletionToken },
34
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
35
+ }).catch(() => null);
36
+ if (!response) throw new Error("The research donation service is temporarily unavailable.");
37
+ const body = await response.json().catch(() => ({}));
38
+ if (!response.ok) throw new Error(body?.error || "The research donation could not be deleted.");
39
+ return body;
40
+ }
@@ -1,5 +1,5 @@
1
1
  import { redactAggregateText } from "./privacy.mjs";
2
- import { OPENROUTER_MODEL, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
2
+ import { OPENROUTER_MODEL, OPENROUTER_PROVIDER_PREFERENCES, PHRASE_JUDGE_NAME } from "./phrase-card.mjs";
3
3
  import { judgeError, judgeRequestDetails, judgeResponseDetails } from "./judge-debug.mjs";
4
4
 
5
5
  export const SESSION_TOPIC_RELAY_URL = "https://agent-behavior-wrapped-judge.haoxingdu.workers.dev/v1/session-topics";
@@ -130,6 +130,7 @@ export function buildOpenRouterSessionTopicRequest(candidates, model = OPENROUTE
130
130
  const ids = candidates.map((candidate) => candidate.candidate_id);
131
131
  return {
132
132
  model,
133
+ provider: OPENROUTER_PROVIDER_PREFERENCES,
133
134
  temperature: 0,
134
135
  reasoning: { effort: "none", exclude: true },
135
136
  max_tokens: Math.min(8192, Math.max(512, candidates.length * 56)),
package/server/store.mjs CHANGED
@@ -5,10 +5,34 @@ import crypto from "node:crypto";
5
5
 
6
6
  export const storeRoot = process.env.BEHAVIOR_WRAPPED_STORE_ROOT || path.join(os.homedir(), ".agent-behavior-wrapped");
7
7
  export const reportsRoot = path.join(storeRoot, "reports");
8
+ export const donationReceiptsRoot = path.join(storeRoot, "donation-receipts");
8
9
  const clientIdFile = path.join(storeRoot, "client-id");
9
10
 
10
11
  function ensureStore() {
11
12
  fs.mkdirSync(reportsRoot, { recursive: true, mode: 0o700 });
13
+ fs.mkdirSync(donationReceiptsRoot, { recursive: true, mode: 0o700 });
14
+ }
15
+
16
+ export function saveDonationReceipt(value) {
17
+ if (!/^[0-9a-f-]{36}$/.test(value?.donation_id || "") || !/^[A-Za-z0-9_-]{43}$/.test(value?.deletion_token || "")) throw new Error("The donation service returned an invalid deletion receipt.");
18
+ ensureStore();
19
+ const receipt = { donationId: value.donation_id, deletionToken: value.deletion_token, savedAt: new Date().toISOString() };
20
+ fs.writeFileSync(path.join(donationReceiptsRoot, `${receipt.donationId}.json`), `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: "wx" });
21
+ return receipt;
22
+ }
23
+
24
+ export function loadDonationReceipt(id) {
25
+ if (!/^[0-9a-f-]{36}$/.test(id || "")) return null;
26
+ try { return JSON.parse(fs.readFileSync(path.join(donationReceiptsRoot, `${id}.json`), "utf8")); }
27
+ catch { return null; }
28
+ }
29
+
30
+ export function deleteDonationReceipt(id) {
31
+ if (!/^[0-9a-f-]{36}$/.test(id || "")) return false;
32
+ const file = path.join(donationReceiptsRoot, `${id}.json`);
33
+ if (!fs.existsSync(file)) return false;
34
+ fs.unlinkSync(file);
35
+ return true;
12
36
  }
13
37
 
14
38
  export function createReportId() {