Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/cli/src/commands/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,18 @@ function readRange(filePath: string, start: number, end: number): string {
return buffer.toString('utf-8');
}

/** Safe pattern for workspace IDs — alphanumeric, hyphens, underscores, and dots only. No path separators. */
const WORKSPACE_ID_PATTERN = /^[a-zA-Z0-9_\-\.]+$/;

/** Resolve a workspace ID to its workflow.log path, or exit with an error. */
function resolveLogFile(workspaceId: string): string {
// Security: reject path traversal characters
if (!WORKSPACE_ID_PATTERN.test(workspaceId)) {
console.error(`ERROR: Invalid workspace name: ${workspaceId}`);
console.error('Workspace names may only contain letters, numbers, hyphens, underscores, and dots.');
process.exit(1);
}

const workspacesDir = getWorkspacesDir();

// 1. Direct match
Expand Down
14 changes: 7 additions & 7 deletions apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ export async function start(args: StartArgs): Promise<void> {
const repo = resolveRepo(args.repo);
const config = args.config ? resolveConfig(args.config) : undefined;

// 4. Ensure workspaces dir is writable by container user (UID 1001)
// 4. Ensure workspaces dir is accessible by container user (UID remapped via entrypoint)
const workspacesDir = getWorkspacesDir();
fs.mkdirSync(workspacesDir, { recursive: true });
fs.chmodSync(workspacesDir, 0o777);
fs.chmodSync(workspacesDir, 0o755);

// 5. Ensure image (auto-build in dev, pull in npx) and start infra
ensureImage(args.version);
Expand All @@ -85,19 +85,19 @@ export async function start(args: StartArgs): Promise<void> {
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;

// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
// subdirs and the overlay backing dirs.
// The run dir and its INTERNAL_DIR need owner-writable permissions for the container
// user (remapped via entrypoint). Others should have read-only access.
const workspacePath = path.join(workspacesDir, workspace);
const internalPath = path.join(workspacePath, INTERNAL_DIR);
fs.mkdirSync(workspacePath, { recursive: true });
fs.chmodSync(workspacePath, 0o777);
fs.chmodSync(workspacePath, 0o755);
migrateLegacyWorkspaceLayout(workspacePath);
fs.mkdirSync(internalPath, { recursive: true });
fs.chmodSync(internalPath, 0o777);
fs.chmodSync(internalPath, 0o755);
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
const dirPath = path.join(internalPath, dir);
fs.mkdirSync(dirPath, { recursive: true });
fs.chmodSync(dirPath, 0o777);
fs.chmodSync(dirPath, 0o755);
}

// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
Expand Down
35 changes: 23 additions & 12 deletions apps/cli/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,30 @@ import dotenv from 'dotenv';
import { resolveConfig } from './config/resolver.js';
import { getMode } from './mode.js';

/** Environment variables forwarded to worker containers. */
/**
* Environment variables forwarded to worker containers.
*
* SECURITY: Credential variables (API keys, OAuth tokens) are NOT forwarded
* via Docker environment variables. They would be visible to any user with
* `docker inspect` access and to any process via `/proc/*/environ` inside the
* container. Credentials reach the worker through the Temporal activity input
* and Claude SDK's sdkEnv map, not through process.env passthrough.
*/
const FORWARD_VARS = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_USE_BEDROCK',
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'ANTHROPIC_SMALL_MODEL',
'ANTHROPIC_MEDIUM_MODEL',
'ANTHROPIC_LARGE_MODEL',
'CLAUDE_ADAPTIVE_THINKING',
// Infrastructure — not credentials
'ANTHROPIC_BASE_URL', // Custom API endpoint URL (proxy/router)
'ANTHROPIC_AUTH_TOKEN', // Auth token for custom endpoint

// Provider mode flags — not credentials
'CLAUDE_CODE_USE_BEDROCK', // AWS Bedrock mode flag
'AWS_REGION', // AWS region (non-secret config)
'AWS_BEARER_TOKEN_BEDROCK', // AWS Bedrock bearer token (needed for SDK auth in container)
'ANTHROPIC_SMALL_MODEL', // Override small model tier
'ANTHROPIC_MEDIUM_MODEL', // Override medium model tier
'ANTHROPIC_LARGE_MODEL', // Override large model tier

// SDK configuration — not credentials
'CLAUDE_ADAPTIVE_THINKING', // SDK thinking budget config
] as const;

/**
Expand Down
116 changes: 116 additions & 0 deletions apps/cli/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
*
* Local mode supports bare repo names (e.g. "my-repo" → ./repos/my-repo).
* Both modes resolve relative paths against CWD.
*
* SECURITY: Resolved paths are validated against a blocklist of system
* directories to prevent accidental exposure of sensitive host files
* to the worker container via Docker volume mounts.
*/

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { isLocal } from './mode.js';

export interface MountPair {
Expand Down Expand Up @@ -50,6 +55,111 @@ export function resolveRunFile(runDir: string, filename: string): string {
* Resolve --repo to absolute path and container mount.
* Dev mode: bare names (no / or . prefix) check ./repos/<name> first.
*/

/** System directories that should not be mounted into the container. */
const BLOCKED_MOUNT_PATHS = [
'/etc',
'/sys',
'/proc',
'/dev',
'/boot',
'/lost+found',
'/media',
'/mnt',
'/run',
'/srv',
'/var/lib/docker',
'/var/run/docker.sock',
'/root',
'/snap',
];

/** Sensitive home subdirectories that should not be mounted. */
const BLOCKED_HOME_SUBDIRS = [
'.ssh',
'.aws',
'.gcloud',
'.config',
'.docker',
'.gnupg',
'.kube',
'snap',
];

/** Normalize a path for comparison — resolve symlinks and trailing slashes. */
function normalizePath(p: string): string {
try {
return fs.realpathSync(p);
} catch {
return path.resolve(p);
}
}

/** Check if a resolved host path is a blocked system directory. */
function getBlockedMountPath(hostPath: string): string | null {
const normalized = normalizePath(hostPath);

for (const blocked of BLOCKED_MOUNT_PATHS) {
const nb = normalizePath(blocked);
if (normalized === nb || normalized.startsWith(nb + path.sep)) {
return blocked;
}
}

const homeDir = normalizePath(os.homedir());
for (const subdir of BLOCKED_HOME_SUBDIRS) {
const bp = path.join(homeDir, subdir);
const nb = normalizePath(bp);
if (normalized === nb || normalized.startsWith(nb + path.sep)) {
return bp;
}
}

return null;
}

/** Allowed base directories for mounts. */
function getAllowedMountPrefixes(): string[] {
const prefixes: string[] = [normalizePath('.')];
if (isLocal()) {
const reposDir = path.resolve('repos');
try {
if (fs.statSync(reposDir).isDirectory()) {
prefixes.push(normalizePath(reposDir));
}
} catch {
// repos/ dir may not exist yet
}
}
try {
prefixes.push(normalizePath('/tmp'));
} catch {
// /tmp should always exist
}
return prefixes;
}

/** Validate that a resolved host path is safe to mount. */
function validateMountPath(hostPath: string, argName: string): void {
const normalized = normalizePath(hostPath);

const blocked = getBlockedMountPath(hostPath);
if (blocked) {
console.error(`ERROR: ${argName} path resolves to a system directory: ${normalized}`);
console.error(` Mounting ${blocked} into the container is not allowed for security reasons.`);
console.error(` Place your target repository under an allowed directory.`);
process.exit(1);
}

const allowed = getAllowedMountPrefixes();
const isAllowed = allowed.some((prefix) => normalized === prefix || normalized.startsWith(prefix + path.sep));
if (!isAllowed) {
console.warn(`WARNING: ${argName} path is outside the expected directory: ${normalized}`);
console.warn(` This path will be mounted into the worker container.`);
console.warn(` Only mount directories you trust, as the AI agent will have read access to all files.`);
}
}

export function resolveRepo(repoArg: string): MountPair {
let hostPath: string;

Expand Down Expand Up @@ -79,6 +189,9 @@ export function resolveRepo(repoArg: string): MountPair {
process.exit(1);
}

// Security validation
validateMountPath(hostPath, '--repo');

const basename = path.basename(hostPath);
return {
hostPath,
Expand All @@ -102,6 +215,9 @@ export function resolveConfig(configArg: string): MountPair {
process.exit(1);
}

// Security validation
validateMountPath(hostPath, '--config');

const basename = path.basename(hostPath);
return {
hostPath,
Expand Down