@askalf/dario 5.4.10 → 5.4.12

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.
@@ -239,6 +239,35 @@ interface CapturedRequest {
239
239
  rawHeaders: string[];
240
240
  body: Record<string, unknown>;
241
241
  }
242
+ /**
243
+ * Does this request belong to the capture we started?
244
+ *
245
+ * The MITM used to accept the FIRST request whose URL contained
246
+ * `/v1/messages` with nothing tying it to the child we spawned (dario#872).
247
+ * The port is ephemeral, so a collision is improbable rather than
248
+ * impossible, and nothing downstream could tell a foreign request from the
249
+ * child's once it was captured.
250
+ *
251
+ * The nonce rides in the URL path because that is the one channel we fully
252
+ * control without touching what CC sends. Two measurements decided it over a
253
+ * key-borne nonce:
254
+ *
255
+ * 1. CC honours a path segment in ANTHROPIC_BASE_URL — given
256
+ * `http://127.0.0.1:PORT/<nonce>` it requests
257
+ * `/<nonce>/v1/messages?beta=true` (and probes `/<nonce>/api/hello`
258
+ * first, which still 404s here).
259
+ * 2. On a subscription install CC authenticates with
260
+ * `authorization: Bearer sk-ant-…`, so ANTHROPIC_API_KEY is not the auth
261
+ * header at all. A key-borne nonce would do nothing on OAuth installs
262
+ * while changing the auth path for API-key ones — and changing what the
263
+ * child authenticates with can change what it sends, which is the whole
264
+ * thing a fingerprint capture must not do.
265
+ *
266
+ * Fails closed: no nonce, no capture. A miss makes the capture return null
267
+ * and the bake exit non-zero, which is the correct outcome for a request we
268
+ * cannot attribute.
269
+ */
270
+ export declare function isOwnCaptureRequest(url: string | undefined, nonce: string): boolean;
242
271
  /**
243
272
  * Run a loopback MITM server on a random port, spawn CC with
244
273
  * ANTHROPIC_BASE_URL pointed at it, wait for one request, respond with a
@@ -85,6 +85,7 @@
85
85
  * the right piece without re-deriving the threat model.
86
86
  */
87
87
  import { spawn, execFileSync } from 'node:child_process';
88
+ import { randomBytes } from 'node:crypto';
88
89
  import { createServer } from 'node:http';
89
90
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'node:fs';
90
91
  import { homedir } from 'node:os';
