Files
Nick Nisi dc9dfb093c fix(workos): tighten auth guidance and risky eval reruns (#25)
* fix(workos): include required --org flag in portal generate-link examples

The "Not in the CLI" table in workos-management.md listed
`workos portal generate-link --intent=sso` and
`workos portal generate-link --intent=dsync` as the correct way to
reach the Admin Portal for connection creation. Both omit the
required --org flag and fail before opening the Portal. This is the
same hallucination shape the PR is trying to prevent agents from
producing — caught in review.

Add --org=<org_id> to both rows, matching the Quick Reference
entry at the top of the file.

* test(workos): tighten no-CLI assertion in connection-create evals

The first assertion in evals 8 and 9 was titled "States connection
creation is NOT in the CLI" but its needles included Admin Portal
and WorkOS Dashboard. An answer that only said "Use the Admin
Portal" without ever stating CLI support is absent passed the
assertion. Since the second assertion in each eval already checks
for the Admin Portal/Dashboard destination, the first was both
redundant and weakened.

Remove destination terms from the first assertion so it genuinely
checks for the explicit no-CLI signal.

* test(workos): improve risky eval coverage

Add targeted recipes for SSO, AuthKit, and RBAC cases that showed negative or noisy eval deltas. Tighten brittle flow-step expectations where the previous wording rewarded incidental prose ordering instead of the intended behavior.

Add multi-case eval filtering and an eval:risk helper so the latest triage cases can be rerun with more samples and saved outputs.

* Fix explicit triage handling in risk reruns

* fix: format:check script incorrectly using prettier

* chore: format files for oxfmt check
2026-04-26 09:06:35 -05:00

190 lines
5.5 KiB
TypeScript

import { spawnSync } from 'child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join, resolve } from 'path';
import { fileURLToPath } from 'url';
interface TriageCase {
caseId: string;
riskScore?: number;
}
interface TriageReport {
triageCases?: TriageCase[];
}
interface Options {
cases?: string[];
triagePath?: string;
top: number;
minRisk: number;
samples: number;
concurrency: number;
model?: string;
report: string;
dryRun: boolean;
noCache: boolean;
saveAllSamples: boolean;
failOnRegression: boolean;
}
const OUTPUT_DIR = join(process.cwd(), 'scripts', 'output');
const DEFAULT_CASES = [
'authkit-ruby-rails',
'sso-node-basic',
'sso-ruby-idp-initiated',
'sso-ruby-domain-routing',
'rbac-python-role-assignment',
];
function argValue(args: string[], name: string): string | undefined {
return args.find((arg) => arg.startsWith(`${name}=`))?.split('=')[1];
}
function parsePositiveInt(raw: string | undefined, fallback: number): number {
const value = Number.parseInt(raw ?? '', 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function parseTop(raw: string | undefined): number {
if (raw === 'all') return Number.POSITIVE_INFINITY;
return parsePositiveInt(raw, 9);
}
function parseArgs(): Options {
const args = process.argv.slice(2);
const cases = argValue(args, '--cases')
?.split(',')
.map((id) => id.trim())
.filter(Boolean);
return {
cases,
triagePath: argValue(args, '--triage'),
top: parseTop(argValue(args, '--top')),
minRisk: parsePositiveInt(argValue(args, '--min-risk'), 1),
samples: parsePositiveInt(argValue(args, '--samples'), 8),
concurrency: parsePositiveInt(argValue(args, '--concurrency'), 3),
model: argValue(args, '--model'),
report: argValue(args, '--report') ?? 'both',
dryRun: args.includes('--dry-run'),
noCache: !args.includes('--cache'),
saveAllSamples: !args.includes('--no-save-all-samples'),
failOnRegression: args.includes('--fail-on-regression'),
};
}
function latestTriagePath(): string | undefined {
if (!existsSync(OUTPUT_DIR)) return undefined;
return readdirSync(OUTPUT_DIR)
.filter((file) => file.startsWith('eval-triage-') && file.endsWith('.json'))
.map((file) => join(OUTPUT_DIR, file))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
}
function readTriageCases(path: string, options: Options): string[] {
const report = JSON.parse(readFileSync(path, 'utf8')) as TriageReport;
const riskyCases = report.triageCases ?? [];
const filtered = riskyCases.filter((entry) => (entry.riskScore ?? 0) >= options.minRisk).map((entry) => entry.caseId);
return Number.isFinite(options.top) ? filtered.slice(0, options.top) : filtered;
}
function unique(values: string[]): string[] {
return [...new Set(values)];
}
function formatTop(top: number): string {
return Number.isFinite(top) ? String(top) : 'all';
}
export function resolveCaseIds(options: Options): { caseIds: string[]; source: string } {
if (options.cases?.length) {
return { caseIds: unique(options.cases), source: '--cases' };
}
if (options.triagePath) {
const triagePath = resolve(options.triagePath);
if (!existsSync(triagePath)) {
throw new Error(`--triage report not found: ${triagePath}`);
}
let caseIds: string[];
try {
caseIds = unique(readTriageCases(triagePath, options));
} catch (err) {
throw new Error(`Failed to read --triage report ${triagePath}: ${(err as Error).message}`);
}
if (caseIds.length === 0) {
throw new Error(
`--triage selected 0 cases from ${triagePath} after --min-risk=${options.minRisk} and --top=${formatTop(options.top)}`,
);
}
return { caseIds, source: triagePath };
}
const triagePath = latestTriagePath();
if (triagePath && existsSync(triagePath)) {
const caseIds = unique(readTriageCases(triagePath, options));
if (caseIds.length > 0) {
return { caseIds, source: triagePath };
}
}
return { caseIds: DEFAULT_CASES, source: 'built-in risky-case fallback' };
}
function shellQuote(value: string): string {
return /^[a-zA-Z0-9_./:=,-]+$/.test(value) ? value : JSON.stringify(value);
}
function main() {
const options = parseArgs();
const { caseIds, source } = resolveCaseIds(options);
const evalArgs = [
'eval',
'--',
`--cases=${caseIds.join(',')}`,
`--samples=${options.samples}`,
`--concurrency=${options.concurrency}`,
`--report=${options.report}`,
];
if (options.noCache) evalArgs.push('--no-cache');
if (options.saveAllSamples) evalArgs.push('--save-all-samples');
if (options.model) evalArgs.push(`--model=${options.model}`);
if (options.failOnRegression) evalArgs.push('--fail-on-regression');
if (options.dryRun) evalArgs.push('--dry-run');
console.log(`Selected ${caseIds.length} case(s) from ${source}:`);
for (const caseId of caseIds) {
console.log(` - ${caseId}`);
}
console.log(`\nCommand:\n pnpm ${evalArgs.map(shellQuote).join(' ')}\n`);
if (options.dryRun) return;
if (!process.env.ANTHROPIC_API_KEY) {
console.error('Error: ANTHROPIC_API_KEY environment variable is required.');
process.exit(1);
}
const result = spawnSync('pnpm', evalArgs, {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 1);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
try {
main();
} catch (err) {
console.error(`Error: ${(err as Error).message}`);
process.exit(1);
}
}