Skip to content
Merged
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
2 changes: 2 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 },
"request-capture": { "enabled": true, "order": 60 },
"ttl-tier-detect": { "enabled": true, "order": 75 },
"cc-version-normalize": { "enabled": true, "order": 90 },
Expand All @@ -25,6 +26,7 @@
"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 }
}
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`);
}
},
};
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"}]}]}}
2 changes: 2 additions & 0 deletions test/fixtures/replay-classes/corpus-edit.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": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "original text"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}]}}
{"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": "assistant", "content": [{"type": "text", "text": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "EDITED text"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}]}}
3 changes: 3 additions & 0 deletions test/fixtures/replay-classes/corpus-flip.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"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": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "deep"}, {"type": "text", "text": "<system-reminder>\nPreToolUse:Edit hook additional context: Spec-origin trace required\n</system-reminder>"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}]}}
{"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": "assistant", "content": [{"type": "text", "text": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "deep"}, {"type": "text", "text": "<system-reminder>\nPreToolUse:Edit hook additional context: Spec-origin trace required\n</system-reminder>"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}, {"role": "user", "content": [{"type": "text", "text": "q"}]}]}}
{"ts": "2026-07-28T02:02: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": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "deep"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}, {"role": "user", "content": [{"type": "text", "text": "next"}]}]}}
2 changes: 2 additions & 0 deletions test/fixtures/replay-classes/corpus-flipback.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": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "target"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}]}}
{"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": "assistant", "content": [{"type": "text", "text": "a1"}]}, {"role": "user", "content": [{"type": "text", "text": "target"}, {"type": "text", "text": "<system-reminder>\nPreToolUse:Edit hook additional context: Spec-origin trace required\n</system-reminder>"}]}, {"role": "assistant", "content": [{"type": "text", "text": "a3"}]}, {"role": "user", "content": [{"type": "text", "text": "go"}]}]}}
2 changes: 2 additions & 0 deletions test/fixtures/replay-classes/corpus-prune.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": "m1"}]}, {"role": "user", "content": [{"type": "text", "text": "m2"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m3"}]}, {"role": "user", "content": [{"type": "text", "text": "m4"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m5"}]}, {"role": "user", "content": [{"type": "text", "text": "m6"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m7"}]}, {"role": "user", "content": [{"type": "text", "text": "m8"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m9"}]}]}}
{"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": "assistant", "content": [{"type": "text", "text": "m3"}]}, {"role": "user", "content": [{"type": "text", "text": "m4"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m5"}]}, {"role": "user", "content": [{"type": "text", "text": "m6"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m7"}]}, {"role": "user", "content": [{"type": "text", "text": "m8"}]}, {"role": "assistant", "content": [{"type": "text", "text": "m9"}]}, {"role": "user", "content": [{"type": "text", "text": "tail"}]}]}}
3 changes: 3 additions & 0 deletions test/fixtures/replay-classes/corpus-sidecar.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"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": "main-0", "cache_control": {"type": "ephemeral"}}]}, {"role": "assistant", "content": [{"type": "text", "text": "main-1"}]}]}}
{"ts": "2026-07-28T02:00:05Z", "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": "Generate a concise title", "cache_control": {"type": "ephemeral"}}], "messages": [{"role": "user", "content": [{"type": "text", "text": "give this a title"}]}]}}
{"ts": "2026-07-28T02:00:10Z", "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": "main-0", "cache_control": {"type": "ephemeral"}}]}, {"role": "assistant", "content": [{"type": "text", "text": "main-1"}]}, {"role": "user", "content": [{"type": "text", "text": "main-2"}]}]}}
Loading
Loading