@@ -363,6 +364,41 @@ export function _atomicWriteJsonForTest(targetPath, data) {
363
364
  function writeLiveCache(data) {
364
365
  atomicWriteJson(liveCachePath(), data);
365
366
  }
367
+ /**
368
+ * Does this request belong to the capture we started?
369
+ *
370
+ * The MITM used to accept the FIRST request whose URL contained
371
+ * `/v1/messages` with nothing tying it to the child we spawned (dario#872).
372
+ * The port is ephemeral, so a collision is improbable rather than
373
+ * impossible, and nothing downstream could tell a foreign request from the
374
+ * child's once it was captured.
375
+ *
376
+ * The nonce rides in the URL path because that is the one channel we fully
377
+ * control without touching what CC sends. Two measurements decided it over a
378
+ * key-borne nonce:
379
+ *
380
+ * 1. CC honours a path segment in ANTHROPIC_BASE_URL — given
381
+ * `http://127.0.0.1:PORT/<nonce>` it requests
382
+ * `/<nonce>/v1/messages?beta=true` (and probes `/<nonce>/api/hello`
383
+ * first, which still 404s here).
384
+ * 2. On a subscription install CC authenticates with
385
+ * `authorization: Bearer sk-ant-…`, so ANTHROPIC_API_KEY is not the auth
386
+ * header at all. A key-borne nonce would do nothing on OAuth installs
387
+ * while changing the auth path for API-key ones — and changing what the
388
+ * child authenticates with can change what it sends, which is the whole
389
+ * thing a fingerprint capture must not do.
390
+ *
391
+ * Fails closed: no nonce, no capture. A miss makes the capture return null
392
+ * and the bake exit non-zero, which is the correct outcome for a request we
393
+ * cannot attribute.
394
+ */
395
+ export function isOwnCaptureRequest(url, nonce) {
396
+ if (!url || nonce.length === 0)
397
+ return false;
398
+ if (!url.startsWith(`/${nonce}/`))
399
+ return false;
400
+ return url.includes('/v1/messages');
401
+ }
366
402
  /**
367
403
  * Run a loopback MITM server on a random port, spawn CC with
368
404
  * ANTHROPIC_BASE_URL pointed at it, wait for one request, respond with a
@@ -377,9 +413,11 @@ export async function captureLiveTemplateAsync(timeoutMs = 10_000) {
377
413
  return extractTemplate(captured);
378
414
  }
379
415
  async function runCapture(timeoutMs) {
416
+ const nonce = `dario-capture-${randomBytes(12).toString('hex')}`;
380
417
  return new Promise((resolve) => {
381
418
  let captured = null;
382
419
  let settled = false;
420
+ let foreign = 0;
383
421
  const settle = (result) => {
384
422
  if (settled)
385
423
  return;
@@ -395,9 +433,17 @@ async function runCapture(timeoutMs) {
395
433
  resolve(result);
396
434
  };
397
435
  const server = createServer((req, res) => {
398
- // Only handle /v1/messages — everything else gets a 404 so CC doesn't
399
- // accidentally think /v1/models is live.
400
- if (!req.url?.includes('/v1/messages')) {
436
+ // Only handle OUR /v1/messages — everything else gets a 404 so CC
437
+ // doesn't accidentally think /v1/models is live, and so a request we
438
+ // cannot attribute to the spawned child is never captured (dario#872).
439
+ if (!isOwnCaptureRequest(req.url, nonce)) {
440
+ // A /v1/messages without our nonce is exactly the case #872 is about:
441
+ // something else reached this port. Say so — a silent 404 here is how
442
+ // a foreign capture would have gone unnoticed.
443
+ if (req.url?.includes('/v1/messages')) {
444
+ foreign++;
445
+ console.error(`[dario] capture: rejected a /v1/messages request that did not carry this capture's nonce (${foreign} so far) — see dario#872`);
446
+ }
401
447
  res.writeHead(404, { 'content-type': 'application/json' });
402
448
  res.end('{"type":"error","error":{"type":"not_found_error","message":"not found"}}');
403
449
  return;
@@ -417,7 +463,7 @@ async function runCapture(timeoutMs) {
417
463
  }
418
464
  captured = {
419
465
  method: req.method ?? 'POST',
420
- path: req.url ?? '/v1/messages',
466
+ path: (req.url ?? '/v1/messages').replace(`/${nonce}`, ''),
421
467
  headers,
422
468
  rawHeaders: Array.isArray(req.rawHeaders) ? [...req.rawHeaders] : [],
423
469
  body,
@@ -481,7 +527,7 @@ async function runCapture(timeoutMs) {
481
527
  settle(null);
482
528
  return;
483
529
  }
484
- const url = `http://127.0.0.1:${address.port}`;
530
+ const url = `http://127.0.0.1:${address.port}/${nonce}`;
485
531
  // Spawn CC with ANTHROPIC_BASE_URL pointed at our MITM.
486
532
  const claudeBin = findClaudeBinary();
487
533
  if (!claudeBin) {
@@ -42,8 +42,14 @@ export declare function scrubTemplate(data: TemplateData): TemplateData;
42
42
  */
43
43
  export declare function scrubText(text: string): string;
44
44
  /**
45
- * Run over a string and report any user-identifying patterns that remain.
46
- * Used by `scripts/check-cc-drift.mjs` to verify the baked template
47
- * passes scrubbing before a release goes out.
45
+ * Run over a string and report anything that must not survive into a bake:
46
+ * user-identifying paths, host-context sections the strip missed, and
47
+ * instruction-file prose (dario#872). Despite the name the contract has never
48
+ * been paths-only. Used by `scripts/check-cc-drift.mjs` to gate a release and
49
+ * by `scripts/capture-and-bake.mjs` to fail a bake.
50
+ *
51
+ * Deliberately NOT called from `extractTemplate`: that serves the live-capture
52
+ * path too, and the live fingerprint keeps host context on purpose — it exists
53
+ * to replay the operator's own CC install faithfully. Only the bake publishes.
48
54
  */
49
55
  export declare function findUserPathHits(text: string): string[];
@@ -222,9 +222,44 @@ function scrubObjectStrings(value) {
222
222
  return value;
223
223
  }
224
224
  /**
225
- * Run over a string and report any user-identifying patterns that remain.
226
- * Used by `scripts/check-cc-drift.mjs` to verify the baked template
227
- * passes scrubbing before a release goes out.
225
+ * Prose CC emits when it injects user- or project-level instruction files
226
+ * (CLAUDE.md, memory, environment) into a prompt. The section strip above is
227
+ * keyed on the `# claudeMd` heading shape; these are keyed on the wrapper text
228
+ * itself, so a reshaped or renamed heading still gets caught.
229
+ *
230
+ * Both halves are load-bearing. `removeSection` and the heading detector in
231
+ * findUserPathHits read the SAME heading list, so a heading CC renames strips
232
+ * nothing and flags nothing — two silent failures — and once paths are scrubbed
233
+ * the leftover prose carries no user path for the other detectors to catch.
234
+ * dario#872 saw one capture in six come back carrying another session's
235
+ * instruction text; this is the half that does not depend on the heading.
236
+ *
237
+ * Every marker is verified absent from the real baked template — base, fable,
238
+ * opus-5 and sonnet-5 prompts plus every tool description. Do NOT add bare
239
+ * `CLAUDE.md` or `system-reminder`: CC's own system prompt and tool
240
+ * descriptions both mention them, so either would fail every bake. Verified,
241
+ * not assumed, and test/context-bleed.mjs pins it.
242
+ */
243
+ const INSTRUCTION_INJECTION_MARKERS = [
244
+ { label: 'instruction-file wrapper', re: /Codebase and user instructions are shown below/ },
245
+ { label: 'instruction-file wrapper', re: /IMPORTANT: These instructions OVERRIDE any default behavior/ },
246
+ // Straight and typographic apostrophe both — CC emits the straight one today,
247
+ // and a marker that a quote-style change silently disarms is not a guard.
248
+ { label: 'instruction-file annotation', re: /user['’]s private global instructions/ },
249
+ { label: 'memory-file annotation', re: /auto-memory, persists across conversations/ },
250
+ { label: 'instruction-file heading', re: /\bContents of [^\n]{1,300}\.md\b/ },
251
+ { label: 'project-instruction wrapper', re: /Here are useful instructions from/ },
252
+ ];
253
+ /**
254
+ * Run over a string and report anything that must not survive into a bake:
255
+ * user-identifying paths, host-context sections the strip missed, and
256
+ * instruction-file prose (dario#872). Despite the name the contract has never
257
+ * been paths-only. Used by `scripts/check-cc-drift.mjs` to gate a release and
258
+ * by `scripts/capture-and-bake.mjs` to fail a bake.
259
+ *
260
+ * Deliberately NOT called from `extractTemplate`: that serves the live-capture
261
+ * path too, and the live fingerprint keeps host context on purpose — it exists
262
+ * to replay the operator's own CC install faithfully. Only the bake publishes.
228
263
  */
229
264
  export function findUserPathHits(text) {
230
265
  const hits = [];
@@ -249,5 +284,13 @@ export function findUserPathHits(text) {
249
284
  hits.push(`# ${name} (host-context section not stripped)`);
250
285
  }
251
286
  }
287
+ // Instruction-file prose, matched on the wrapper rather than the heading.
288
+ // Excerpt is bounded: a hit is printed by the bake, and the whole point is
289
+ // that the matched text may be somebody's private instructions.
290
+ for (const { label, re } of INSTRUCTION_INJECTION_MARKERS) {
291
+ const m = text.match(re);
292
+ if (m)
293
+ hits.push(`${m[0].slice(0, 60)} (${label} not stripped)`);
294
+ }
252
295
  return hits;
253
296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.10",
3
+ "version": "5.4.12",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/",
24
24
  "test": "node --test --test-concurrency=8 test/all.test.mjs",
25
- "test:serial": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/live-fingerprint.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/cc-authorize-probe-classifier.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/doctor-identity-drift.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/durable-token-persist.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs && node test/sanitize-messages.mjs && node test/platform-tools.mjs && node test/strict-template-flags.mjs && node test/request-queue.mjs && node test/effort-flag.mjs && node test/template-invariants.mjs",
25
+ "test:serial": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/live-fingerprint.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/cc-authorize-probe-classifier.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/doctor-identity-drift.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/durable-token-persist.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs && node test/context-bleed.mjs && node test/capture-provenance.mjs && node test/sanitize-messages.mjs && node test/platform-tools.mjs && node test/strict-template-flags.mjs && node test/request-queue.mjs && node test/effort-flag.mjs && node test/template-invariants.mjs",
26
26
  "audit": "npm audit --production --audit-level=high",
27
27
  "prepublishOnly": "npm run build",
28
28
  "start": "node dist/cli.js",