feat: claude-code tokenization proxy with ggshield DLP

Local DLP proxy between claude-code and the Anthropic API. Scans outgoing
/v1/messages bodies with ggshield, swaps detected secrets for opaque
placeholder tokens, and restores them in the streamed SSE response on the fly.

Licensed under AGPL-3.0-or-later.
This commit is contained in:
2026-06-19 22:48:45 -04:00
commit bd138a8d12
11 changed files with 1193 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
// Copyright (C) 2026 Rootiest
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Transform, type TransformCallback } from "node:stream";
import { StringDecoder } from "node:string_decoder";
import { pathToFileURL } from "node:url";
import { resolveToken as storeResolve } from "./store.ts";
// Token shape: <<<gg_token_[0-9a-f]+>>> (hex, because that's what we generate).
const PREFIX = "<<<gg_token_";
const SUFFIX = ">>>";
const COMPLETE_RE = /^<<<gg_token_[0-9a-f]+>>>/;
// Could `s` (which starts with '<') still grow into a token with more input?
// True for any strict prefix of a well-formed token.
function isPartialPrefix(s: string): boolean {
if (PREFIX.startsWith(s)) return true; // still building the literal "<<<gg_token_"
if (!s.startsWith(PREFIX)) return false;
const rest = s.slice(PREFIX.length);
// hex chars, then an optional partial of the ">>>" closer
return /^[0-9a-f]*>{0,2}$/.test(rest);
}
/**
* Stateful SSE detokenizer. Scans the raw upstream byte stream for our
* placeholder tokens and swaps them back to the original secrets on the fly,
* holding back only the few characters that could still become a token so
* tokens split across chunk boundaries are reassembled correctly.
*/
export class DetokenizeStream extends Transform {
private buf = "";
private decoder = new StringDecoder("utf8");
private resolve: (token: string) => string | undefined;
constructor(resolve: (token: string) => string | undefined = storeResolve) {
super();
this.resolve = resolve;
}
private emitFor(token: string): string {
const secret = this.resolve(token);
if (secret === undefined) return token; // unknown token: leave it untouched
// ponytail: token appears inside a JSON string in the SSE `data:` line, so
// re-escape. Assumes secrets are escape-neutral (typical API keys/tokens);
// a secret literally containing a backslash or quote would double-escape —
// upgrade to context-aware splicing if that ever bites.
return JSON.stringify(secret).slice(1, -1);
}
override _transform(chunk: Buffer, _enc: BufferEncoding, cb: TransformCallback): void {
this.buf += this.decoder.write(chunk);
let out = "";
while (this.buf.length > 0) {
const lt = this.buf.indexOf("<");
if (lt === -1) {
out += this.buf;
this.buf = "";
break;
}
out += this.buf.slice(0, lt); // everything before '<' is safe to emit
this.buf = this.buf.slice(lt); // buf now starts at the '<'
const complete = COMPLETE_RE.exec(this.buf);
if (complete) {
out += this.emitFor(complete[0]);
this.buf = this.buf.slice(complete[0].length);
continue;
}
if (isPartialPrefix(this.buf)) break; // might finish next chunk; hold it
// This '<' can't begin a token. Emit it and keep scanning past it.
out += "<";
this.buf = this.buf.slice(1);
}
cb(null, out);
}
override _flush(cb: TransformCallback): void {
const out = this.buf + this.decoder.end();
this.buf = "";
cb(null, out);
}
}
// --- self-check: `node src/detokenize.ts --test` -------------------------
function runSelfTest(): void {
const assert = (cond: boolean, msg: string) => {
if (!cond) throw new Error("FAIL: " + msg);
};
const map = new Map([
["<<<gg_token_abc123>>>", "SUPER-SECRET-KEY"],
["<<<gg_token_dead>>>", "hunter2"],
]);
const resolve = (t: string) => map.get(t);
// Feed input split into arbitrary chunks, collect output.
const run = (chunks: string[]): string => {
const s = new DetokenizeStream(resolve);
let out = "";
s.on("data", (d) => (out += d.toString()));
for (const c of chunks) s.write(Buffer.from(c, "utf8"));
s.end();
return out;
};
// 1. Token split across a chunk boundary (the critical case).
assert(
run(["password is <<<gg_to", "ken_abc123>>> done"]) ===
"password is SUPER-SECRET-KEY done",
"split token reassembled",
);
// 2. Token split one char at a time.
assert(
run("x <<<gg_token_dead>>> y".split("")) === "x hunter2 y",
"char-by-char token",
);
// 3. Plain text with stray '<' and '<<<' that is NOT a token flushes promptly.
assert(run(["a < b <<< c <<<not"]) === "a < b <<< c <<<not", "non-token < flushed");
// 4. Unknown token passes through unchanged.
assert(run(["see <<<gg_token_ffff>>>!"]) === "see <<<gg_token_ffff>>>!", "unknown token kept");
// 5. Two tokens back to back.
assert(
run(["<<<gg_token_abc123>>><<<gg_token_dead>>>"]) === "SUPER-SECRET-KEYhunter2",
"adjacent tokens",
);
// 6. Incomplete token at end of stream flushed verbatim.
assert(run(["tail <<<gg_token_ab"]) === "tail <<<gg_token_ab", "incomplete token flushed on end");
// 7. Overlapping '<' runs.
assert(run(["<<<<gg_token_dead>>>"]) === "<hunter2", "leading extra angle bracket");
console.log("detokenize self-test: all assertions passed");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runSelfTest();
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (C) 2026 Rootiest
// SPDX-License-Identifier: AGPL-3.0-or-later
import http from "node:http";
import https from "node:https";
import { tokenizeBody } from "./tokenize.ts";
import { DetokenizeStream } from "./detokenize.ts";
const PORT = Number(process.env.PORT) || 8080;
const UPSTREAM_HOST = process.env.ANTHROPIC_UPSTREAM_HOST || "api.anthropic.com";
// Headers we must not forward verbatim (they describe the OLD body/transport).
const STRIP_REQ = new Set(["host", "content-length", "accept-encoding"]);
const STRIP_RES = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
function readBody(req: http.IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function pick(headers: http.IncomingHttpHeaders, strip: Set<string>): http.OutgoingHttpHeaders {
const out: http.OutgoingHttpHeaders = {};
for (const [k, v] of Object.entries(headers)) {
if (v !== undefined && !strip.has(k.toLowerCase())) out[k] = v;
}
return out;
}
const server = http.createServer(async (req, res) => {
try {
const isTarget = req.method === "POST" && (req.url ?? "").startsWith("/v1/messages");
const rawBody = await readBody(req);
// Only the messages endpoint carries user prompts worth scanning.
const outBody = isTarget ? Buffer.from(await tokenizeBody(rawBody.toString("utf8")), "utf8") : rawBody;
const headers = pick(req.headers, STRIP_REQ);
headers["content-length"] = Buffer.byteLength(outBody);
// Force identity so the SSE stream reaches the detokenizer uncompressed.
headers["accept-encoding"] = "identity";
const upstream = https.request(
{ host: UPSTREAM_HOST, port: 443, method: req.method, path: req.url, headers },
(upRes) => {
res.writeHead(upRes.statusCode ?? 502, pick(upRes.headers, STRIP_RES));
if (isTarget) {
// Detokenize the response body (SSE or JSON) on the way back.
upRes.pipe(new DetokenizeStream()).pipe(res);
} else {
upRes.pipe(res);
}
},
);
upstream.on("error", (err) => {
console.error("[proxy] upstream error:", err.message);
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain" });
res.end("Upstream request failed");
});
upstream.end(outBody);
} catch (err) {
console.error("[proxy] request error:", (err as Error).message);
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
res.end("Proxy error");
}
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[proxy] DLP tokenization proxy listening on http://127.0.0.1:${PORT}`);
console.log(`[proxy] forwarding to https://${UPSTREAM_HOST}`);
console.log(`[proxy] point claude-code at it: export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}`);
});
+12
View File
@@ -0,0 +1,12 @@
// Copyright (C) 2026 Rootiest
// SPDX-License-Identifier: AGPL-3.0-or-later
// In-memory token <-> secret map. ponytail: a Map is enough; secrets live only
// for the lifetime of the proxy process. Swap for SQLite only if you need
// persistence across restarts (you almost certainly don't — secrets shouldn't
// outlive the session).
export const secrets = new Map<string, string>();
export function resolveToken(token: string): string | undefined {
return secrets.get(token);
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (C) 2026 Rootiest
// SPDX-License-Identifier: AGPL-3.0-or-later
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { secrets } from "./store.ts";
let ggshieldWarned = false;
function newToken(): string {
return `<<<gg_token_${randomBytes(8).toString("hex")}>>>`;
}
// Run ggshield over `text` and return the list of plaintext secret strings it
// found. Returns [] (and warns once) if ggshield is missing or errors — fail
// open so the proxy never blocks traffic, per the brief.
async function scanSecrets(text: string): Promise<string[]> {
let dir: string | undefined;
try {
dir = await mkdtemp(join(tmpdir(), "ggscan-"));
const file = join(dir, "payload.txt");
await writeFile(file, text, "utf8");
const stdout = await new Promise<string>((resolve, reject) => {
// ggshield 1.45 has no stdin scan mode, so we scan a temp file.
// --show-secrets puts the plaintext in `match`; --all-secrets stops it
// filtering example/known keys; exit code 1 just means "found secrets".
const proc = spawn(
"ggshield",
["secret", "scan", "--json", "--show-secrets", "--all-secrets", "path", file],
{ stdio: ["ignore", "pipe", "ignore"] },
);
let out = "";
proc.stdout.on("data", (d) => (out += d));
proc.on("error", reject); // ENOENT etc.
proc.on("close", () => resolve(out));
});
const report = JSON.parse(stdout);
const found = new Set<string>();
for (const entity of report.entities_with_incidents ?? []) {
for (const incident of entity.incidents ?? []) {
for (const occ of incident.occurrences ?? []) {
if (typeof occ.match === "string" && occ.match.length > 0) found.add(occ.match);
}
}
}
return [...found];
} catch (err) {
if (!ggshieldWarned) {
ggshieldWarned = true;
const reason = (err as NodeJS.ErrnoException).code === "ENOENT"
? "ggshield not found on PATH"
: `ggshield scan failed: ${(err as Error).message}`;
console.warn(`[proxy] WARNING: ${reason} — forwarding traffic WITHOUT secret redaction`);
}
return [];
} finally {
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Scan a raw request body for secrets, replace each with a placeholder token,
* record the token->secret mapping, and return the redacted body. Longest
* secrets first so a secret that contains another is replaced whole.
*/
export async function tokenizeBody(body: string): Promise<string> {
const found = await scanSecrets(body);
if (found.length === 0) return body;
let redacted = body;
for (const secret of found.sort((a, b) => b.length - a.length)) {
if (!redacted.includes(secret)) continue;
const token = newToken();
secrets.set(token, secret);
redacted = redacted.split(secret).join(token);
}
return redacted;
}