Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions proxy/extensions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"bootstrap-defense": { "enabled": true, "order": 45 },
"output-guard-stash": { "enabled": true, "order": 55 },
"ttl-tier-detect": { "enabled": true, "order": 75 },
"cc-version-normalize": { "enabled": true, "order": 90 },
"fingerprint-strip": { "enabled": true, "order": 100 },
Expand All @@ -15,13 +16,15 @@
"workflow-agent-id-synthesis": { "enabled": true, "order": 365 },
"image-retry-circuit-breaker": { "enabled": true, "order": 370 },
"read-dedupe": { "enabled": true, "order": 380 },
"insertion-normalization": { "enabled": true, "order": 395 },
"cache-control-normalize": { "enabled": true, "order": 400 },
"messages-cache-breakpoint": { "enabled": true, "order": 410 },
"ttl-management": { "enabled": true, "order": 500 },
"cache-telemetry": { "enabled": true, "order": 600 },
"overage-warning": { "enabled": true, "order": 610 },
"upstream-error-log": { "enabled": true, "order": 670 },
"session-budget-breaker": { "enabled": true, "order": 690 },
"output-guard": { "enabled": true, "order": 690 },
"request-log": { "enabled": false, "order": 700 },
"jsonl-session-mirror": { "enabled": true, "order": 720 }
}
1,125 changes: 1,125 additions & 0 deletions proxy/extensions/insertion-normalization.mjs

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions proxy/extensions/message-hash.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// message-hash — content identity for a message, shared by every extension
// that needs to recognise "the same message" across requests.
//
// Hash everything EXCEPT cache_control, because cache_control is what the
// proxy itself mutates: including it would make a message look changed the
// instant we mark it, which is the opposite of an identity.
//
// Not an extension — a primitive. It lived in mid-history-breakpoint-ladder
// until that extension was removed (it manufactured the mid-history
// divergences it was meant to bound); insertion-normalization and
// deferred-tool-rewrite both depend on this function and never depended on
// rung placement, so it moved here rather than dying with its old host.

import { createHash } from "node:crypto";

export function hashMessageContent(msg) {
if (!msg || !Array.isArray(msg.content)) return null;
const stripped = msg.content.map((block) => {
if (!block || typeof block !== "object") return block;
const { cache_control, ...rest } = block;
return rest;
});
return createHash("sha256").update(JSON.stringify(stripped)).digest("hex").slice(0, 16);
}

// Conversation identity: the hash of msgs[0], the one entry nothing appends
// past. Lives here, not in one extension, because BOTH stateful extensions
// need it and the second one learning it late is what this file exists to
// prevent.
//
// History (2026-07-28, one day, twice). insertion-normalization keyed its
// canonical on (session-id, system-prompt) and thrashed: every subagent of a
// session runs the same agent prompt, so one bucket held 39 distinct
// conversations and 100% of conversation switches within a bucket reset
// (60/60) against 1% of same-conversation continuations. Adding this sub-key
// took it to 0 resets across 940 requests.
//
// deferred-tool-rewrite had the IDENTICAL key and did not get the fix, and it
// cost real cache: its tool_addition announcement is anchored to a message
// identity, so under a shared key the anchor belongs to somebody else's
// history, fails to match, and re-anchors to "after the last user message" —
// a different index every request. Measured: output diverging at index 4
// while CC's own history was identical through index 23, twice in one corpus.
//
// The general rule this keeps re-teaching: an identity computed more cheaply
// than the thing it identifies will collide, and the collision presents as
// churn rather than as a bug.
export function conversationSubKey(messages) {
const first = Array.isArray(messages) ? messages[0] : null;
if (!first) return "empty";
const h = hashMessageContent(first);
if (h) return h;
// hashMessageContent covers block-array content only and returns null for
// STRING content — correct for its own callers, but as a bucket key that
// null collapsed every string-content conversation into one shared "empty"
// bucket (56 of 602 requests in the measured capture). A message carrying
// no content at all is the only remaining "empty".
if (first.content === undefined || first.content === null) return "empty";
return createHash("sha256")
.update(JSON.stringify({ role: first.role ?? null, content: first.content }))
.digest("hex")
.slice(0, 16);
}
32 changes: 32 additions & 0 deletions proxy/extensions/output-guard-stash.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// output-guard-stash — first half of the output guard (directive:
// docs/directives/proxy-output-guard.md). Order 55: before the first
// body-mutating extension (cc-version-normalize, 90), so the stash is
// what CC actually sent. The validating half is output-guard.mjs
// (order 690). Two files because the pipeline loads one default export
// per file and the two halves must run at opposite ends of the chain.

