diff --git a/CLAUDE.md b/CLAUDE.md index fe1351ae..1efdb674 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,7 +249,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter | | **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) skip Basic auth (localhost-only, schema-validated). When auth is active (`CODEMAN_PASSWORD` set), the loopback bypass requires the per-instance `X-Codeman-Hook-Secret` header **unconditionally** — COD-54 introduced it tunnel-gated; COD-91 (PR #127) made it always-on because Codeman can't detect a user's own loopback reverse proxy (own cloudflared/`tailscale serve`/nginx → 127.0.0.1), closing that residual plain-bypass gap. Hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env, `config/hook-secret.ts`); a missing/wrong secret gets 401 and rate-limits in a dedicated bucket (never locks out login). Tunnel enable **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged — via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (env, COD-55) **or** the per-request `acknowledgeUnauthTunnel:true` action field (1.1.9): the welcome/settings tunnel toggle pops a security confirm dialog and, on confirm, resends with that flag (server logs a loud warning on every passwordless tunnel start; curl/API stay refused without password/env/flag). The flag is an action field, never persisted | | **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS`=1 (opt-in hooks-only listener on the docker bridge gateway so in-container hooks reach a loopback-bound server; bind IP from `CODEMAN_DOCKER_BRIDGE_HOST` or auto-detect) | -| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | ### SSE Event Registry diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 47970a49..6cf28fc1 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -60,7 +60,7 @@ export function stripAnsi(text: string): string { */ export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; -export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_/\-. ~]+$/; +export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u; /** * Execute a global regex pattern against data, calling the callback for each match. diff --git a/test/working-directory-schema.test.ts b/test/working-directory-schema.test.ts new file mode 100644 index 00000000..52080c56 --- /dev/null +++ b/test/working-directory-schema.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { CreateSessionSchema, QuickRunSchema, ScheduledRunSchema } from '../src/web/schemas.js'; + +describe('working directory schemas', () => { + const unicodeWorkingDir = '/mnt/d/AI/中文项目'; + + it('accepts Unicode paths for session creation and run requests', () => { + expect(CreateSessionSchema.safeParse({ workingDir: unicodeWorkingDir, mode: 'codex' }).success).toBe(true); + expect(QuickRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test' }).success).toBe(true); + expect( + ScheduledRunSchema.safeParse({ workingDir: unicodeWorkingDir, prompt: 'test', durationMinutes: 10 }).success + ).toBe(true); + }); + + it('continues to reject shell metacharacters in Unicode paths', () => { + const unsafeWorkingDir = `${unicodeWorkingDir};rm -rf /`; + + expect(CreateSessionSchema.safeParse({ workingDir: unsafeWorkingDir, mode: 'codex' }).success).toBe(false); + expect(QuickRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test' }).success).toBe(false); + expect( + ScheduledRunSchema.safeParse({ workingDir: unsafeWorkingDir, prompt: 'test', durationMinutes: 10 }).success + ).toBe(false); + }); +});