feat: backport auth-validation preflight + email_login credentials
CI / Type-check & lint (pull_request) Successful in 16s
CI / Build & push worker image (pull_request) Has been skipped
CI / Build & push API image (pull_request) Has been skipped

Backport upstream Shannon PR #335:
- Add credential validation activity that drives a real browser login
  before the full pipeline, catching bad credentials early
- New email_login credentials type for magic-link and email-OTP flows
- Make credentials.password optional for passwordless flows
- Playwright stealth config (chrome.runtime, plugin simulation, UA)
- Centralize prompt directory resolution into resolvePromptDir helper
- New AUTH_LOGIN_FAILED error code with non-retryable classification
- Remove dangerous-pattern validation on credentials.password
- Pipeline-testing stub for auth validation (returns success)
- Auth validation timeout of 10 minutes for browser-based login
- .playwright directory workspace overlay for CLI/Docker

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-20 00:59:27 +00:00
committed by Hugh Commit [agent]
parent 70af2b12db
commit 47a6e4933a
16 changed files with 489 additions and 26 deletions
@@ -138,6 +138,9 @@ function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { ty
case ErrorCode.AUTH_FAILED:
return { type: 'AuthenticationError', retryable: false };
case ErrorCode.AUTH_LOGIN_FAILED:
return { type: 'AuthLoginFailedError', retryable: false };
case ErrorCode.BILLING_ERROR:
return { type: 'BillingError', retryable: true };
@@ -76,6 +76,17 @@ async function buildLoginInstructions(
`generated TOTP code using secret "${authentication.credentials.totp_secret}"`,
);
}
if (authentication.credentials.email_login) {
const emailLogin = authentication.credentials.email_login;
userInstructions = userInstructions.replace(/\$email_address/g, emailLogin.address);
userInstructions = userInstructions.replace(/\$email_password/g, emailLogin.password);
if (emailLogin.totp_secret) {
userInstructions = userInstructions.replace(
/\$email_totp/g,
`generated TOTP code using secret "${emailLogin.totp_secret}"`,
);
}
}
}
loginInstructions = loginInstructions.replace(/{{user_instructions}}/g, userInstructions);
@@ -221,6 +232,16 @@ async function interpolateVariables(
}
}
/**
* Resolve a prompt directory override to an absolute path.
* Falls back to the compiled-in PROMPTS_DIR when no override is given.
*/
export function resolvePromptDir(promptDir: string | undefined): string {
if (!promptDir) return PROMPTS_DIR;
if (path.isAbsolute(promptDir)) return promptDir;
return path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), promptDir);
}
// Pure function: Load and interpolate prompt template
export async function loadPrompt(
promptName: string,
@@ -0,0 +1,128 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* Auth-validation preflight service.
*
* Drives a real browser login before the full pipeline runs,
* catching bad credentials early and saving API budget.
*/
import type { JsonSchemaOutputFormat } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { runClaudePrompt } from '../ai/claude-executor.js';
import type { AuditSession } from '../audit/index.js';
import type { ActivityLogger } from '../types/activity-logger.js';
import type { DistributedConfig, ProviderConfig } from '../types/config.js';
import { ErrorCode } from '../types/errors.js';
import type { Result } from '../types/result.js';
import { err, ok } from '../types/result.js';
import { PentestError } from './error-handling.js';
import { loadPrompt } from './prompt-manager.js';
type FailurePoint = 'username_or_password' | 'totp_secret' | 'out_of_band';
const AuthValidationSchema = z.object({
login_success: z.boolean(),
failure_point: z.enum(['username_or_password', 'totp_secret', 'out_of_band']).optional(),
failure_detail: z.string().max(250).optional(),
});
const AUTH_VALIDATION_OUTPUT_FORMAT: JsonSchemaOutputFormat = {
type: 'json_schema',
schema: z.toJSONSchema(AuthValidationSchema, { target: 'draft-07' }) as Record<string, unknown>,
};
export interface AuthValidationInput {
webUrl: string;
repoPath: string;
config: DistributedConfig;
pipelineTestingMode: boolean;
auditSession: AuditSession;
logger: ActivityLogger;
promptDir?: string;
apiKey?: string;
providerConfig?: ProviderConfig;
}
function classifyResult(parsed: z.infer<typeof AuthValidationSchema>): Result<void, PentestError> {
if (parsed.login_success) {
return ok(undefined);
}
const failurePoint: FailurePoint = parsed.failure_point ?? 'username_or_password';
const detail = parsed.failure_detail ?? 'Login failed';
return err(
new PentestError(
`Authentication validation failed at "${failurePoint}": ${detail}`,
'config',
false,
{ failurePoint, failureDetail: detail },
ErrorCode.AUTH_LOGIN_FAILED,
),
);
}
export async function validateAuthentication(input: AuthValidationInput): Promise<Result<void, PentestError>> {
const { webUrl, repoPath, config, pipelineTestingMode, auditSession, logger, promptDir, apiKey, providerConfig } =
input;
// 1. Load the validation prompt
const prompt = await loadPrompt(
'validate-authentication',
{ webUrl, repoPath },
config,
pipelineTestingMode,
logger,
promptDir,
);
// 2. Run the agent with structured output
const result = await runClaudePrompt(
prompt,
repoPath,
'',
'Auth validation',
'validate-authentication',
auditSession,
logger,
'medium',
AUTH_VALIDATION_OUTPUT_FORMAT,
apiKey,
undefined,
providerConfig,
);
// 3. Parse structured output
if (!result.success || !result.structuredOutput) {
return err(
new PentestError(
`Auth validation agent did not return a structured verdict: ${result.error ?? 'unknown error'}`,
'validation',
true,
{ agentError: result.error },
ErrorCode.AGENT_EXECUTION_FAILED,
),
);
}
const parseResult = AuthValidationSchema.safeParse(result.structuredOutput);
if (!parseResult.success) {
return err(
new PentestError(
`Auth validation output failed schema validation: ${parseResult.error.message}`,
'validation',
true,
{ zodErrors: parseResult.error.issues },
ErrorCode.OUTPUT_VALIDATION_FAILED,
),
);
}
// 4. Classify the verdict
return classifyResult(parseResult.data);
}