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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ AUDIT_LOG=./audit.jsonl
ADMIN_TOKEN= # required header x-admin-token for /admin/*; empty = admin API disabled (403)
# ALLOW_OPEN_ADMIN=1 # dev only: with ADMIN_TOKEN empty, serve /admin/* with NO auth
TENANT_FROM_AUTH=true # when no X-Tenant, derive the tenant from the API key
# Honour the caller's X-Tenant header. false = ignore it; the tenant always comes from the
# API key, so a caller can't select another tenant's (possibly looser) policy.
TRUST_TENANT_HEADER=true
# Honour the caller's X-Tenant header. false (the default) = ignore it; the tenant always
# comes from the API key. true = honour it, but only for a tenant whose policy is at least as
# strict as the caller's own (403 otherwise); it still picks the audit/metrics tenant label.
TRUST_TENANT_HEADER=false
# Per-request X-Redact-Mode / X-Redact-Sets may only TIGHTEN policy by default (off, or a
# narrower set list, is refused with 403). true = callers may loosen it (per tenant:
# "allowHeaderOverride": true via /admin/tenant).
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ image and creates the GitHub release from this file.

### Security

- The Responses API request walk reads every input item. `function_call_output` output in its part-array form, `custom_tool_call` input, `custom_tool_call_output` output, `local_shell_call_output` output, `custom` tool descriptions and `prompt.variables` (string and `input_text` values) used to reach the provider unredacted. Input items are now walked fail-closed: every string leaf of every item is redacted, including item types cordon does not know, except structural fields the provider needs byte-exact (`type`, `role`, `status`, `id`, `call_id`, `approval_request_id`, `name`, `model`, `encrypted_content`) and media (`input_image` / `input_file` parts, `image_generation_call` items, `image_url` / `file_id` / `file_url` / `file_data`). An item nested more than 16 levels deep is refused (422) rather than forwarded partly unread.
- A near-miss spelling of a generation endpoint (`/v1/chat/completions/`, `/v1/x/../responses`, `/v1/./messages`, `/v1/Chat/Completions`, `/v1/chat/completion%73`) took the verbatim passthrough and forwarded the raw body; the upstream fetch resolves dot segments, so some of these reached the real endpoint with PII intact. The path is now classified the way the upstream can read it and such a request is redacted. It is still forwarded to the path the client sent.
- `X-Tenant` is ignored unless `TRUST_TENANT_HEADER=true`. Before, any caller could name any tenant and get its policy, including one with `mode: off` or `allowHeaderOverride`, which made "headers can only tighten" untrue by default. When the header is trusted, it may only select a tenant whose policy is at least as strict as the caller's own (mode, sets, fail mode, `redactSystem`, `consistentPseudonyms`, `allowHeaderOverride`, and identical upstream bases); anything looser is refused with 403 before the upstream is called. **Breaking** for deployments that route callers to tenants with `X-Tenant`: set `TRUST_TENANT_HEADER=true`, and move tenants with a looser or residency policy to key-derived tenants.
- Per-request headers can no longer weaken redaction. `X-Redact-Mode` may only be as strong as or stronger than the tenant/global mode (`off` < `reversible` < `strip`), and `X-Redact-Sets` must include every policy set; `X-Redact-Mode: off` or a narrower set list is refused with 403 and the upstream is never called. Until now any caller could send `X-Redact-Mode: off` and forward raw PII. Opt back in per tenant (`"allowHeaderOverride": true`) or globally (`ALLOW_HEADER_OVERRIDE=true`). **Breaking** for clients that relied on loosening headers.
- The admin API is disabled (403) when `ADMIN_TOKEN` is unset, instead of open to anyone who can reach the port. `ALLOW_OPEN_ADMIN=1` restores the open dev behaviour. The token is compared in constant time. **Breaking** for deployments that ran `/admin/*` without a token.
- Upstream failures return a fixed message and a `requestId` (also `X-Request-Id`) instead of the raw exception, which could name internal hosts such as a residency upstream; the detail is logged server-side.

### Added

- `UPSTREAM_TIMEOUT_MS` (default 600000): a provider that sends no response headers in time gets a 504 instead of holding the connection forever. Once headers arrive the body, including a long stream, is not timed.
- `TRUST_TENANT_HEADER` (default `true`): set `false` to ignore `X-Tenant` and always derive the tenant from the API key, so callers can't select another tenant's policy.
- `TRUST_TENANT_HEADER` (default `false`): set `true` to honour `X-Tenant`, limited to tenants at least as strict as the caller's own (see Security).