export function isGuardEnabled(env = process.env) {
return env.CACHE_FIX_OUTPUT_GUARD === "1";
}

export default {
name: "output-guard-stash",
description:
"Stash a pre-mutation clone of the request body for output-guard's " +
"restore path",
enabled: false, // overridden by extensions.json
order: 55,

async onRequest(ctx) {
if (!isGuardEnabled()) return;
if (!ctx || !ctx.body || !Array.isArray(ctx.body.messages)) return;
try {
ctx.meta = ctx.meta || {};
ctx.meta._preMutationBody = structuredClone(ctx.body);
} catch {
// A failed stash disables the restore path for this request only;
// output-guard treats a missing stash as "cannot restore" and
// passes through with a WARN.
}
},
};
201 changes: 201 additions & 0 deletions proxy/extensions/output-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// output-guard — last-line invariant check on the outgoing body.
// Directive: docs/directives/proxy-output-guard.md.
//
// Protects against US, not CC: the pipeline mutates bodies (reorder,
// rewrite, inject, and since phase 3 substitute first-seen bytes), and
// the composition of individually-correct extensions is where the one
// real shipped defect lived. On any hard-invariant violation the guard
// forwards CC's ORIGINAL bytes (stashed by output-guard-stash at order
// 55) — valid by definition, they are what the API would have received
// with the proxy absent.
//
// Order 690: after the last body mutator (ttl-management, 500), before
// the pure observers (request-log 700, jsonl-session-mirror 720) so
// what they record is what actually goes out.
//
// Fail-open at the mutation level, fail-safe at the request level: a
// validator crash counts as "cannot verify" and passes the mutated body
// through with a WARN — the guard must never be able to break a request
// or silently disable the pipeline's value.

import { appendFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { claudeHome } from "../claude-home.mjs";
import { resolveSessionId } from "./cache-telemetry.mjs";
import { validateToolAdjacency } from "./insertion-normalization.mjs";
import { isGuardEnabled } from "./output-guard-stash.mjs";

const MAX_MARKERS = 4;

function isDebug(env = process.env) {
return env.CACHE_FIX_DEBUG === "1";
}

function getSnapshotDir() {
return join(claudeHome(), "cache-fix-snapshots");
}

// --- Invariant validators (pure; each returns null or a violation string) ---

function checkToolAdjacency(body) {
return validateToolAdjacency(body.messages)
? null
: "tool-adjacency: a tool_result user message is not preceded by its matching tool_use assistant message";
}

function countMarkers(body) {
let n = 0;
const scan = (blocks) => {
if (!Array.isArray(blocks)) return;
for (const b of blocks) {
if (b && typeof b === "object" && b.cache_control) n++;
}
};
scan(body.system);
for (const m of body.messages) scan(m?.content);
return n;
}

function checkMarkerBudget(body) {
const n = countMarkers(body);
return n <= MAX_MARKERS ? null : `marker-budget: ${n} cache_control markers exceed the API cap of ${MAX_MARKERS}`;
}

// "system" is legal mid-conversation (mid-conversation system messages,
// and deferred-tool-rewrite's injected tool_addition messages) — but never
// as messages[0], per the documented placement constraint.
function checkRoles(body) {
for (let i = 0; i < body.messages.length; i++) {
const r = body.messages[i]?.role;
if (r !== "user" && r !== "assistant" && r !== "system") {
return `roles: messages[${i}] has invalid role ${JSON.stringify(r)}`;
}
if (r === "system" && i === 0) {
return `roles: messages[0] must not be system (placement constraint)`;
}
}
return null;
}

function checkContentPresent(body) {
for (let i = 0; i < body.messages.length; i++) {
const c = body.messages[i]?.content;
const ok = typeof c === "string" || (Array.isArray(c) && c.length > 0);
if (!ok) return `content: messages[${i}] has missing or empty content`;
}
return null;
}

// Invariant 5 (BACKLOG.md, "suppression can strip a request's FINAL
// message", 2026-07-30): a message-REMOVING mutation shipped (duplicate
// suppression) without a tail-validity check, and three live requests
// ended assistant-role -> upstream "400 must end with a user message".
// The other four invariants are shape-level facts about ONE body; this
// one needs the body CC actually sent, so it takes it as a second
// argument rather than deriving anything from `body` alone.
//
// Conditioned on the INCOMING shape rather than an unconditional "never
// end assistant": if CC itself sent a request already ending in
// assistant role (a prefill-style continuation, however rare in observed
// traffic), that is the client's own intent and not this guard's business
// to overturn — the guard protects against OUR mutations, not against CC.
// `incomingBody` absent (e.g. the pre-mutation stash unavailable, or a
// direct unit-test call) means "cannot verify" for this one check, so it
// yields no violation rather than guessing.
function checkAssistantTerminal(body, incomingBody) {
if (!incomingBody || !Array.isArray(incomingBody.messages) || incomingBody.messages.length === 0) return null;
const incomingLast = incomingBody.messages[incomingBody.messages.length - 1];
if (incomingLast?.role === "assistant") return null;
const forwardedLast = body.messages[body.messages.length - 1];
if (forwardedLast?.role === "assistant") {
return "assistant-terminal: incoming request ended non-assistant but the forwarded body ends assistant — a mutation stripped the trailing message";
}
return null;
}

const VALIDATORS = [checkToolAdjacency, checkMarkerBudget, checkRoles, checkContentPresent, checkAssistantTerminal];

// Exported for tests: run all validators, return the first violation or
// null. `incomingBody` is optional — only checkAssistantTerminal reads it;
// every other validator is unaffected by its absence.
export function findViolation(body, incomingBody) {
for (const v of VALIDATORS) {
const violation = v(body, incomingBody);
if (violation) return violation;
}
return null;
}

async function appendGuardEvent(dir, key, record) {
try {
await mkdir(dir, { recursive: true });
await appendFile(join(dir, `${key}-guard-events.jsonl`), JSON.stringify(record) + "\n");
} catch {
// Telemetry loss must not affect the request.
}
}

export default {
name: "output-guard",
description:
"Validate hard invariants (tool adjacency, marker budget, roles, " +
"content presence) on the outgoing body; on violation forward the " +
"pre-mutation original instead",
enabled: false, // overridden by extensions.json
order: 690,

async onRequest(ctx) {
if (!isGuardEnabled()) return;
if (!ctx || !ctx.body || !Array.isArray(ctx.body.messages)) return;

ctx.meta = ctx.meta || {};
let violation;
try {
violation = findViolation(ctx.body, ctx.meta._preMutationBody);
} catch (err) {
// Cannot verify -> pass the mutated body through (fail-open); a
// guard crash must never break the request or the pipeline's value.
ctx.meta.outputGuardStats = { verified: false, error: String(err?.message ?? err) };
process.stderr.write(
`[output-guard] WARN: validator crashed (${err?.message ?? err}) — body passed through UNVERIFIED\n`,
);
return;
}

if (!violation) {
ctx.meta.outputGuardStats = { verified: true, fired: false };
return;
}

const stash = ctx.meta._preMutationBody;
const sid = ctx.headers ? resolveSessionId(ctx.headers) : null;
const key = sid ? `s-${sid.replace(/[^A-Za-z0-9_-]/g, "_")}` : "nokey";

if (stash) {
ctx.body = stash;
ctx.meta.outputGuardStats = { verified: true, fired: true, violation, restored: true };
process.stderr.write(
`[output-guard] CRITICAL: ${violation} — pipeline output DISCARDED, original client body forwarded. An extension (or interaction) produced an invalid body; see ${key}-guard-events.jsonl\n`,
);
} else {
// No stash (stash failed or guard enabled mid-request): nothing safe
// to restore to; forward the mutated body and say so loudly.
ctx.meta.outputGuardStats = { verified: true, fired: true, violation, restored: false };
process.stderr.write(
`[output-guard] CRITICAL: ${violation} — NO pre-mutation stash available, mutated body forwarded as-is\n`,
);
}

await appendGuardEvent(getSnapshotDir(), key, {
ts: new Date().toISOString(),
sid,
violation,
restored: Boolean(stash),
messageCount: Array.isArray(ctx.body?.messages) ? ctx.body.messages.length : 0,
});

if (isDebug()) {
process.stderr.write(`[output-guard] DEBUG: stats=${JSON.stringify(ctx.meta.outputGuardStats)}\n`);
}
},
};
33 changes: 33 additions & 0 deletions test/fixtures/insertion-1405.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"_comment": "Synthetic minimal repro of the 2026-07-27 14:05 splice shape (docs/directives/proxy-insertion-normalization.md). NOT real session content — built by hand for insertion-normalization.test.mjs. `priorMessages` is the 12-entry canonical history from the prior request (arrival order). `incomingMessages` is the NEXT request's messages[] as Claude Code actually sends it: two new user-role entries (a queued operator message and a task-reminder-shaped system-reminder wrapper) spliced in BETWEEN prior canonical index 9 and 10, instead of appended after index 11 where they causally arrived. Expected: classifyInsertion(incomingMessages, canonicalFromPrior) -> action 'normalized', re-serialized to priorMessages (0..11) followed by the two new entries in their incoming relative order (queued-message, then task-reminder).",
"priorMessages": [
{ "role": "user", "content": [{ "type": "text", "text": "turn0-user: investigate the widget cache" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn1-assistant: looking into it" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn2-user: check the config file" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn3-assistant: config looks fine" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn4-user: run the test suite" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn5-assistant: tests pass" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn6-user: add a regression guard" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn7-assistant: guard added" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn8-user: commit the change" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn9-assistant: committed" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn10-user: what's next on the roadmap" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn11-assistant: next is the deploy step" }] }
],
"incomingMessages": [
{ "role": "user", "content": [{ "type": "text", "text": "turn0-user: investigate the widget cache" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn1-assistant: looking into it" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn2-user: check the config file" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn3-assistant: config looks fine" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn4-user: run the test suite" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn5-assistant: tests pass" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn6-user: add a regression guard" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn7-assistant: guard added" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn8-user: commit the change" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn9-assistant: committed" }] },
{ "role": "user", "content": [{ "type": "text", "text": "queued-message: operator says pause before deploy" }] },
{ "role": "user", "content": [{ "type": "text", "text": "<system-reminder>\nThe task tools haven't been used recently.\n</system-reminder>" }] },
{ "role": "user", "content": [{ "type": "text", "text": "turn10-user: what's next on the roadmap" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "turn11-assistant: next is the deploy step" }] }
]
}
2 changes: 2 additions & 0 deletions test/fixtures/replay-classes/corpus-compaction.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"ts": "2026-07-28T02:00:00Z", "sid": "test-sid", "key": "s-test-sid", "headers": {"anthropic-beta": "context-management-2025-06-27", "session-id": "test-sid"}, "body": {"model": "claude-opus-5", "system": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}], "messages": [{"role": "user", "content": [{"type": "text", "text": "u0", "cache_control": {"type": "ephemeral"}}]}, {"role": "assistant", "content": [{"type": "text", "text": "x1"}]}, {"role": "user", "content": [{"type": "text", "text": "x2"}]}, {"role": "assistant", "content": [{"type": "text", "text": "x3"}]}, {"role": "user", "content": [{"type": "text", "text": "x4"}]}, {"role": "assistant", "content": [{"type": "text", "text": "x5"}]}, {"role": "user", "content": [{"type": "text", "text": "x6"}]}, {"role": "assistant", "content": [{"type": "text", "text": "x7"}]}, {"role": "user", "content": [{"type": "text", "text": "x8"}]}, {"role": "assistant", "content": [{"type": "text", "text": "x9"}]}, {"role": "user", "content": [{"type": "text", "text": "x10"}]}, {"role": "assistant", "content": [{"type": "text", "text": "x11"}]}]}}
{"ts": "2026-07-28T02:01:00Z", "sid": "test-sid", "key": "s-test-sid", "headers": {"anthropic-beta": "context-management-2025-06-27", "session-id": "test-sid"}, "body": {"model": "claude-opus-5", "system": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}], "messages": [{"role": "user", "content": [{"type": "text", "text": "u0", "cache_control": {"type": "ephemeral"}}]}, {"role": "user", "content": [{"type": "text", "text": "summary of everything"}]}]}}
Loading