Skip to content

Commit b844f31

Browse files
jirispilkaclaude
andauthored
fix: Fix error in the validation pipeline (#669)
* fix: strip all invalid HTTP header characters in sanitizeEnvValue The previous regex only removed \r\n but Node.js rejects all control characters outside [\t\x20-\x7e\x80-\xff] with ERR_INVALID_CHAR. CI secrets can contain null bytes or other control chars that slipped through. https://claude.ai/code/session_01Kj7rEHNuhTG5U7NrM9RWaG * fix: sanitize env vars in-place so third-party libs get clean values The OTel OTLP exporter (used by @arizeai/phoenix-otel) reads PHOENIX_API_KEY directly from process.env via getEnvApiKey(), bypassing our sanitizeEnvValue() wrapper. It then passes the raw value to node:http which throws ERR_INVALID_CHAR. Add sanitizeProcessEnv() that rewrites sensitive env vars on process.env itself, called right after dotenv.config(). This ensures every reader — including third-party libraries — gets clean values. https://claude.ai/code/session_01Kj7rEHNuhTG5U7NrM9RWaG * fix: simplify regex, improve comments, add redacted env logging - Replace cryptic allowlist regex with readable control-char blocklist - Explain why in-place sanitization is needed (phoenix-otel reads env directly) - Log redacted env var values during sanitizeProcessEnv() for CI debugging https://claude.ai/code/session_01Kj7rEHNuhTG5U7NrM9RWaG * fix: simplify tests for sanitizeEnvValue and sanitizeProcessEnv * docs: update DEVELOPMENT.md to include LLM evals * fix: improve value redaction logic for safer logging --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48fbec9 commit b844f31

7 files changed

Lines changed: 93 additions & 8 deletions

File tree

DEVELOPMENT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Restart Claude Code for the change to take effect. This token is picked up by bo
104104
| **Unit tests** | `npm run test:unit` | Individual modules in isolation — no credentials needed |
105105
| **Integration tests** | `npm run test:integration` | Full server over all transports against real Apify API (requires `APIFY_TOKEN` + `npm run build`) |
106106
| **mcpc probing** | `mcpc @stdio tools-call ...` | Interactive end-to-end verification during development |
107+
| **LLM evals** | CI only — apply `validated` label | Runs `evals/run_evaluation.ts` against multiple models via OpenRouter; requires `PHOENIX_*` and `OPENROUTER_*` secrets |
108+
109+
To trigger the eval workflow on a PR, apply the **`validated`** label.
110+
The workflow then runs automatically and posts results to Phoenix.
111+
It also runs automatically on every merge to the `master` branch.
107112

108113
### Live probing with mcpc
109114

evals/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
99
import log from '@apify/log';
1010

1111
// Re-export shared config
12-
export { OPENROUTER_CONFIG, sanitizeEnvValue, validateEnvVars, getRequiredEnvVars } from './shared/config.js';
12+
export { OPENROUTER_CONFIG, sanitizeEnvValue, sanitizeProcessEnv, validateEnvVars, getRequiredEnvVars } from './shared/config.js';
1313

1414
// Read the version from test-cases.json
1515
function getTestCasesVersion(): string {

evals/create_dataset.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { hideBin } from 'yargs/helpers';
1414

1515
import log from '@apify/log';
1616

17-
import { sanitizeEnvValue, validatePhoenixEnvVars } from './config.js';
17+
import { sanitizeEnvValue, sanitizeProcessEnv, validatePhoenixEnvVars } from './config.js';
1818
import { loadTestCases, filterByCategory, filterById, type TestCase } from './evaluation_utils.js';
1919

2020
// Set log level to debug
@@ -32,6 +32,7 @@ type CliArgs = {
3232

3333
// Load environment variables from .env file if present
3434
dotenv.config({ path: '.env' });
35+
sanitizeProcessEnv();
3536

3637
// Parse command line arguments using yargs
3738
const argv = yargs(hideBin(process.argv))

evals/run_evaluation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
PHOENIX_MAX_RETRIES,
3131
type EvaluatorName,
3232
sanitizeEnvValue,
33+
sanitizeProcessEnv,
3334
validatePhoenixEnvVars
3435
} from './config.js';
3536

@@ -58,6 +59,7 @@ const RUN_LLM_EVALUATOR = true;
5859
const RUN_TOOLS_EXACT_MATCH_EVALUATOR = true;
5960

6061
dotenv.config({ path: '.env' });
62+
sanitizeProcessEnv();
6163

6264
// Parse command line arguments using yargs
6365
const argv = yargs(hideBin(process.argv))

evals/shared/config.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,60 @@ export function getRequiredEnvVars(): Record<string, string | undefined> {
2323
}
2424

2525
/**
26-
* Removes newlines, trims whitespace, and strips surrounding quotes from env values.
27-
* CI secrets often include trailing newlines or quotes that break HTTP headers and URLs.
26+
* Strips control characters, trims whitespace, and removes surrounding double quotes.
27+
* CI secrets often contain trailing newlines or invisible control chars that break HTTP headers.
2828
*/
2929
export function sanitizeEnvValue(value?: string): string | undefined {
3030
if (value == null) return value;
31-
return value.replace(/[\r\n]/g, '').trim().replace(/^"|"$/g, '');
31+
// eslint-disable-next-line no-control-regex
32+
return value.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').trim().replace(/^"|"$/g, '');
33+
}
34+
35+
/**
36+
* Env vars used in HTTP headers (API keys, tokens, URLs).
37+
*
38+
* Why in-place? The phoenix-otel OTel exporter reads PHOENIX_API_KEY directly
39+
* from process.env (inside getEnvApiKey()) and passes it to node:http, which
40+
* throws ERR_INVALID_CHAR on any control characters. We can't intercept that
41+
* read, so we sanitize process.env itself before any library loads.
42+
*/
43+
const ENV_KEYS_TO_SANITIZE = [
44+
'OPENROUTER_API_KEY',
45+
'OPENROUTER_BASE_URL',
46+
'PHOENIX_API_KEY',
47+
'PHOENIX_BASE_URL',
48+
];
49+
50+
/**
51+
* Redact a value for safe logging: shows first 4 and last 4 chars, masks the rest.
52+
* Fully masks short values (≤ 8 chars) to prevent reconstruction from the log line.
53+
* Returns '(empty)' for empty strings, '(unset)' for undefined/null.
54+
*/
55+
function redact(value?: string | null): string {
56+
if (value == null) return '(unset)';
57+
if (value.length === 0) return '(empty)';
58+
if (value.length <= 6) return `*** (${value.length} chars)`;
59+
return `${value.slice(0, 3)}***${value.slice(-3)} (${value.length} chars)`;
60+
}
61+
62+
/**
63+
* Sanitize env vars in-place on process.env and log redacted values for CI debugging.
64+
* Must be called before any library reads these values.
65+
*/
66+
export function sanitizeProcessEnv(): void {
67+
for (const key of ENV_KEYS_TO_SANITIZE) {
68+
const raw = process.env[key];
69+
if (raw != null) {
70+
const sanitized = sanitizeEnvValue(raw)!;
71+
const changed = raw !== sanitized;
72+
process.env[key] = sanitized;
73+
// eslint-disable-next-line no-console
74+
console.log(`env ${key}: ${redact(sanitized)}${changed ? ' (sanitized)' : ''}`);
75+
} else {
76+
// eslint-disable-next-line no-console
77+
console.log(`env ${key}: ${redact(raw)}`);
78+
}
79+
}
3280
}
3381

3482
/**

evals/workflows/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77

88
// Re-export shared config for convenience
9-
export { OPENROUTER_CONFIG, sanitizeEnvValue, validateEnvVars } from '../shared/config.js';
9+
export { OPENROUTER_CONFIG, sanitizeEnvValue, sanitizeProcessEnv, validateEnvVars } from '../shared/config.js';
1010

1111
/**
1212
* Default model configuration for agent and judge

tests/unit/evals.config.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it } from 'vitest';
22

3-
import { sanitizeEnvValue } from '../../evals/shared/config.js';
3+
import { sanitizeEnvValue, sanitizeProcessEnv } from '../../evals/shared/config.js';
44

55
describe('sanitizeEnvValue', () => {
66
it('returns undefined for undefined', () => {
@@ -47,8 +47,37 @@ describe('sanitizeEnvValue', () => {
4747
expect(sanitizeEnvValue('')).toBe('');
4848
});
4949

50+
it('strips control characters', () => {
51+
expect(sanitizeEnvValue('sk-abc\x00123')).toBe('sk-abc123'); // null byte
52+
expect(sanitizeEnvValue('sk-abc\x01123')).toBe('sk-abc123'); // SOH
53+
expect(sanitizeEnvValue('sk-abc\x0b123')).toBe('sk-abc123'); // vertical tab
54+
expect(sanitizeEnvValue('sk-abc\x0c123')).toBe('sk-abc123'); // form feed
55+
expect(sanitizeEnvValue('sk-abc\x1f123')).toBe('sk-abc123'); // unit separator
56+
expect(sanitizeEnvValue('sk-abc\x7f123')).toBe('sk-abc123'); // DEL
57+
});
58+
5059
it('is idempotent', () => {
5160
const value = ' "sk-abc123"\r\n';
5261
expect(sanitizeEnvValue(sanitizeEnvValue(value))).toBe(sanitizeEnvValue(value));
5362
});
5463
});
64+
65+
describe('sanitizeProcessEnv', () => {
66+
afterEach(() => {
67+
delete process.env.PHOENIX_API_KEY;
68+
delete process.env.OPENROUTER_API_KEY;
69+
});
70+
71+
it('sanitizes env vars in-place', () => {
72+
process.env.PHOENIX_API_KEY = 'key-with-newline\n';
73+
process.env.OPENROUTER_API_KEY = ' "quoted-key"\r\n';
74+
sanitizeProcessEnv();
75+
expect(process.env.PHOENIX_API_KEY).toBe('key-with-newline');
76+
expect(process.env.OPENROUTER_API_KEY).toBe('quoted-key');
77+
});
78+
79+
it('leaves unset vars untouched', () => {
80+
sanitizeProcessEnv();
81+
expect(process.env.PHOENIX_API_KEY).toBeUndefined();
82+
});
83+
});

0 commit comments

Comments
 (0)