## [0.3.0] - 2026-09-22

Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ Append-only JSONL where `hash = sha256(prevHash + canonicalJSON(record))`. Appen
The vault is per-request, policy is in-memory (`policy.ts`), the audit log is a local file — so cordon runs as a **single service with no Redis/DB** (unlike deja). Don't add a cache/datastore dependency without a real reason. Policy has **optional** file-backed durability (`POLICY_STORE`): `policy.load()` runs before `app.listen` and `policy.save()` fires on every `setPolicy` (serialized through a tail promise, mirroring the audit append; fail-safe on absent/malformed files). Unset = pure in-memory, the default path untouched — still no datastore.

## Conventions & gotchas
- Only `/v1/chat/completions` and `/v1/messages` are redacted; every other `/v1/*` path (count_tokens, embeddings, models) forwards **verbatim** via `passthroughUnknown` and must never be normalized.
- Only `/v1/chat/completions`, `/v1/responses` and `/v1/messages` are redacted; every other `/v1/*` path (count_tokens, embeddings, models) forwards **verbatim** via `passthroughUnknown` and its body must never be normalized. Which paths count as a generation endpoint is decided by `providers.generationRoute`, which reads the path the way the upstream can (trailing slash, dot segments, case, percent-escapes), so a near-miss spelling is redacted rather than passed through.
- The Responses request walk (`pushResponsesLeaves` in `redact/apply.ts`) is fail-closed over item types: every string leaf of every input item is redacted except the structural keys in `RESPONSES_STRUCTURAL_KEYS` and media parts. Add a key there only if the upstream needs it byte-exact and the model does not read it as content.
- `X-Tenant` is ignored unless `TRUST_TENANT_HEADER=true`; when honoured, `providers.tenantSelectionViolation` refuses (403) a tenant whose policy is looser than the caller's key-derived one.
- The brand string lives in `config.brand` — don't hardcode "cordon" in user-facing strings.
- `CORDON_TEST_HOOKS=1` enables the `X-Cordon-Fail: 1` header that forces a detection failure (to exercise fail-closed). It is **off by default** — never rely on it in production paths.
- Provider auth headers (`authorization`/`x-api-key`/`anthropic-version`) are forwarded verbatim; cordon never terminates provider auth.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ All four sets are on by default. Narrow them per tenant; a request can add sets
- **`strip`**: irreversible placeholders (`[EMAIL]`); nothing is restored. For when the answer never needs the real value.
- **`off`**: passthrough, still audited as a bypass.
- **Policy is a floor.** A caller's `X-Redact-Mode` / `X-Redact-Sets` can only make redaction stricter than the tenant or global policy; `X-Redact-Mode: off` or a narrower set list is refused with 403 and never forwarded. Allow loosening per tenant (`allowHeaderOverride`) or globally (`ALLOW_HEADER_OVERRIDE=true`).
- **The tenant comes from the API key.** `X-Tenant` is ignored unless you set `TRUST_TENANT_HEADER=true`, and even then it can only select a tenant whose policy is at least as strict as the one the caller's key already gets (403 otherwise). It does choose the tenant name recorded in the audit log and metrics.
- **Admin API is off until you set `ADMIN_TOKEN`.** Without it `/admin/*` returns 403 rather than running open.
- **Tamper-evident audit.** Every request appends a hash-chained record of counts and types, never values; `npm run audit` verifies the chain.
- **Per-tenant policy**: consistent pseudonyms, data residency (regional upstreams), durable policy store.
Expand All @@ -61,7 +62,7 @@ X-Redact-Mode: strip → "text":"email [EMAIL] re card [CREDIT_CARD]"
## What it does not do

- **Names, free-text addresses, medical conditions.** There is no NER. A person's name in prose passes through. The detector is an interface (`src/detect`), so a Presidio-style sidecar can be added; it is not included.
- **Embeddings, `count_tokens`, images.** Only the three generation endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) are redacted; other `/v1/*` paths, including `/v1/responses/{id}`, pass through verbatim. Image and file parts are left untouched.
- **Embeddings, `count_tokens`, images.** Only the three generation endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) are redacted; other `/v1/*` paths, including `/v1/responses/{id}`, pass through verbatim. A spelling of a generation endpoint the provider can still resolve (a trailing slash, `.`/`..` segments, a different case, percent-escapes) is redacted like the endpoint itself. Image and file parts are left untouched.
- **Token counts.** Streaming usage figures are the provider's, computed on the de-identified text.

If you need one of those, say so in an issue. The scope above is deliberate, not accidental.
Expand Down
6 changes: 4 additions & 2 deletions _run_tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ const base = { OPENAI_BASE: STUB, ANTHROPIC_BASE: STUB };

