mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-11 13:20:05 +00:00
Enhance error messages throughout the codebase to provide clearer explanations and more actionable guidance for users. Changes: Shell Scripts (shared/common.sh): - Improve non-interactive mode error with better examples - Expand model ID validation to show valid characters and examples - Add detailed server name requirements with examples - Fix diagnostic function to handle cases without fixes section TypeScript CLI (cli/src/security.ts): - Enhance identifier validation with bullet points and examples - Add context about entity type (agent vs cloud) in errors - Improve path traversal error with specific character explanations - Better prompt validation messages with plain language guidance - Improve overly-long identifier/prompt errors with helpful context TypeScript CLI (cli/src/commands.ts): - Rewrite download failure messages to be more user-friendly - Change "Common causes" to "What's wrong" for clarity - Change "How to fix" to "What to do" for better action orientation - Add more specific troubleshooting steps for network issues - Improve wording to be less technical and more helpful Impact: - Users get clearer, more actionable error messages - Error messages now include examples of correct usage - Reduced cognitive load by using plain language instead of jargon - Better guidance for fixing issues without needing to consult docs Agent: ux-engineer Co-authored-by: Spawn Refactor Service <refactor@spawn.service> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
/**
|
|
* Security validation utilities for spawn CLI
|
|
* SECURITY-CRITICAL: These functions protect against injection attacks
|
|
*/
|
|
|
|
// Allowlist pattern for agent and cloud identifiers
|
|
// Only lowercase alphanumeric, hyphens, and underscores allowed
|
|
const IDENTIFIER_PATTERN = /^[a-z0-9_-]+$/;
|
|
|
|
/**
|
|
* Validates an identifier (agent or cloud name) against security constraints.
|
|
* SECURITY-CRITICAL: Prevents path traversal, command injection, and URL injection.
|
|
*
|
|
* @param identifier - The agent or cloud identifier to validate
|
|
* @param fieldName - Human-readable field name for error messages
|
|
* @throws Error if validation fails
|
|
*/
|
|
export function validateIdentifier(identifier: string, fieldName: string): void {
|
|
if (!identifier || identifier.trim() === "") {
|
|
const listCmd = fieldName.toLowerCase().includes("agent") ? "spawn agents" : "spawn clouds";
|
|
throw new Error(
|
|
`${fieldName} is required but was not provided.\n\n` +
|
|
`Run '${listCmd}' to see all available options.`
|
|
);
|
|
}
|
|
|
|
// Check length constraints (prevent DoS via extremely long identifiers)
|
|
if (identifier.length > 64) {
|
|
const listCmd = fieldName.toLowerCase().includes("agent") ? "spawn agents" : "spawn clouds";
|
|
const entityType = fieldName.toLowerCase().includes("agent") ? "agent" : "cloud provider";
|
|
throw new Error(
|
|
`${fieldName} is too long (${identifier.length} characters, maximum is 64).\n\n` +
|
|
`This looks unusual. ${entityType.charAt(0).toUpperCase() + entityType.slice(1)} names are typically short identifiers.\n\n` +
|
|
`Did you accidentally paste something else? Check that you're using the correct ${entityType} name.\n\n` +
|
|
`To see all available ${entityType}s, run: ${listCmd}`
|
|
);
|
|
}
|
|
|
|
// Allowlist validation: only safe characters
|
|
if (!IDENTIFIER_PATTERN.test(identifier)) {
|
|
const listCmd = fieldName.toLowerCase().includes("agent") ? "spawn agents" : "spawn clouds";
|
|
const entityType = fieldName.toLowerCase().includes("agent") ? "agent" : "cloud provider";
|
|
throw new Error(
|
|
`Invalid ${fieldName.toLowerCase()}: "${identifier}"\n\n` +
|
|
`${entityType.charAt(0).toUpperCase() + entityType.slice(1)} names can only contain:\n` +
|
|
` • Lowercase letters (a-z)\n` +
|
|
` • Numbers (0-9)\n` +
|
|
` • Hyphens (-) and underscores (_)\n\n` +
|
|
`Examples of valid names:\n` +
|
|
` • claude\n` +
|
|
` • github-codespaces\n` +
|
|
` • e2b\n\n` +
|
|
`To see all available ${entityType}s, run: ${listCmd}`
|
|
);
|
|
}
|
|
|
|
// Prevent path traversal patterns (defense in depth)
|
|
if (identifier.includes("..") || identifier.includes("/") || identifier.includes("\\")) {
|
|
const listCmd = fieldName.toLowerCase().includes("agent") ? "spawn agents" : "spawn clouds";
|
|
const entityType = fieldName.toLowerCase().includes("agent") ? "agent" : "cloud provider";
|
|
throw new Error(
|
|
`Invalid ${fieldName.toLowerCase()}: "${identifier}"\n\n` +
|
|
`The name contains path-like characters that aren't allowed:\n` +
|
|
` • Forward slashes (/)\n` +
|
|
` • Backslashes (\\)\n` +
|
|
` • Parent directory references (..)\n\n` +
|
|
`${entityType.charAt(0).toUpperCase() + entityType.slice(1)} names must be simple identifiers without paths.\n\n` +
|
|
`To see all available ${entityType}s, run: ${listCmd}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a bash script for obvious malicious patterns before execution.
|
|
* SECURITY-CRITICAL: Last line of defense before executing remote code.
|
|
*
|
|
* @param script - The script content to validate
|
|
* @throws Error if dangerous patterns are detected
|
|
*/
|
|
export function validateScriptContent(script: string): void {
|
|
// Ensure script is not empty
|
|
if (!script || script.trim() === "") {
|
|
throw new Error(
|
|
"The downloaded script is empty.\n\n" +
|
|
"This usually means the server returned an error instead of the script.\n\n" +
|
|
"How to fix:\n" +
|
|
" 1. Check your internet connection\n" +
|
|
" 2. Verify the combination exists: spawn matrix\n" +
|
|
" 3. Wait a moment and try again (the server may be temporarily unavailable)"
|
|
);
|
|
}
|
|
|
|
// Check for obviously malicious patterns
|
|
const dangerousPatterns: Array<{ pattern: RegExp; description: string }> = [
|
|
{ pattern: /rm\s+-rf\s+\/(?!\w)/, description: "destructive filesystem operation (rm -rf /)" },
|
|
{ pattern: /mkfs\./, description: "filesystem formatting command" },
|
|
{ pattern: /dd\s+if=/, description: "raw disk operation" },
|
|
{ pattern: /:(){:|:&};:/, description: "fork bomb pattern" },
|
|
];
|
|
|
|
for (const { pattern, description } of dangerousPatterns) {
|
|
if (pattern.test(script)) {
|
|
throw new Error(
|
|
`Security check failed: the downloaded script contains a dangerous pattern.\n\n` +
|
|
`Pattern detected: ${description}\n\n` +
|
|
`This is unexpected and may indicate the file was tampered with or corrupted.\n` +
|
|
`Please report this at: https://github.com/OpenRouterTeam/spawn/issues`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Ensure script starts with shebang
|
|
if (!script.trim().startsWith("#!")) {
|
|
throw new Error(
|
|
"The downloaded file doesn't appear to be a valid bash script.\n\n" +
|
|
"Common causes:\n" +
|
|
" • The server returned an error page (404, 500, etc.) instead of the script\n" +
|
|
" • Network connection was interrupted during download\n" +
|
|
" • The script file hasn't been published yet (even though it appears in the matrix)\n\n" +
|
|
"How to fix:\n" +
|
|
" 1. Check your internet connection and try again\n" +
|
|
" 2. Run 'spawn matrix' to verify the combination is marked as implemented\n" +
|
|
" 3. Wait a few moments (the script may be deploying) and retry\n" +
|
|
" 4. If the issue persists, report it: https://github.com/OpenRouterTeam/spawn/issues"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Sensitive path patterns that should never be read as prompt files
|
|
// These protect credentials and system files from accidental exfiltration
|
|
const SENSITIVE_PATH_PATTERNS: ReadonlyArray<{ pattern: RegExp; description: string }> = [
|
|
{ pattern: /(?:^|\/)\.ssh\//, description: "SSH directory (may contain private keys)" },
|
|
{ pattern: /(?:^|\/)\.aws\//, description: "AWS credentials directory" },
|
|
{ pattern: /(?:^|\/)\.config\/gcloud\//, description: "Google Cloud credentials" },
|
|
{ pattern: /(?:^|\/)\.azure\//, description: "Azure credentials directory" },
|
|
{ pattern: /(?:^|\/)\.kube\//, description: "Kubernetes config (may contain tokens)" },
|
|
{ pattern: /(?:^|\/)\.docker\/config\.json$/, description: "Docker registry credentials" },
|
|
{ pattern: /(?:^|\/)\.npmrc$/, description: "npm credentials" },
|
|
{ pattern: /(?:^|\/)\.netrc$/, description: "netrc credentials" },
|
|
{ pattern: /(?:^|\/)\.env(?:\.\w+)?$/, description: "environment file (may contain secrets)" },
|
|
{ pattern: /(?:^|\/)\.git-credentials$/, description: "Git credentials" },
|
|
{ pattern: /^\/etc\/shadow$/, description: "system password hashes" },
|
|
{ pattern: /^\/etc\/master\.passwd$/, description: "system password hashes (macOS)" },
|
|
{ pattern: /id_(?:rsa|ed25519|ecdsa|dsa)(?:\.pub)?$/, description: "SSH key file" },
|
|
];
|
|
|
|
// Maximum prompt file size (1MB) to prevent accidental reads of large files
|
|
const MAX_PROMPT_FILE_SIZE = 1024 * 1024;
|
|
|
|
/**
|
|
* Validates a prompt file path for safety before reading.
|
|
* SECURITY-CRITICAL: Prevents reading sensitive files and exfiltrating credentials.
|
|
*
|
|
* @param filePath - The file path to validate
|
|
* @throws Error if the path points to a sensitive file or fails validation
|
|
*/
|
|
export function validatePromptFilePath(filePath: string): void {
|
|
if (!filePath || filePath.trim() === "") {
|
|
throw new Error(
|
|
"Prompt file path is required when using --prompt-file.\n\n" +
|
|
"Example:\n" +
|
|
" spawn <agent> <cloud> --prompt-file instructions.txt"
|
|
);
|
|
}
|
|
|
|
// Normalize the path to resolve .. and symlink-like textual tricks
|
|
const { resolve } = require("path");
|
|
const resolved = resolve(filePath);
|
|
|
|
// Check against sensitive path patterns
|
|
for (const { pattern, description } of SENSITIVE_PATH_PATTERNS) {
|
|
if (pattern.test(resolved)) {
|
|
throw new Error(
|
|
`Security check failed: cannot use '${filePath}' as a prompt file.\n\n` +
|
|
`This path points to ${description}.\n` +
|
|
`Prompt contents are sent to the agent and may be logged or stored remotely.\n\n` +
|
|
`For security, use a plain text file instead:\n` +
|
|
` 1. Create a new file: echo "Your instructions here" > prompt.txt\n` +
|
|
` 2. Use it: spawn <agent> <cloud> --prompt-file prompt.txt`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates prompt file metadata (must be a regular file, within size limit).
|
|
*
|
|
* @param filePath - The file path to check
|
|
* @param statFn - Stat function (injectable for testing)
|
|
* @throws Error if file is not suitable for reading as a prompt
|
|
*/
|
|
export function validatePromptFileStats(filePath: string, stats: { isFile: () => boolean; size: number }): void {
|
|
if (!stats.isFile()) {
|
|
throw new Error(
|
|
`Cannot read prompt: '${filePath}' is not a regular file.\n\n` +
|
|
`The path points to a directory, device, or other non-file object.\n` +
|
|
`Provide a path to a text file containing your prompt.`
|
|
);
|
|
}
|
|
|
|
if (stats.size > MAX_PROMPT_FILE_SIZE) {
|
|
const sizeMB = (stats.size / (1024 * 1024)).toFixed(1);
|
|
throw new Error(
|
|
`Prompt file is too large: ${sizeMB}MB (maximum is 1MB).\n\n` +
|
|
`How to fix:\n` +
|
|
` • Use a shorter, more focused prompt\n` +
|
|
` • Break the work into multiple smaller tasks\n` +
|
|
` • Remove unnecessary context or examples`
|
|
);
|
|
}
|
|
|
|
if (stats.size === 0) {
|
|
throw new Error(
|
|
`Prompt file is empty: ${filePath}\n\n` +
|
|
`The file exists but contains no text.\n` +
|
|
`Add your instructions to the file and try again.`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates a prompt string for non-interactive agent execution.
|
|
* SECURITY-CRITICAL: Prevents command injection via prompt parameter.
|
|
*
|
|
* @param prompt - The user-provided prompt to validate
|
|
* @throws Error if validation fails
|
|
*/
|
|
export function validatePrompt(prompt: string): void {
|
|
if (!prompt || prompt.trim() === "") {
|
|
throw new Error(
|
|
"Prompt is required but was not provided.\n\n" +
|
|
"Provide a prompt with --prompt:\n" +
|
|
" spawn <agent> <cloud> --prompt \"Your task here\"\n\n" +
|
|
"Or use a file:\n" +
|
|
" spawn <agent> <cloud> --prompt-file prompt.txt"
|
|
);
|
|
}
|
|
|
|
// Check length constraints (10KB max to prevent DoS)
|
|
const MAX_PROMPT_LENGTH = 10 * 1024;
|
|
if (prompt.length > MAX_PROMPT_LENGTH) {
|
|
const lengthKB = (prompt.length / 1024).toFixed(1);
|
|
throw new Error(
|
|
`Your prompt is too long (${lengthKB}KB, maximum is 10KB).\n\n` +
|
|
`For longer instructions, save them to a file instead:\n\n` +
|
|
` 1. Save your prompt: echo "Your long instructions..." > instructions.txt\n` +
|
|
` 2. Use the file: spawn <agent> <cloud> --prompt-file instructions.txt\n\n` +
|
|
`This also makes it easier to edit and reuse your prompts.`
|
|
);
|
|
}
|
|
|
|
// Check for obvious command injection patterns
|
|
// These patterns would break out of the shell quoting used in bash scripts
|
|
const dangerousPatterns: Array<{ pattern: RegExp; description: string; suggestion: string }> = [
|
|
{ pattern: /\$\(.*\)/, description: "command substitution $()", suggestion: 'Instead of "Fix $(ls)", try "Fix the output from ls"' },
|
|
{ pattern: /`[^`]*`/, description: "backtick command substitution", suggestion: "Describe the command output instead of using backticks" },
|
|
{ pattern: /;\s*rm\s+-rf/, description: "dangerous command sequence", suggestion: "Describe what you want the agent to do without using shell syntax" },
|
|
{ pattern: /\|\s*bash/, description: "shell piping to bash", suggestion: "Describe the desired outcome instead" },
|
|
{ pattern: /\|\s*sh/, description: "shell piping to sh", suggestion: "Describe the desired outcome instead" },
|
|
];
|
|
|
|
for (const { pattern, description, suggestion } of dangerousPatterns) {
|
|
if (pattern.test(prompt)) {
|
|
throw new Error(
|
|
`Your prompt contains shell syntax that can't be safely passed to the agent.\n\n` +
|
|
`Issue: ${description}\n\n` +
|
|
`${suggestion}\n\n` +
|
|
`Important: You don't need to write shell commands in your prompt!\n` +
|
|
`Just describe what you want in plain English, and the agent will write the code for you.\n\n` +
|
|
`Example:\n` +
|
|
` Instead of: "Fix $(ls -la)"\n` +
|
|
` Write: "Fix the directory listing issues"`
|
|
);
|
|
}
|
|
}
|
|
}
|