// port → instance env
const instances = {
// Primary instance: has a strong TENANT_SECRET, so consistent-pseudonym requests work.
8810: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test.jsonl", CORDON_TEST_HOOKS: "1", TENANT_SECRET: "test-secret-0123456789abcdef" },
// Primary instance: has a strong TENANT_SECRET, so consistent-pseudonym requests work, and
// opts in to X-Tenant (TRUST_TENANT_HEADER) so the tenant-selection rule is exercised.
8810: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test.jsonl", CORDON_TEST_HOOKS: "1", TENANT_SECRET: "test-secret-0123456789abcdef", TRUST_TENANT_HEADER: "true" },
// Secret-less instance: consistent pseudonyms enabled per-tenant here must FAIL CLOSED
// (no ALLOW_WEAK_PSEUDONYM_SECRET) — exercises the per-request pseudonym-secret guard.
// TRUST_TENANT_HEADER is left at its default, so X-Tenant is ignored here.
8811: { ADMIN_TOKEN: "secret", AUDIT_LOG: "./_audit_test_nosecret.jsonl" },
// No ADMIN_TOKEN; an Anthropic upstream accepts connections but never answers.
// The proxy suite opens it on :8901 with a short timeout for the 504 path.
Expand Down
4 changes: 3 additions & 1 deletion _stub-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ function collectText(body, provider) {
if (typeof body.input === "string") parts.push(body.input);
else for (const item of body.input || []) {
if (item?.role === "user") pushContent(item.content);
if (item?.type === "function_call_output" && typeof item.output === "string") parts.push(item.output);
// Tool outputs of every kind (a string, or input_text parts) and a custom tool's input.
if (/_call_output$/.test(item?.type ?? "")) pushContent(item.output);
if (item?.type === "custom_tool_call" && typeof item.input === "string") parts.push(item.input);
}
return parts.join(" ");
}
Expand Down
60 changes: 60 additions & 0 deletions _test_fuzz.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,5 +341,65 @@ prop(
),
);

// ---------------- Responses API walk ----------------

// Every Responses input shape a client can send text in, including an item type the walk
// has no case for (a random name, so no allow-list can pass it by accident).
const itemTypeName = fc.stringMatching(/^[a-z]{3,12}_(item|call|call_output)$/);
const responsesBody = (t, unknownType) => ({
model: "m",
instructions: t,
input: [
{ role: "user", content: t },
{ role: "user", content: [{ type: "input_text", text: t }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: t, annotations: [] }] },
{ type: "function_call", call_id: "c1", name: "f", arguments: JSON.stringify({ q: t }) },
{ type: "function_call_output", call_id: "c1", output: t },
{ type: "function_call_output", call_id: "c1", output: [{ type: "input_text", text: t }] },
{ type: "custom_tool_call", call_id: "c2", name: "g", input: t },
{ type: "custom_tool_call_output", call_id: "c2", output: [{ type: "input_text", text: t }] },
{ type: "local_shell_call_output", call_id: "c3", output: t },
{ type: unknownType, id: "x1", body: { text: t, lines: [t] } },
],
tools: [{ type: "function", name: "f", description: t, parameters: { example: t } }, { type: "custom", name: "g", description: t }],
prompt: { id: "p1", variables: { name: t, v: { type: "input_text", text: t } } },
});

prop(
"Responses: seeded PII never survives in any input shape, known or unknown item type",
fc.property(seededText, itemTypeName, fc.constantFrom("reversible", "strip"), ({ text, seeds }, unknownType, mode) => {
const body = responsesBody(text, unknownType);
const before = JSON.stringify(body);
const { deidBody } = applyRedaction(body, "openai", new Vault(mode), ALL, detector, true, "responses");
const out = JSON.stringify(deidBody);
return !seeds.some((s) => out.includes(s.value)) && JSON.stringify(body) === before;
}),
);

prop(
"Responses: redact→re-identify round-trips to identity on arbitrary text",
fc.property(cleanText, itemTypeName, (text, unknownType) => {
const v = new Vault("reversible");
const { deidBody } = applyRedaction(responsesBody(text, unknownType), "openai", v, ALL, detector, true, "responses");
// Each field was de-identified on its own; every one must restore to the original.
const deids = [deidBody.input[0].content, deidBody.input[6].input, deidBody.input[9].body.text, deidBody.prompt.variables.name];
return deids.every((d) => {
const resp = { output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: d }] }] };
return reidentifyBody(resp, "openai", v, "responses").output[0].content[0].text === text;
});
}),
);

prop(
"Responses: applyRedaction is total on arbitrary input items and never mutates its input",
fc.property(fc.array(fc.jsonValue({ maxDepth: 8 }), { maxLength: 5 }), fc.jsonValue({ maxDepth: 4 }), (input, variables) => {
const body = { model: "m", input, prompt: { id: "p", variables } };
const before = JSON.stringify(body);
const { deidBody } = applyRedaction(body, "openai", new Vault("reversible"), ALL, detector, true, "responses");
JSON.stringify(deidBody);
return JSON.stringify(body) === before;
}),
);

console.log(`\nfuzz: ${pass} passed, ${fail} failed (${RUNS} runs/property)`);
process.exit(fail ? 1 : 0);
Loading
Loading