diff --git a/.env.example b/.env.example index e6b3c4a..498d67c 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # NEVER put KWORK_TOKEN, KWORK_LOGIN, KWORK_PASSWORD, KWORK_PHONE_LAST or # KWORK_PROXY_URL in Codex/Claude MCP config, .env, argv, or a launcher command. # Run `uvx kwork-mcp@ login` once in a real TTY; it prompts privately -# and stores the verified token/optional proxy under 0600. +# and stores the verified token/optional proxy under 0600 (owner-only DACL and +# DPAPI on Windows). # The server serves the single account bound by login. Set the ID only when # several accounts are bound, to pick one of them. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..614b2c2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run in Git Bash on Windows too, which cannot run CRLF scripts. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff7725b..ca516e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run mypy + - run: uv run mypy --platform win32 tests: name: Tests / Python ${{ matrix.python-version }} @@ -69,6 +70,43 @@ jobs: --cov-report=term-missing --cov-report=xml + windows: + name: Windows / Python ${{ matrix.python-version }} + runs-on: windows-2025 + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.14"] + defaults: + run: + shell: bash + steps: + # Check out as on Linux; .gitattributes keeps shell scripts LF for everyone else. + - name: Keep LF line endings + run: git config --global core.autocrlf false + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install exact uv and Python + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.32" + checksum: "acfde570451cfdb8689fa159a138ee805ba4e241c466432750302c86254b0984" + python-version: ${{ matrix.python-version }} + enable-cache: true + cache-dependency-glob: uv.lock + - run: uv sync --locked --all-groups + # Coverage is gated on Linux, where the POSIX branches run. + - run: uv run pytest tests/ -v + - run: uv build --no-sources --wheel + - name: Smoke-test installed wheel + run: | + wheel="$(find dist -maxdepth 1 -name '*.whl' -print -quit)" + test -n "$wheel" + bash scripts/smoke-wheel.sh "$wheel" + package: name: Build and inspect distributions runs-on: ubuntu-24.04 diff --git a/AGENTS.md b/AGENTS.md index 21cd728..c92be02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,14 @@ Stdio MCP gateway for the Kwork freelance marketplace: 22 tools (18 typed reads + a durable `prepare → commit → reconcile` write protocol) on FastMCP 4 / MCP SDK 2 over the pinned -`kwork==0.2.0` client. Python 3.12–3.14, managed with `uv`. +`kwork==0.2.0` client. Python 3.12–3.14 on macOS, Linux and Windows, managed with `uv`. ## Commands - `uv run ruff check .` — lint - `uv run ruff format --check .` — format check - `uv run mypy` — strict type check +- `uv run mypy --platform win32` — the same check for the Windows branches - `uv run python -m pytest tests/ -q` — tests - `uv run python -m pytest tests/ -q --cov=kwork_mcp --cov-report=term-missing` — tests + coverage (gate: 92% branch) - `uv run kwork-mcp` — start the server (stdio); needs a bootstrapped account @@ -20,13 +21,16 @@ durable `prepare → commit → reconcile` write protocol) on FastMCP 4 / MCP SD ``` src/kwork_mcp/ __init__.py main(): bare command = stdio server (rejects secret env, no banner); args → terminal CLI - bootstrap.py terminal CLI: login (TTY auth → store, prints client commands), status, logout, write resolution + bootstrap.py terminal CLI: login (TTY auth → store, connects clients, prints commands), status, logout, write resolution + clients.py finds Claude Desktop / Cursor configs (incl. the MSIX Claude) and adds the server with a .bak config.py KworkConfig (pydantic-settings, KWORK_ prefix), bound-account selection, proxy validation server.py create_server(): FastMCP app, lifespan, SERVER_INSTRUCTIONS, unknown-tool guard session.py KworkSessionManager: lazy auth, account identity checks, call_read / call_write_step coordination.py CoordinationStore: shared SQLite — rate limits, circuits, cursors, write ledger, writer lock contracts.py pinned pykwork signatures and generic route params (enforce_route_params) - security.py SecureTokenStore, sanitize_external / redact_text, log redaction + security.py SecureTokenStore, state directory checks, sanitize_external / redact_text, log redaction + private_fs.py platform layer: POSIX modes or Windows owner/DACL checks, locks, DPAPI sealing + windows.py Win32 calls through ctypes (imported only on Windows) errors.py GatewayError taxonomy, classify_upstream_error models.py ResultEnvelope, records, write requests, WriteState middleware.py strict, sanitized tool-input validation @@ -52,6 +56,17 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor - **Secretless steady state.** The server accepts only safe env (`KWORK_PERSIST_TOKEN=true`, `KWORK_WRITES`, limits, `KWORK_STATE_DIR`, optional `KWORK_EXPECTED_USER_ID`). Login, password, token, phone and proxy go only through `kwork-mcp login` into the account store. +- **Platforms.** All OS differences go through `private_fs`. POSIX keeps state private with + 0700/0600 modes, an fd-anchored `O_NOFOLLOW` directory walk and `flock`. Windows has no mode + bits: state directories are created with an owner-only protected DACL (ancestors outside the + profile must not let others rename or re-permission them), token and lock files are checked + by owner and DACL through their handles and the SQLite ledger by path before each + connection, tokens are sealed with DPAPI bound to the account scope, and locks use + `msvcrt.locking`. The default state stays in the profile (`~/.local/state/kwork-mcp`), not + in AppData, which packaged (MSIX) clients see virtualized. + Write `if sys.platform == "win32": ... else: ...` rather than an early return, so that + `mypy --platform win32` sees no unreachable code; coverage skips the Windows branches and + `windows.py`, which run on the Windows CI job. - **Account selection.** Without `KWORK_EXPECTED_USER_ID` the server serves the single account that login stored a token for (`load_server_config`). With none or several it still starts without a session: every tool answers `auth_required` or `account_binding_required` @@ -123,8 +138,8 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor - NEVER perform a remote write outside the ledger, and never auto-retry a remote write. - ALWAYS use native Russian in user-facing strings; keep code comments in English (ruff RUF003 rejects Cyrillic look-alike letters in comments). -- ALWAYS run `uv run ruff check . && uv run mypy && uv run python -m pytest tests/ -q` before a - commit. +- ALWAYS run `uv run ruff check . && uv run mypy && uv run mypy --platform win32 && + uv run python -m pytest tests/ -q` before a commit. - Live checks against a real account are read-only unless the owner explicitly allows a write; anonymize any captured response before it becomes a fixture. diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a3609..413983c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ ## [Unreleased] +## [1.6.0] - 2026-10-04 + +### Added + +- Поддержка Windows 10 и 11 без WSL. Раньше `uvx kwork-mcp` на Windows падал при + импорте. Состояние по умолчанию хранится в домашнем каталоге, как на macOS и + Linux: `%USERPROFILE%\.local\state\kwork-mcp`. Права `0700`/`0600` на Windows + ничего не значат, поэтому вместо них проверяются владелец и DACL: каталоги + состояния создаются с доступом только для вашей учётной записи и SYSTEM, а файлы + состояния проверяются по владельцу и DACL перед каждым использованием. Токен + зашифрован DPAPI для текущего пользователя Windows и привязан к аккаунту: другой + пользователь его не прочитает, а файл, скопированный под имя другого аккаунта, не + откроется. + Межпроцессные блокировки на Windows идут через `msvcrt`. Свой `KWORK_STATE_DIR` + вне профиля принимается, только если другие пользователи не могут переименовать + каталоги над ним. На macOS и Linux поведение не изменилось. +- `login` на Windows предупреждает, если запущен от имени администратора: это может + быть другая учётная запись, и сервер в агенте не увидит вход. Если токен не + расшифровывается (сброс пароля Windows, перенос профиля), сервер отвечает + `auth_expired` и просит выполнить login заново. +- `kwork-mcp login` подключает Claude Desktop и Cursor сам: если они установлены, он + спрашивает и дописывает сервер в их конфиг (на Windows и в версию Claude из Microsoft + Store), не трогая остальные серверы и сохраняя прежний файл как `.bak`. Конфиг, который + не разбирается как обычный JSON, не меняется, а другой сервер с тем же именем + заменяется только после отдельного вопроса. Править JSON руками больше не нужно. +- `kwork-mcp login` на Windows печатает команды с кавычками для PowerShell и cmd, + путь к конфигу Cursor (`%USERPROFILE%\.cursor\mcp.json`) и подсказку открыть + конфиг Claude Desktop через Settings → Developer → Edit Config. Вывод в pipe или + файл и логи сервера на Windows идут в UTF-8, а не в кодировке ANSI, поэтому + русский текст не ломает команду и читается в логах клиента. +- CI проверяет типы Windows-веток (`mypy --platform win32`) и прогоняет тесты и + установку собранного пакета на Windows. + ### Fixed - Описание `KWORK_WRITES` в `server.json` для MCP Registry больше не упоминает окно @@ -313,7 +346,8 @@ - Legacy `KWORK_TOKEN_FILE`, implicit `.env` loading and in-process-only limiter. - Experimental MCP Tasks and out-of-scope pipeline/business integrations. -[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.5.2...HEAD +[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.6.0 [1.5.2]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.2 [1.5.1]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.1 [1.5.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.0 diff --git a/README.md b/README.md index 3043d73..7dc6923 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,22 @@ Code, Claude Desktop, Codex, Cursor и другим MCP-клиентам. Аге ## Быстрый старт -Нужны macOS или Linux, Python 3.12–3.14 и [uv](https://docs.astral.sh/uv/). -Windows не поддерживается. +Нужны macOS, Linux или Windows 10/11, Python 3.12–3.14 и [uv](https://docs.astral.sh/uv/). +На Windows uv ставится в PowerShell: + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Если агент работает внутри WSL, для kwork-mcp это Linux со своим домашним каталогом: +выполните `login` там же, где запускается агент. ### 1. Войдите в Kwork (один раз) -В обычном терминале: +В обычном терминале (на Windows в PowerShell или Windows Terminal): ```bash -uvx kwork-mcp@1.5.2 login +uvx kwork-mcp@1.6.0rc1 login ``` Команда скрыто спросит логин и пароль Kwork, а также, по желанию, последние 4 цифры @@ -48,39 +55,46 @@ uvx kwork-mcp@1.5.2 login Найден аккаунт Kwork: your_name (user_id=123456). Привязать его? [y/N]: да ``` -После подтверждения токен сохраняется в защищённое хранилище на вашем компьютере -(`~/.local/state/kwork-mcp`). Логин и пароль нигде не сохраняются. В конце команда -напечатает готовые команды подключения для Claude Code и Codex и блок для Claude -Desktop и Cursor. +После подтверждения токен сохраняется в защищённое хранилище на вашем компьютере: +`~/.local/state/kwork-mcp`, на Windows `%USERPROFILE%\.local\state\kwork-mcp`. Логин и +пароль нигде не сохраняются. + +Если на компьютере есть Claude Desktop или Cursor, команда спросит, подключить ли к ним +kwork-mcp, и сама допишет сервер в их конфиг. Остальные серверы в конфиге не меняются, +прежняя версия файла остаётся рядом как `.bak`. После этого перезапустите клиент. Для +Claude Code и Codex команда напечатает готовые команды подключения. ### 2. Подключите агента **Claude Code:** ```bash -claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2 +claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0rc1 ``` **Codex:** ```bash -codex mcp add kwork -- uvx kwork-mcp@1.5.2 +codex mcp add kwork -- uvx kwork-mcp@1.6.0rc1 ``` -**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjIiXX0%3D) +**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS42LjByYzEiXX0%3D) + +**Claude Desktop:** `login` подключает его сам, после этого перезапустите приложение.
-Claude Desktop и другие клиенты +Подключить вручную: Claude Desktop и другие клиенты Claude Desktop: Settings → Developer → Edit Config, файл `claude_desktop_config.json`. -Cursor без кнопки: `~/.cursor/mcp.json` или `.cursor/mcp.json` проекта. +Cursor без кнопки: `~/.cursor/mcp.json` (на Windows `%USERPROFILE%\.cursor\mcp.json`) +или `.cursor/mcp.json` проекта. ```json { "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.2"] + "args": ["kwork-mcp@1.6.0rc1"] } } } @@ -102,9 +116,9 @@ JSON. Перезапустите клиент и попросите агента: «проверь статус аккаунта Kwork». Он вызовет `account_status` и покажет ваш `user_id` и имя. -Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.2 status` покажет, с каким +Без клиента то же видно в терминале: `uvx kwork-mcp@1.6.0rc1 status` покажет, с каким аккаунтом и сайтом запустится сервер, не обращаясь к Kwork. Сменить аккаунт: -`uvx kwork-mcp@1.5.2 logout`, затем снова `login`. +`uvx kwork-mcp@1.6.0rc1 logout`, затем снова `login`. ## Отправка откликов и сообщений @@ -128,7 +142,7 @@ Codex то же включает `default_tools_approval_mode = "writes"` в н ```bash claude mcp remove kwork --scope user -claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.2 +claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.6.0rc1 ``` В режиме `auto` от чужих команд в текстах проектов и сообщений защищает только @@ -148,7 +162,7 @@ claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.2 `KWORK_SITE=com`. Например, для Claude Code вторым сервером рядом с kwork.ru: ```bash -claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.2 +claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.6.0rc1 ``` Аккаунт и токен у kwork.ru и kwork.com общие, поэтому заново входить не нужно. @@ -161,23 +175,23 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.2 | Что видите | Что делать | |---|---| -| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.5.2 login` и перезапустите клиент | -| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.5.2 logout ` | -| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.5.2 status` покажет это без запросов к Kwork | +| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.6.0rc1 login` и перезапустите клиент | +| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.6.0rc1 logout ` | +| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.6.0rc1 status` покажет это без запросов к Kwork | | Сервер не стартует, «некорректная конфигурация: …» | Проверьте названные переменные `KWORK_*` в конфиге клиента | | «KWORK_ENABLE_WRITES удалена в 1.5.0» | Замените её на `KWORK_WRITES=off`, `confirm` или `auto` | | `write_disabled` | Сервер запущен с `KWORK_WRITES=off`, доступно только чтение | | `captcha` | Войдите в Kwork в браузере, пройдите капчу, затем повторите `login` | -| Claude Desktop не видит сервер | Возьмите блок для Claude Desktop из вывода `login` (в нём полный путь к `uvx`) или укажите путь из `which uvx`, затем перезапустите приложение | +| Claude Desktop не видит сервер | Возьмите блок для Claude Desktop из вывода `login` (в нём полный путь к `uvx`) или укажите путь из `which uvx` (на Windows `where uvx`), затем перезапустите приложение | | `ambiguous_write` с `related_write_id` | Отправка с неизвестным результатом блокирует новые. Попросите агента выполнить `reconcile_write` для этого ID | | Сверка долго не сходится | Проверьте операцию на сайте Kwork, указанном в поле `site` у `pending-writes`, и зафиксируйте исход вручную (команды ниже) | Ручная фиксация исхода запускается с теми же `KWORK_*` переменными, что у сервера: ```bash -uvx kwork-mcp@1.5.2 pending-writes -uvx kwork-mcp@1.5.2 resolve-write succeeded # операция на Kwork прошла -uvx kwork-mcp@1.5.2 resolve-write absent # операции на Kwork нет +uvx kwork-mcp@1.6.0rc1 pending-writes +uvx kwork-mcp@1.6.0rc1 resolve-write succeeded # операция на Kwork прошла +uvx kwork-mcp@1.6.0rc1 resolve-write absent # операции на Kwork нет ``` ## Инструменты @@ -212,8 +226,10 @@ uvx kwork-mcp@1.5.2 resolve-write absent # операции на перед каждой записью. - Тексты проектов, профилей и сообщений помечаются как внешние данные, а не инструкции для агента. -- Токен лежит в файлах с правами `0600`. Приложение их не шифрует, поэтому - используйте шифрование диска. +- На macOS и Linux токен лежит в файлах с правами `0600`. Приложение его не + шифрует, поэтому используйте шифрование диска. +- На Windows каталог с токеном открыт только вашей учётной записи и SYSTEM, а сам + токен зашифрован DPAPI: прочитать его может только ваш пользователь Windows. - Лимиты запросов к Kwork общие для всех процессов одного аккаунта. Подробно: [модель безопасности](docs/security.md), [настройки](docs/configuration.md), @@ -226,7 +242,7 @@ git clone https://github.com/simonether/kwork-mcp.git cd kwork-mcp uv sync --locked --dev uv run ruff check . && uv run ruff format --check . -uv run mypy +uv run mypy && uv run mypy --platform win32 uv run pytest tests/ -q --cov=kwork_mcp ``` diff --git a/docs/architecture.md b/docs/architecture.md index 45f750a..cb6e632 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,9 @@ flowchart LR Fingerprint общей rate/circuit/write policy закрепляется в metadata: процесс с несовместимой конфигурацией того же state directory завершается fail-loud. - **Secure credential store** атомарно сохраняет account-scoped session token и - optional proxy URL под межпроцессным `flock`. + optional proxy URL под межпроцессным lock (`flock`, на Windows `msvcrt.locking`). +- **Platform layer** (`private_fs`, Win32 calls в `windows.py`) прячет различия ОС: + POSIX modes и `flock` либо Windows owner/DACL checks, DPAPI и `msvcrt` locks. - **Upstream adapter** фиксирует `kwork==0.2.0`, ограничивает generic route params и сохраняет error payloads, которые исходная библиотека могла потерять. @@ -105,7 +107,9 @@ State root открывается через проверенную FD-цепо по одному `readlink` hop с повторным обходом и loop limit, поэтому target не может скрыть writable intermediate alias. Missing tail создаётся и открывается только через `mkdirat/openat(O_NOFOLLOW)`; final directory обязан быть current-owned -exact `0700`. +exact `0700`. На Windows FD-цепочки нет: недостающие каталоги создаются с +owner-only protected DACL, а final directory и каждый state file проверяются по +owner и DACL (см. [security.md](security.md#windows)). ## Межпроцессная координация diff --git a/docs/configuration.md b/docs/configuration.md index 1233a98..3232961 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -98,7 +98,7 @@ Codex/Claude MCP configuration, даже если host помечает их к | Переменная | Default | Ограничения | |---|---:|---| | `KWORK_TIMEOUT` | `30` | 1–120 секунд | -| `KWORK_STATE_DIR` | XDG/`~/.local/state/kwork-mcp` | Только абсолютный путь | +| `KWORK_STATE_DIR` | XDG/`~/.local/state/kwork-mcp`; Windows: `%USERPROFILE%\.local\state\kwork-mcp` | Только абсолютный путь | | `KWORK_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | `KWORK_TOKEN_FILE` удалён и намеренно вызывает ошибку конфигурации. Один общий @@ -108,14 +108,16 @@ owner/mode/regular-file/no-symlink и обязательного `get_me`. Serve legacy-файл не читает и автоматически не импортирует. После validated import legacy source сохраняется; удалите его вручную только после проверки нового flow. -State directory должен принадлежать текущему OS user и иметь exact mode `0700`. +На Linux/macOS state directory должен принадлежать текущему OS user и иметь exact +mode `0700`. Проверяется вся physical ancestor chain: допустимы только root/current-owner каталоги без group/other write. Один sticky shared temp boundary разрешён, но обычный `0777` parent, вложенный shared boundary, чужой owner, final symlink и special bits отклоняются. Trusted non-final aliases раскрываются по одному hop с повторной проверкой, а missing components открываются через FD-relative `O_NOFOLLOW`, поэтому alias target/race не может скрыть writable ancestor. -`coordination.sqlite3`, credential/lock files должны иметь `0600`. Account record +`coordination.sqlite3`, credential/lock files должны иметь `0600` (Windows-правила +ниже). Account record содержит verified token и optional proxy URL. Legacy record без proxy означает direct connection. Record с `https` proxy или без явного port (такой proxy не работал и в 1.0.0rc1) при загрузке отклоняется как @@ -123,9 +125,26 @@ Legacy record без proxy означает direct connection. Record с `https` удалить proxy, остановите процессы account/state, повторите login и перезапустите MCP; уже открытый client record не перечитывает. Не размещайте общий state на NFS или другом filesystem без надёжных POSIX locks/SQLite semantics. -Production runtime использует `fcntl`/`flock` и поддерживает Linux/macOS, но не -Windows. ACL/MAC/mount policy не должны давать другим principals write вопреки -mode bits; при сомнении используйте отдельный local private volume. +На Linux/macOS runtime использует `fcntl`/`flock`. ACL/MAC/mount policy не должны +давать другим principals write вопреки mode bits; при сомнении используйте +отдельный local private volume. + +На Windows mode bits не действуют, и вместо них проверяются owner и DACL. +Default state directory — `%USERPROFILE%\.local\state\kwork-mcp`, `XDG_STATE_HOME` не учитывается. Не +`%LOCALAPPDATA%`: упакованные (MSIX) приложения вроде Claude Desktop видят свою +копию AppData, и сервер, запущенный клиентом, разошёлся бы с терминалом. +Gateway создаёт недостающие каталоги с доступом только для текущего user и +SYSTEM. Существующий каталог принимается, если его owner — текущий user, SYSTEM +или Administrators и DACL не пускает никого другого. Каталог, созданный вручную в +корне диска, обычно наследует `Authenticated Users: Modify` и отклоняется как +`state_directory_acl_not_private`: укажите несуществующий путь, gateway создаст его +сам. Для пути вне профиля пользователя каталоги выше не должны позволять другим +пользователям переименовать их (`state_directory_untrusted_writable_ancestor`): +папка в корне диска, созданная вручную, обычно позволяет. Сетевые пути, junction в +конце пути и FAT/exFAT не подходят. Token record на Windows дополнительно +зашифрован DPAPI текущего user; после сброса пароля Windows или переноса профиля +он не открывается, и сервер просит выполнить login заново (`auth_expired`). +Подробно — в [модели безопасности](security.md#windows). ## Rate limits и resilience @@ -246,10 +265,12 @@ Bootstrap prompts идут в stderr, success — один allowlisted JSON obje URL/authority и userinfo динамически добавляются в redaction set для логов и внешних payload; отдельные proxy user/host fragments — только от 8 символов (`MIN_DISTINCTIVE_SECRET_LENGTH`), чтобы короткое `user` не портило обычные данные. -Credential record защищён mode `0600`, но не application-level encryption: -используйте шифрование диска, private backups и отдельную OS account. +На Linux/macOS credential record защищён mode `0600`, но не application-level +encryption: используйте шифрование диска, private backups и отдельную OS account. +На Windows record зашифрован DPAPI и открывается только под тем же Windows user. Если filesystem error произошёл после atomic `os.replace`, но до подтверждённого -directory `fsync`, результат возвращается как `credential_update_unknown`: новый +directory `fsync` и повторной проверки файла (на Windows directory `fsync` нет), +результат возвращается как `credential_update_unknown`: новый record уже может быть видим. Не считайте это гарантированным сохранением старого token и сначала повторно проверьте store/bootstrap. diff --git a/docs/security.md b/docs/security.md index f25b23b..372e849 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,6 +37,58 @@ non-sticky `0777/child-0700` намеренно неприемлем. компрометация root/current OS user находятся вне гарантии. Для таких окружений нужен отдельный private local state volume и операционная проверка ACL/mount. +### Windows + +Mode bits на Windows не ограничивают доступ, а directory нельзя открыть как FD, +поэтому POSIX walk заменён проверкой owner и DACL (`private_fs`, Win32 calls в +`windows.py`). Trusted principals те же, что у OpenSSH for Windows для private +keys: текущий user, SYSTEM и Administrators (они и так могут забрать любой файл). + +- Каждый недостающий компонент пути создаётся `CreateDirectoryW` сразу с protected + DACL `D:P(A;OICI;FA;;;)(A;OICI;FA;;;SY)`, поэтому каталог ни на миг не + получает унаследованный, возможно общий, DACL родителя. +- Final directory не может быть symlink или junction (name-surrogate reparse + point). Его owner обязан быть trusted principal, а каждая allow entry DACL — для + trusted principal или OWNER RIGHTS (`S-1-3-4`, его даёт `os.mkdir(path, 0o700)` в + Python 3.12.4+; owner уже проверен). Inherit-only entries тоже учитываются: они + достались бы каждому новому файлу. Исключение — CREATOR OWNER, который + превращается в создателя файла. Deny entries доступ не расширяют. Отсутствующий + DACL означает полный доступ для всех и отклоняется. +- Token и lock files проверяются по owner и DACL через открытый handle + (`GetSecurityInfo`); token, кроме того, сверяется с файлом, прошедшим lstat (тот же + volume и file ID). `coordination.sqlite3`, как и на POSIX, проверяется по пути + перед каждым соединением. +- Каталоги выше state directory внутри профиля пользователя и над ним (`C:\`, + `C:\Users`) не проверяются: по умолчанию другие пользователи не могут их + переименовать или сменить им права. Для пути вне профиля каждый каталог выше, включая + корень диска, должен принадлежать user, SYSTEM, Administrators или + TrustedInstaller, и никто другой не может иметь на нём DELETE, WRITE_DAC, + WRITE_OWNER, FILE_DELETE_CHILD или GENERIC_ALL (DELETE на корне диска безвреден). + Иначе: `state_directory_untrusted_ancestor_owner` или + `state_directory_untrusted_writable_ancestor`. Это аналог POSIX-запрета + shared-writable ancestors: кто может переименовать родителя, тот может подменить + ledger между проверкой и открытием. +- Token record шифруется `CryptProtectData` текущего Windows user с entropy + `kwork-mcp token `. Другой user не расшифрует record и не подложит свой, + а файл, скопированный под имя другого аккаунта, не откроется. Record, который + DPAPI не открывает (другой user, сброс пароля Windows, перенос профиля), даёт + `auth_expired` с подсказкой выполнить login: новый вход перезаписывает его без + расшифровки. Ledger и lock files не шифруются: их защищает DACL. +- `login` в окне администратора предупреждает: повышение прав может означать другую + учётную запись Windows (другой administrator или скрытая учётная запись + Administrator Protection), и токен попал бы в её профиль под её ключ DPAPI. +- Межпроцессные locks — `msvcrt.locking` на первом байте lock file; Windows снимает + их при закрытии handle или завершении процесса. Directory `fsync` недоступен, + поэтому token record заменяется `MoveFileExW` с `MOVEFILE_WRITE_THROUGH`. Rename и + unlink до секунды повторяются при sharing violation (антивирус или индексатор + держат свежий файл); remote writes это не затрагивает. +- UNC, device (`\\?\`, `\\.\`) и drive-relative paths, alternate data streams, + reserved device names (`NUL`, `COM1`) и имена с точкой или пробелом в конце + отклоняются как `state_directory_path_invalid`. + +Вне гарантии на Windows: компрометация текущего user или administrator, сетевые +диски и FAT/exFAT (там нет DACL, такой state directory отклоняется). + ## Credentials и account identity - `.env` из cwd никогда не загружается. @@ -67,9 +119,12 @@ non-sticky `0777/child-0700` намеренно неприемлем. validation. - Credential record содержит подтверждённые `user_id`/`username`, token и optional proxy URL. Его dataclass repr скрывает credential fields. -- Token и lock открываются с `O_NOFOLLOW`, проверкой regular file, owner и mode. +- Token и lock открываются с `O_NOFOLLOW`, проверкой regular file, owner и mode. На + Windows `O_NOFOLLOW` нет: symlink на чужой файл не пройдёт проверку owner и DACL + через handle. - Запись record использует private tempfile, `fsync`, atomic `os.replace` и directory - `fsync` под межпроцессным `flock`. + `fsync` под межпроцессным `flock` (на Windows `msvcrt.locking`, без directory + `fsync`). - До replace сбой сохраняет старый record побайтно; после replace сбой durability классифицируется `credential_update_unknown`, потому что новый record уже может быть видим. @@ -81,7 +136,8 @@ non-sticky `0777/child-0700` намеренно неприемлем. Bootstrap сначала берёт account writer lock, затем credential flock. Он не может ротировать token/proxy посреди multi-step write; concurrent bootstrap получает typed `write_in_progress`. Legacy `~/.kwork_token` читается только после явного -согласия и проверок regular-file/current-owner/exact-0600/no-symlink/inode +согласия и проверок regular-file/current-owner/exact-0600 (на Windows owner и +DACL)/no-symlink/inode stability/size/UTF-8/single-line. Normal startup его никогда не импортирует. Закрытие bootstrap client имеет абсолютный пятисекундный deadline и bounded cancel-drain. После deadline account/token locks освобождаются; если store уже diff --git a/pyproject.toml b/pyproject.toml index 4d74874..d3def54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", @@ -103,6 +104,8 @@ required-version = ">=0.11.6" [tool.coverage.run] branch = true source = ["kwork_mcp"] +# The Win32 calls run only on the Windows CI job, which has no gate. +omit = ["*/kwork_mcp/windows.py"] [tool.coverage.report] fail_under = 92 @@ -112,6 +115,10 @@ exclude_also = [ "if TYPE_CHECKING:", "if __name__ == .__main__.:", "raise NotImplementedError", + 'if sys\.platform == "win32":', +] +partial_also = [ + 'if sys\.platform != "win32":', ] [tool.mypy] diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index 94b4c1d..0e6bfc6 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -14,6 +14,12 @@ cleanup() { } trap cleanup EXIT cd "$smoke_dir" +# Native Windows programs need a Windows path; Git Bash ships cygpath. +if command -v cygpath >/dev/null; then + state_dir="$(cygpath -m "$smoke_dir")/state" +else + state_dir="${smoke_dir}/state" +fi runner=(uv run --isolated --no-project --with "$wheel" --) @@ -24,7 +30,7 @@ import kwork_mcp from kwork_mcp.bootstrap import run_bootstrap_cli from kwork_mcp.server import create_server -assert kwork_mcp.__version__ == "1.5.2" +assert kwork_mcp.__version__ == "1.6.0rc1" assert callable(run_bootstrap_cli) create_server() scripts = { @@ -39,9 +45,10 @@ assert scripts == { PY "${runner[@]}" kwork-mcp-bootstrap --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp-bootstrap --version)" == "1.5.2" ]] +# Python on Windows ends printed lines with CRLF; the comparisons drop the CR. +[[ "$("${runner[@]}" kwork-mcp-bootstrap --version | tr -d '\r')" == "1.6.0rc1" ]] "${runner[@]}" kwork-mcp --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.2" ]] +[[ "$("${runner[@]}" kwork-mcp --version | tr -d '\r')" == "1.6.0rc1" ]] set +e "${runner[@]}" kwork-mcp login login.stdout 2>login.stderr @@ -52,16 +59,16 @@ set -e # status is offline: with no stored login it explains and creates nothing. set +e -KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" kwork-mcp status >status.stdout 2>status.stderr +KWORK_STATE_DIR="$state_dir" "${runner[@]}" kwork-mcp status >status.stdout 2>status.stderr status_status="$?" set -e [[ "$status_status" -eq 2 ]] -grep -q "kwork-mcp 1.5.2" status.stdout +grep -q "kwork-mcp 1.6.0rc1" status.stdout [[ ! -e "${smoke_dir}/state" ]] # Directory inspections start the bare server with no account: it must list the # tools, answer every call with auth_required and create no state. -KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" python -I - <<'PY' +KWORK_STATE_DIR="$state_dir" "${runner[@]}" python -I - <<'PY' import json import subprocess import sys @@ -78,7 +85,8 @@ server = subprocess.Popen( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - text=True, + # MCP speaks UTF-8; the locale default on Windows is the ANSI code page. + encoding="utf-8", ) assert server.stdin is not None and server.stdout is not None answers = {} diff --git a/server.json b/server.json index 28603af..910fa44 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.simonether/kwork-mcp", "title": "Kwork", "description": "Account-bound, typed and durable stdio MCP gateway for the Kwork freelance marketplace", - "version": "1.5.2", + "version": "1.6.0rc1", "websiteUrl": "https://github.com/simonether/kwork-mcp", "repository": { "url": "https://github.com/simonether/kwork-mcp", @@ -13,7 +13,7 @@ { "registryType": "pypi", "identifier": "kwork-mcp", - "version": "1.5.2", + "version": "1.6.0rc1", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/site/index.html b/site/index.html index 49cabad..b23cdaa 100644 --- a/site/index.html +++ b/site/index.html @@ -56,8 +56,8 @@ "url": "https://simonether.github.io/kwork-mcp/", "image": "https://simonether.github.io/kwork-mcp/og.png", "applicationCategory": "DeveloperApplication", - "operatingSystem": "macOS, Linux", - "softwareVersion": "1.5.2", + "operatingSystem": "macOS, Linux, Windows", + "softwareVersion": "1.6.0rc1", "softwareRequirements": "Python 3.12–3.14, uv", "programmingLanguage": "Python", "inLanguage": "ru", @@ -83,10 +83,10 @@ {"@type": "Question", "name": "Это официальный инструмент Kwork?", "acceptedAnswer": {"@type": "Answer", "text": "Нет. Это независимый проект с открытым кодом под лицензией MIT. Kwork его не выпускает и не поддерживает."}}, {"@type": "Question", "name": "Может ли агент откликнуться на проект без меня?", "acceptedAnswer": {"@type": "Answer", "text": "Только если вы сами это разрешили. По умолчанию агент показывает точный текст, цену и получателя и отправляет только после вашего «да». С KWORK_WRITES=auto он отправляет сам, с KWORK_WRITES=off только читает."}}, {"@type": "Question", "name": "Что будет, если связь оборвётся при отправке?", "acceptedAnswer": {"@type": "Answer", "text": "Сервер не повторит запись сам и не угадает исход. Новые отправки для аккаунта ждут, пока агент не сверит результат с Kwork или вы не разберёте запись вручную командой kwork-mcp resolve-write."}}, - {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен в ~/.local/state/kwork-mcp с правами 0600. Токен не шифруется, поэтому включите шифрование диска."}}, - {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.5.2 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, + {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен: на macOS и Linux в ~/.local/state/kwork-mcp с правами 0600, на Windows в %USERPROFILE%\\.local\\state\\kwork-mcp, зашифрованный DPAPI. На macOS и Linux токен не шифруется, поэтому включите шифрование диска."}}, + {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.6.0rc1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, {"@type": "Question", "name": "Какие клиенты поддерживаются?", "acceptedAnswer": {"@type": "Answer", "text": "Claude Code, Claude Desktop, Codex, Cursor и другие MCP-клиенты, которые умеют запускать локальный сервер через stdio."}}, - {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Нет, только macOS и Linux."}}, + {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Да, на Windows 10 и 11 без WSL. uv ставится одной командой в PowerShell, дальше всё так же, как на macOS и Linux."}}, {"@type": "Question", "name": "Сколько стоит?", "acceptedAnswer": {"@type": "Answer", "text": "Нисколько: kwork-mcp распространяется бесплатно под лицензией MIT."}} ] } @@ -136,10 +136,10 @@

MCP-сервер для Kwork

Установить
- uvx kwork-mcp@1.5.2 login + uvx kwork-mcp@1.6.0rc1 login
-

Бесплатно и с открытым кодом (MIT). macOS и Linux, Python 3.12–3.14. Версия 1.5.2 на PyPI.

+

Бесплатно и с открытым кодом (MIT). macOS, Linux и Windows, Python 3.12–3.14. Версия 1.6.0rc1 на PyPI.

@@ -237,7 +237,7 @@

Каждая отправка с вашего «да»

  • Пароль не попадает в конфиг

    -

    Логин и пароль вводятся один раз через скрытый ввод и нигде не сохраняются. Токен лежит на вашем компьютере в файлах с правами 0600.

    +

    Логин и пароль вводятся один раз через скрытый ввод и нигде не сохраняются. Токен лежит на вашем компьютере в файлах, открытых только вашей учётной записи.

  • Только ваш аккаунт

    @@ -256,17 +256,21 @@

    Чужой текст не команда

    Установка

    -

    Нужны macOS или Linux, Python 3.12–3.14 и uv. Windows не поддерживается.

    +

    Нужны macOS, Linux или Windows 10/11, Python 3.12–3.14 и uv. На Windows uv ставится в PowerShell:

    +
    +
    PowerShell
    +
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
    +
    1. Войдите в Kwork (один раз)

      -

      Запустите в обычном терминале:

      +

      Запустите в обычном терминале (на Windows в PowerShell):

      терминал
      -
      uvx kwork-mcp@1.5.2 login
      +
      uvx kwork-mcp@1.6.0rc1 login
      -

      Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp. В конце она напечатает готовые команды подключения, а для Claude Desktop — блок с полным путём к uvx.

      +

      Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp (на Windows %USERPROFILE%\.local\state\kwork-mcp). Если у вас есть Claude Desktop или Cursor, она предложит подключить kwork-mcp к ним сама, а для Claude Code и Codex напечатает готовые команды.

    2. Подключите агента

      @@ -276,31 +280,32 @@

      Подключите агента

      Claude Code

      терминал
      -
      claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2
      +
      claude mcp add kwork --scope user -- uvx kwork-mcp@1.6.0rc1

    Codex

    терминал
    -
    codex mcp add kwork -- uvx kwork-mcp@1.5.2
    +
    codex mcp add kwork -- uvx kwork-mcp@1.6.0rc1

    Cursor

    -

    Добавить в Cursor

    -

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json.

    +

    Добавить в Cursor

    +

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json (на Windows %USERPROFILE%\.cursor\mcp.json).

    Claude Desktop

    -

    Settings → Developer → Edit Config, файл claude_desktop_config.json.

    +

    login подключит Claude Desktop сам: ответьте «да» на его вопрос и перезапустите приложение. Остальные серверы в конфиге не меняются.

    +

    Вручную: Settings → Developer → Edit Config, файл claude_desktop_config.json.

    claude_desktop_config.json
    {
       "mcpServers": {
         "kwork": {
           "command": "uvx",
    -      "args": ["kwork-mcp@1.5.2"]
    +      "args": ["kwork-mcp@1.6.0rc1"]
         }
       }
     }
    @@ -312,7 +317,7 @@

    Claude Desktop

  • Проверьте

    -

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.5.2 status.

    +

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.6.0rc1 status.

    Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте в конфиг KWORK_WRITES=auto, для одного только чтения — KWORK_WRITES=off (в Claude Code: -e KWORK_WRITES=off). Подробности, решение проблем и ручная сверка отправок описаны в README.

  • @@ -408,11 +413,11 @@

    Вопросы

    Где хранятся логин и пароль? -

    Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен в ~/.local/state/kwork-mcp с правами 0600. Токен не шифруется, поэтому включите шифрование диска.

    +

    Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен: на macOS и Linux в ~/.local/state/kwork-mcp с правами 0600, на Windows в %USERPROFILE%\.local\state\kwork-mcp, зашифрованный DPAPI. На macOS и Linux токен не шифруется, поэтому включите шифрование диска.

    Как сменить аккаунт? -

    Выполните в терминале uvx kwork-mcp@1.5.2 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    +

    Выполните в терминале uvx kwork-mcp@1.6.0rc1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

    Какие клиенты поддерживаются? @@ -420,7 +425,7 @@

    Вопросы

    Работает ли на Windows? -

    Нет, только macOS и Linux.

    +

    Да, на Windows 10 и 11 без WSL. uv ставится одной командой в PowerShell, дальше всё так же, как на macOS и Linux.

    Сколько стоит? diff --git a/src/kwork_mcp/__init__.py b/src/kwork_mcp/__init__.py index 69ed99d..5864d9d 100644 --- a/src/kwork_mcp/__init__.py +++ b/src/kwork_mcp/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import sys from collections.abc import Sequence @@ -9,7 +10,23 @@ __all__ = ["__version__", "main"] +def use_utf8_streams() -> None: + """Write stdout and stderr as UTF-8. + + On Windows Python encodes redirected output (a pipe, a file, Git Bash) in + the ANSI code page, which may lack Cyrillic and is not what MCP clients + read. The console itself is UTF-8 already. The MCP channel wraps the + stdout buffer on its own and is not affected. + """ + + for stream in (sys.stdout, sys.stderr): + if isinstance(stream, io.TextIOWrapper): + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + + def main(argv: Sequence[str] | None = None) -> None: + if sys.platform == "win32": + use_utf8_streams() args = list(sys.argv[1:] if argv is None else argv) if args: # Arguments select the human terminal commands (login, pending-writes, diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py index fc11d11..2bbfe83 100644 --- a/src/kwork_mcp/bootstrap.py +++ b/src/kwork_mcp/bootstrap.py @@ -9,9 +9,10 @@ import os import shlex import shutil +import subprocess import sys import warnings -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Collection, Sequence from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -20,10 +21,12 @@ from kwork.schema.actor import Actor from pydantic import SecretStr, ValidationError, model_validator +from kwork_mcp import clients, private_fs from kwork_mcp.config import ( AccountSelectionError, KworkConfig, bound_account_ids, + client_default_state_dir, contains_unsafe_text_codepoint, load_server_config, secret_server_environment_present, @@ -61,8 +64,9 @@ login спрашивает логин, пароль, последние 4 цифры телефона и прокси скрытым вводом в настоящем терминале; через аргументы и .env они не принимаются. Если KWORK_EXPECTED_USER_ID не задан, login покажет найденный аккаунт и попросит -подтвердить привязку; если задан, аккаунт обязан с ним совпасть. В конце login -печатает команды, которые подключают сервер к Claude Code и Codex. +подтвердить привязку; если задан, аккаунт обязан с ним совпасть. Затем login +предлагает подключить сервер к найденным Claude Desktop и Cursor (дописывает их +конфиг, прежний сохраняет как .bak) и печатает команды для Claude Code и Codex. Если вход выполнен для одного аккаунта, сервер и команды выше работают с ним сами. Если аккаунтов несколько, укажите нужный в KWORK_EXPECTED_USER_ID или @@ -71,9 +75,9 @@ Для kwork.com задайте KWORK_SITE=com. Аккаунт и токен у kwork.ru и kwork.com общие, поэтому повторный вход при смене сайта не нужен. -Если существует legacy ~/.kwork_token с owner=current user и mode 0600, login -предложит проверить и импортировать его. Сервер legacy-файл никогда не -импортирует. +Если существует legacy ~/.kwork_token, доступный только вам (на macOS и Linux: +owner=current user и mode 0600), login предложит проверить и импортировать его. +Сервер legacy-файл никогда не импортирует. Отправка с неизвестным исходом (submission_unknown) блокирует новые отправки аккаунта, пока её не сверит reconcile_write. Если сверка не приходит к выводу, @@ -562,7 +566,7 @@ async def _run_write_admin( return 2 write_id, outcome = rest if not (stdin.isatty() and stderr.isatty()): - stderr.write("Ошибка: resolve-write требует настоящий интерактивный TTY.\n") + stderr.write(_not_a_terminal("Ошибка: resolve-write требует настоящий интерактивный TTY.")) return 2 record = await coordinator.get_write(write_id, scope=scope) if record is None: @@ -621,8 +625,62 @@ async def _run_write_admin( } -def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_retained: bool) -> str: - """What to run next, with every setting the server needs to find this account.""" +def _not_a_terminal(message: str) -> str: + if sys.platform == "win32": + # Git Bash in its own mintty window hands programs pipes, not a console. + message += " На Windows запустите команду в PowerShell или Windows Terminal." + return message + "\n" + + +def _warn_if_elevated(stderr: TextIO) -> None: + # An elevated window may run as another Windows account (another admin, or + # the hidden one of Administrator Protection): the token would land in its + # profile, sealed for it, and the client's server would not find it. + if sys.platform == "win32": + try: + elevated = private_fs.is_elevated() + except OSError: + elevated = False + if elevated: + stderr.write( + "Внимание: login запущен от имени администратора. Если это другая учётная запись Windows, " + "сервер в агенте не увидит вход. Надёжнее запустить login в обычном окне PowerShell.\n" + ) + + +def _command_line(args: list[str]) -> str: + """Quote a command for the user's shell: PowerShell or cmd on Windows, a POSIX shell elsewhere.""" + + if sys.platform == "win32": + line = subprocess.list2cmdline(args) + else: + line = shlex.join(args) + return line + + +def _client_config_locations() -> dict[str, str]: + if sys.platform == "win32": + cursor = r"%USERPROFILE%\.cursor\mcp.json" + else: + cursor = "~/.cursor/mcp.json" + # Settings → Developer → Edit Config finds the file, wherever this build keeps it. + return {"Claude Desktop": "claude_desktop_config.json (Settings → Developer → Edit Config)", "Cursor": cursor} + + +def _restart_hint(client: str) -> str: + if client == "Cursor": + hint = "Перезапустите Cursor, если он открыт." + elif sys.platform == "win32": + hint = "Перезапустите Claude Desktop: закройте его через значок в трее и откройте снова." + elif sys.platform == "darwin": + hint = "Перезапустите Claude Desktop: Cmd+Q и откройте снова." + else: + hint = "Перезапустите Claude Desktop." + return hint + + +def _server_environment(actor: Actor, config: KworkConfig) -> list[str]: + """Settings a client must pass for the server to find this account.""" try: several_accounts = len(bound_account_ids(config.state_dir)) > 1 @@ -634,31 +692,113 @@ def _connection_instructions(actor: Actor, config: KworkConfig, *, legacy_file_r environment.append(f"KWORK_EXPECTED_USER_ID={actor.id}") if config.site != "ru": environment.append(f"KWORK_SITE={config.site}") - # A client may start the server without XDG_STATE_HOME, so anything but - # the plain default location is passed explicitly. - if config.state_dir != Path.home() / ".local" / "state" / "kwork-mcp": + # A client starts the server with a trimmed environment, so anything but + # the location it finds on its own is passed explicitly. + if config.state_dir != client_default_state_dir(): environment.append(f"KWORK_STATE_DIR={config.state_dir}") - name = "kwork" if config.site == "ru" else f"kwork-{config.site}" + return environment + + +def _server_name(config: KworkConfig) -> str: + return "kwork" if config.site == "ru" else f"kwork-{config.site}" + + +def _client_entry(actor: Actor, config: KworkConfig) -> dict[str, Any]: + # Claude Desktop and Cursor start servers without the shell PATH, so they + # get the absolute uvx path of the terminal that ran login. + entry: dict[str, Any] = {"command": shutil.which("uvx") or "uvx", "args": [f"kwork-mcp@{__version__}"]} + environment = _server_environment(actor, config) + if environment: + entry["env"] = dict(item.split("=", 1) for item in environment) + return entry + + +def _failure_code(exc: BaseException) -> str: + # The class and the Windows error code are safe to show and tell the cause. + winerror = getattr(exc, "winerror", None) + return f"{type(exc).__name__}, WinError {winerror}" if winerror else type(exc).__name__ + + +def _connect_clients( + actor: Actor, + config: KworkConfig, + *, + stdin: TextIO, + stdout: TextIO, + stderr: TextIO, +) -> set[str]: + """Offer to add the server to each Claude Desktop or Cursor config found; return the clients set up.""" + + name = _server_name(config) + entry = _client_entry(actor, config) + connected: set[str] = set() + for client in clients.find_clients(): + location = _terminal_safe(str(client.path)) + try: + current = clients.existing_entry(client.path, name) + except clients.ClientConfigError: + stdout.write( + f"{client.name}: конфиг {location} не разобран как JSON и не изменён, добавьте блок ниже вручную.\n" + ) + continue + if current == entry: + connected.add(client.name) + stdout.write(f"{client.name}: kwork-mcp уже подключён.\n") + continue + if current is not None and not clients.is_kwork_mcp_entry(current): + question = f"В {client.name} уже есть другой сервер «{name}». Заменить его на kwork-mcp? [y/N]: " + else: + question = f"Подключить kwork-mcp к {client.name}? [y/N]: " + if _visible_prompt(question, stdin=stdin, stderr=stderr).casefold() not in _CONFIRMATIONS: + continue + try: + result = clients.register_server(client.path, name, entry) + except (clients.ClientConfigError, OSError) as exc: + reason = _failure_code(exc) + stdout.write(f"{client.name}: не удалось записать {location} ({reason}), добавьте блок ниже вручную.\n") + continue + connected.add(client.name) + stdout.write(f"{client.name}: kwork-mcp подключён. {_restart_hint(client.name)}\n") + if result.backup is not None: + stdout.write(f" Прежний конфиг сохранён в {_terminal_safe(str(result.backup))}\n") + return connected + + +def _account_connected(actor: Actor) -> str: + return f"Аккаунт {_terminal_safe(str(actor.username))} (user_id {actor.id}) подключён.\n" + + +def _connection_instructions( + actor: Actor, + config: KworkConfig, + *, + legacy_file_retained: bool, + connected: Collection[str] = (), +) -> str: + """What to run next, with every setting the server needs to find this account.""" + + environment = _server_environment(actor, config) + name = _server_name(config) claude = ["claude", "mcp", "add", name, "--scope", "user"] codex = ["codex", "mcp", "add", name] for item in environment: claude += ["-e", item] codex += ["--env", item] server = ["--", "uvx", f"kwork-mcp@{__version__}"] - # Claude Desktop starts servers without the shell PATH, so it gets the - # absolute uvx path of the terminal that ran login. - desktop: dict[str, Any] = {"command": shutil.which("uvx") or "uvx", "args": [f"kwork-mcp@{__version__}"]} - if environment: - desktop["env"] = dict(item.split("=", 1) for item in environment) lines = [ - f"Аккаунт {_terminal_safe(str(actor.username))} (user_id {actor.id}) подключён.", "", - "Добавьте kwork-mcp в агента. Claude Code:", - " " + _terminal_safe(shlex.join(claude + server)), + ("Другие агенты. Claude Code:" if connected else "Добавьте kwork-mcp в агента. Claude Code:"), + " " + _terminal_safe(_command_line(claude + server)), "Codex:", - " " + _terminal_safe(shlex.join(codex + server)), - 'Claude Desktop и Cursor: в "mcpServers" файла claude_desktop_config.json или ~/.cursor/mcp.json', - " " + _terminal_safe(f'"{name}": ' + json.dumps(desktop, ensure_ascii=False)), + " " + _terminal_safe(_command_line(codex + server)), + ] + locations = {client: where for client, where in _client_config_locations().items() if client not in connected} + if locations: + lines += [ + f'{" и ".join(locations)}: в "mcpServers" файла {" или ".join(locations.values())}', + " " + _terminal_safe(f'"{name}": ' + json.dumps(_client_entry(actor, config), ensure_ascii=False)), + ] + lines += [ "", "Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте " "KWORK_WRITES=auto, для одного только чтения KWORK_WRITES=off.", @@ -697,7 +837,7 @@ async def _run_logout( return 2 try: if not (stdin.isatty() and stderr.isatty()): - stderr.write("Ошибка: logout запускается только в интерактивном терминале (TTY).\n") + stderr.write(_not_a_terminal("Ошибка: logout запускается только в интерактивном терминале (TTY).")) return 2 config = _base_bootstrap_config() account_id = account_arg if account_arg is not None else _selected_account(config) @@ -856,8 +996,9 @@ async def run_bootstrap_cli( stderr.write("Ошибка: не удалось проверить, что login запущен в интерактивном терминале (TTY).\n") return 2 if not interactive: - stderr.write("Ошибка: login запускается только в интерактивном терминале (TTY).\n") + stderr.write(_not_a_terminal("Ошибка: login запускается только в интерактивном терминале (TTY).")) return 2 + _warn_if_elevated(stderr) try: base = _base_bootstrap_config() @@ -963,7 +1104,9 @@ async def run_bootstrap_cli( token_store=store, client_factory=client_factory, ) - stdout.write(_connection_instructions(actor, config, legacy_file_retained=used_legacy)) + stdout.write(_account_connected(actor)) + connected = _connect_clients(actor, config, stdin=stdin, stdout=stdout, stderr=stderr) + stdout.write(_connection_instructions(actor, config, legacy_file_retained=used_legacy, connected=connected)) return 0 except (EOFError, KeyboardInterrupt, getpass.GetPassWarning): stderr.write("Вход отменён: сохранённый токен не изменён.\n") @@ -986,6 +1129,10 @@ async def run_bootstrap_cli( def main(argv: Sequence[str] | None = None) -> None: + if sys.platform == "win32": + from kwork_mcp import use_utf8_streams + + use_utf8_streams() args = list(sys.argv[1:] if argv is None else argv) code = asyncio.run( run_bootstrap_cli( diff --git a/src/kwork_mcp/clients.py b/src/kwork_mcp/clients.py new file mode 100644 index 0000000..f62a711 --- /dev/null +++ b/src/kwork_mcp/clients.py @@ -0,0 +1,161 @@ +"""Find Claude Desktop and Cursor configs and add the kwork-mcp server to them. + +`kwork-mcp login` offers this so nobody has to edit JSON by hand. A config is +touched only after the user agrees, only if it parses as plain JSON, and the +previous version is kept next to it as `.bak`. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from kwork_mcp import private_fs + + +class ClientConfigError(Exception): + """The client config cannot be read as plain JSON; it was not changed.""" + + +@dataclass(frozen=True, slots=True) +class ClientConfig: + name: str + path: Path + + +@dataclass(frozen=True, slots=True) +class Registration: + outcome: Literal["added", "updated", "unchanged"] + backup: Path | None + + +def _claude_desktop_dir() -> Path: + if sys.platform == "win32": + # The Microsoft Store (MSIX) build sees its own copy of AppData; a + # config in the real AppData would be ignored. + local = os.environ.get("LOCALAPPDATA") + packages = (Path(local) if local else Path.home() / "AppData" / "Local") / "Packages" + for package in sorted(packages.glob("Claude_*")): + packaged = package / "LocalCache" / "Roaming" / "Claude" + if packaged.is_dir(): + return packaged + roaming = os.environ.get("APPDATA") + directory = (Path(roaming) if roaming else Path.home() / "AppData" / "Roaming") / "Claude" + elif sys.platform == "darwin": + directory = Path.home() / "Library" / "Application Support" / "Claude" + else: + # No official Linux build; community builds follow XDG. + xdg = os.environ.get("XDG_CONFIG_HOME") + directory = (Path(xdg) if xdg else Path.home() / ".config") / "Claude" + return directory + + +def find_clients() -> list[ClientConfig]: + """The clients on this computer that have been started at least once.""" + + found = [] + claude = _claude_desktop_dir() + if claude.is_dir(): + found.append(ClientConfig("Claude Desktop", claude / "claude_desktop_config.json")) + cursor = Path.home() / ".cursor" + if cursor.is_dir(): + found.append(ClientConfig("Cursor", cursor / "mcp.json")) + return found + + +def is_kwork_mcp_entry(entry: object) -> bool: + """Whether a config entry starts kwork-mcp, in any version or from source.""" + + if not isinstance(entry, dict): + return False + words = [entry.get("command"), *(entry.get("args") or [])] + return any( + isinstance(word, str) and (word == "kwork-mcp" or word.startswith(("kwork-mcp@", "kwork-mcp=="))) + for word in words + ) + + +def _read(path: Path) -> tuple[dict[str, Any], dict[str, Any]] | None: + """The config and its mcpServers, or None when the file does not exist.""" + + try: + text = path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return None + except (OSError, UnicodeError) as exc: + raise ClientConfigError(type(exc).__name__) from exc + try: + config = json.loads(text) if text.strip() else {} + except json.JSONDecodeError as exc: + raise ClientConfigError("invalid_json") from exc + if not isinstance(config, dict): + raise ClientConfigError("not_an_object") + servers = config.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + raise ClientConfigError("servers_not_an_object") + return config, servers + + +def existing_entry(path: Path, name: str) -> object: + """The entry `name` in the config, or None; raises ClientConfigError like `register_server`.""" + + read = _read(path) + return None if read is None else read[1].get(name) + + +def register_server(path: Path, name: str, entry: dict[str, Any]) -> Registration: + """Set mcpServers[name] to `entry`, keeping everything else in the config.""" + + read = _read(path) + if read is None: + servers: dict[str, Any] = {} + config: dict[str, Any] = {"mcpServers": servers} + else: + config, servers = read + previous = servers.get(name) + if previous == entry: + return Registration("unchanged", None) + servers[name] = entry + payload = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode() + + backup = None + if read is not None: + backup = path.with_name(path.name + ".bak") + # copy2 keeps the mode: the config may hold other servers' secrets. + shutil.copy2(path, backup) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + if read is not None: + shutil.copymode(path, temp_name) + try: + private_fs.replace(temp_name, path) + except PermissionError: + if read is None: + raise + # An antivirus scan or the client itself may hold the file so that + # it cannot be replaced; it can still be rewritten, and the backup + # above keeps the previous version. + _overwrite(path, payload) + Path(temp_name).unlink(missing_ok=True) + except BaseException: + Path(temp_name).unlink(missing_ok=True) + raise + return Registration("updated" if previous is not None else "added", backup) + + +def _overwrite(path: Path, payload: bytes) -> None: + with path.open("r+b") as handle: + handle.write(payload) + handle.truncate() + handle.flush() + os.fsync(handle.fileno()) diff --git a/src/kwork_mcp/config.py b/src/kwork_mcp/config.py index 0b39591..4490a54 100644 --- a/src/kwork_mcp/config.py +++ b/src/kwork_mcp/config.py @@ -9,6 +9,7 @@ import hashlib import os import re +import sys import unicodedata from collections.abc import Mapping from pathlib import Path @@ -25,9 +26,21 @@ def _default_state_dir() -> Path: - base = os.environ.get("XDG_STATE_HOME") - if base: - return Path(base).expanduser() / "kwork-mcp" + # Windows keeps it in the profile as well, not in AppData: packaged (MSIX) + # apps such as Claude Desktop see their own copy of AppData, so the server + # they start and the terminal would keep separate state. + xdg = os.environ.get("XDG_STATE_HOME") if sys.platform != "win32" else None + base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "state" + return base / "kwork-mcp" + + +def client_default_state_dir() -> Path: + """The state directory of a server that an MCP client starts without KWORK_STATE_DIR. + + Clients pass servers a trimmed environment without XDG_STATE_HOME; the + home directory (USERPROFILE on Windows) is always there. + """ + return Path.home() / ".local" / "state" / "kwork-mcp" diff --git a/src/kwork_mcp/coordination.py b/src/kwork_mcp/coordination.py index 3dd7862..9bf1724 100644 --- a/src/kwork_mcp/coordination.py +++ b/src/kwork_mcp/coordination.py @@ -4,7 +4,6 @@ import asyncio import base64 -import fcntl import hashlib import hmac import json @@ -22,6 +21,7 @@ from pathlib import Path from typing import Any, TypeVar +from kwork_mcp import private_fs from kwork_mcp.config import KworkConfig from kwork_mcp.errors import AmbiguousWriteError, ContractDriftError, GatewayError from kwork_mcp.models import ErrorCode, ErrorInfo, WriteAction, WriteState @@ -118,15 +118,16 @@ def _initialize(self) -> None: pass else: try: - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) finally: os.close(fd) info = self._path.lstat() - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + if private_fs.is_link(info) or not stat.S_ISREG(info.st_mode): raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): + problem = private_fs.file_problem(info, path=self._path) + if problem == "wrong_owner": raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: + if problem is not None: raise GatewayError(ErrorCode.VALIDATION, diagnostic="coordination_db_permissions") with closing(self._connect()) as conn: @@ -298,15 +299,14 @@ def _initialize(self) -> None: str(prepared_row["write_id"]), ), ) - os.chmod(self._path, 0o600) + private_fs.restrict_path(self._path) def _connect(self) -> sqlite3.Connection: info = self._path.lstat() if ( - stat.S_ISLNK(info.st_mode) + private_fs.is_link(info) or not stat.S_ISREG(info.st_mode) - or stat.S_IMODE(info.st_mode) != 0o600 - or (hasattr(os, "geteuid") and info.st_uid != os.geteuid()) + or private_fs.file_problem(info, path=self._path) is not None ): raise GatewayError( ErrorCode.VALIDATION, @@ -337,20 +337,21 @@ def _acquire_writer_lock(self, scope: str) -> int: fd = -1 try: fd = os.open(path, flags, 0o600) - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): raise GatewayError( ErrorCode.VALIDATION, diagnostic="writer_lock_not_regular", ) - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): + problem = private_fs.file_problem(info, fd=fd) + if problem is not None: raise GatewayError( ErrorCode.VALIDATION, - diagnostic="writer_lock_wrong_owner", + diagnostic=f"writer_lock_{problem}", ) try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + private_fs.try_lock(fd) except BlockingIOError as exc: raise GatewayError( ErrorCode.WRITE_IN_PROGRESS, @@ -368,7 +369,7 @@ def _acquire_writer_lock(self, scope: str) -> int: @staticmethod def _release_writer_lock(fd: int) -> None: try: - fcntl.flock(fd, fcntl.LOCK_UN) + private_fs.unlock(fd) finally: os.close(fd) diff --git a/src/kwork_mcp/private_fs.py b/src/kwork_mcp/private_fs.py new file mode 100644 index 0000000..bccab87 --- /dev/null +++ b/src/kwork_mcp/private_fs.py @@ -0,0 +1,347 @@ +"""Owner-only state files and interprocess locks on POSIX and Windows. + +POSIX keeps the state private with modes: 0700 directories and 0600 files +owned by the effective uid. Windows mode bits mean nothing, so there the owner +and the DACL are checked instead, new state directories get an owner-only +protected DACL, and stored tokens are sealed with DPAPI: another Windows user +can neither read a token nor plant one. +""" + +from __future__ import annotations + +import errno +import os +import stat +import sys +import time +from collections.abc import Callable, Sequence +from pathlib import Path, PureWindowsPath + +if sys.platform == "win32": + import msvcrt + + from kwork_mcp import windows +else: + import fcntl + +SYSTEM_SID = "S-1-5-18" +ADMINISTRATORS_SID = "S-1-5-32-544" +# OWNER RIGHTS: whoever currently owns the object. os.mkdir(path, 0o700) grants it +# on Windows since Python 3.12.4. +OWNER_RIGHTS_SID = "S-1-3-4" +# CREATOR OWNER: replaced by the creator in what a directory passes on. +CREATOR_OWNER_SID = "S-1-3-0" +# NT SERVICE\TrustedInstaller owns the system drive root. +TRUSTED_INSTALLER_SID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" +# Rights that let their holder rename, replace or re-permission a directory. +_DELETE = 0x00010000 +_WRITE_DAC = 0x00040000 +_WRITE_OWNER = 0x00080000 +_FILE_DELETE_CHILD = 0x00000040 +_GENERIC_ALL = 0x10000000 +# Reparse tags with this bit redirect to another name: symlinks, junctions. +_NAME_SURROGATE_TAG = 0x20000000 +_SHARING_VIOLATION_WAIT_SECONDS = 1.0 +_SHARING_VIOLATION_ERRORS = frozenset({5, 32, 33}) +_RESERVED_DEVICE_NAMES = frozenset( + {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} + | {f"COM{number}" for number in range(1, 10)} + | {f"LPT{number}" for number in range(1, 10)} +) + + +AccessEntries = Sequence[tuple[str, str | None, int, bool]] + + +def windows_acl_problem( + owner: str | None, + entries: AccessEntries | None, + *, + user_sid: str, + directory: bool = False, +) -> str | None: + """Why a Windows state file or directory is not private to `user_sid`, or None. + + Like OpenSSH for Windows with private keys, it trusts the user, SYSTEM and + Administrators, who can take any file anyway; OWNER RIGHTS is trusted too, + because the owner must be one of them. `entries` are the DACL entries as + (kind, sid, mask, inherit_only); a missing DACL grants everyone access. A + directory must not pass anyone else on to the files created in it either, + so its inherit-only entries count too. + """ + + trusted = {user_sid, SYSTEM_SID, ADMINISTRATORS_SID} + if owner not in trusted: + return "wrong_owner" + if entries is None: + return "acl_not_private" + allowed = trusted | {OWNER_RIGHTS_SID} + if directory: + allowed.add(CREATOR_OWNER_SID) + for kind, sid, mask, inherit_only in entries: + if (inherit_only and not directory) or kind == "deny": + continue + if kind == "allow" and (mask == 0 or sid in allowed): + continue + return "acl_not_private" + return None + + +def windows_ancestor_problem( + owner: str | None, + entries: AccessEntries | None, + *, + user_sid: str, + root: bool, +) -> str | None: + """Why someone else could swap a directory above the state directory, or None. + + The Windows counterpart of rejecting group- or world-writable POSIX + ancestors. The owner can rewrite the DACL, so it must be trusted, and no one + else may hold DELETE, WRITE_DAC, WRITE_OWNER, FILE_DELETE_CHILD or + GENERIC_ALL. DELETE on a drive root is harmless: a root cannot be renamed. + """ + + trusted = {user_sid, SYSTEM_SID, ADMINISTRATORS_SID, TRUSTED_INSTALLER_SID} + if owner not in trusted: + return "untrusted_ancestor_owner" + if entries is None: + return "untrusted_writable_ancestor" + dangerous = _WRITE_DAC | _WRITE_OWNER | _FILE_DELETE_CHILD | _GENERIC_ALL | (0 if root else _DELETE) + allowed = trusted | {OWNER_RIGHTS_SID} + for kind, sid, mask, inherit_only in entries: + if inherit_only or kind == "deny": + continue + if kind == "allow" and (not mask & dangerous or sid in allowed): + continue + return "untrusted_writable_ancestor" + return None + + +def windows_path_is_ambiguous(path: PureWindowsPath) -> bool: + """Reject Windows path syntax that may name something other than it reads. + + Only `X:\\...` paths pass: UNC and device paths, drive-relative paths, + alternate data streams, reserved device names and names that Windows trims + (trailing dot or space) are refused. + """ + + drive = path.drive + if len(drive) != 2 or drive[1] != ":" or not drive[0].isascii() or not drive[0].isalpha() or not path.root: + return True + for part in path.parts[1:]: + device = part.split(".", 1)[0].rstrip(" ").upper() + if ":" in part or part.endswith((".", " ")) or device in _RESERVED_DEVICE_NAMES: + return True + return False + + +def try_lock(fd: int) -> None: + """Take an exclusive lock on an open file or raise BlockingIOError at once. + + The lock belongs to the open file: closing it or the death of the process + releases it, so a crash never leaves a stale lock behind. + """ + + if sys.platform == "win32": + # msvcrt locks bytes from the current position; lock files stay empty. + os.lseek(fd, 0, os.SEEK_SET) + try: + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + except OSError as exc: + if exc.errno in {errno.EACCES, errno.EDEADLK}: + raise BlockingIOError(errno.EAGAIN, "the lock is held") from exc + raise + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def unlock(fd: int) -> None: + if sys.platform == "win32": + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(fd, fcntl.LOCK_UN) + + +def restrict_file(fd: int) -> None: + """Make an open state file owner-only. + + POSIX sets 0600, which the umask may have narrowed or a stale file may + lack. On Windows the file inherits the owner-only DACL of the state + directory and is checked through its handle instead. + """ + + if sys.platform != "win32": + os.fchmod(fd, 0o600) + + +def restrict_path(path: Path) -> None: + if sys.platform != "win32": + os.chmod(path, 0o600) + + +def is_link(info: os.stat_result) -> bool: + """A symlink or, on Windows, any reparse point that redirects, such as a junction.""" + + if sys.platform == "win32": + reparse = info.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT + redirects = bool(reparse and info.st_reparse_tag & _NAME_SURROGATE_TAG) + else: + redirects = False + return redirects or stat.S_ISLNK(info.st_mode) + + +def file_problem(info: os.stat_result, *, fd: int | None = None, path: Path | None = None) -> str | None: + """Why a state file is not private to the current user, or None. + + POSIX reads the owner and mode from `info`: `wrong_owner` or + `permissions_must_be_0600`. Windows reads the owner and DACL of the open + file `fd`, or of `path` when nothing is open: `wrong_owner`, + `acl_not_private` or `acl_unreadable`. + """ + + if sys.platform == "win32": + problem = _windows_problem(fd=fd, path=path) + elif info.st_uid != os.geteuid(): + problem = "wrong_owner" + elif stat.S_IMODE(info.st_mode) != 0o600: + problem = "permissions_must_be_0600" + else: + problem = None + return problem + + +def directory_problem(path: Path) -> str | None: # pragma: no cover - Windows only + """Why a Windows state directory is not private, as `file_problem` reports it. + + POSIX checks directories during the fd-anchored walk in `security`. + """ + + return _windows_problem(path=path, directory=True) + + +def ancestor_problem(path: Path, *, root: bool) -> str | None: # pragma: no cover - Windows only + """Why someone else could swap the Windows directory `path`, or None.""" + + if sys.platform == "win32": + try: + owner, entries = windows.path_security(path) + return windows_ancestor_problem(owner, entries, user_sid=windows.current_user_sid(), root=root) + except OSError: + return "acl_unreadable" + else: + raise NotImplementedError + + +def _windows_problem( # pragma: no cover - Windows only + *, + fd: int | None = None, + path: Path | None = None, + directory: bool = False, +) -> str | None: + if sys.platform == "win32": + try: + if fd is not None: + owner, entries = windows.handle_security(fd) + elif path is not None: + owner, entries = windows.path_security(path) + else: + raise ValueError("fd or path is required") + return windows_acl_problem(owner, entries, user_sid=windows.current_user_sid(), directory=directory) + except OSError: + return "acl_unreadable" + else: + raise NotImplementedError + + +def is_elevated() -> bool: + """Whether a Windows process runs with an administrator token; always False on POSIX.""" + + if sys.platform == "win32": + elevated = windows.is_elevated() + else: + elevated = False + return elevated + + +def create_private_directory(path: Path) -> None: # pragma: no cover - Windows only + """Create a Windows directory open only to the current user and SYSTEM.""" + + if sys.platform == "win32": + windows.create_private_directory(path) + else: + raise NotImplementedError + + +def fsync_directory(path: Path) -> None: + """Make a rename or unlink in `path` durable. + + Windows cannot open a directory as a file; NTFS journals the change itself. + """ + + if sys.platform != "win32": + directory_fd = os.open(path, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def replace(source: str, target: Path) -> None: + """os.replace that waits out a brief Windows sharing violation. + + Windows also writes the rename through to disk, since it has no directory + fsync to follow it. + """ + + if sys.platform == "win32": + _wait_out_sharing_violation(lambda: windows.replace(source, target)) + else: + _wait_out_sharing_violation(lambda: os.replace(source, target)) + + +def unlink(path: Path) -> None: + _wait_out_sharing_violation(path.unlink) + + +def _wait_out_sharing_violation(operation: Callable[[], None]) -> None: + # An antivirus scanner or the search indexer may hold a fresh file open + # for a moment on Windows; POSIX renames and unlinks never wait. + if sys.platform == "win32": + deadline = time.monotonic() + _SHARING_VIOLATION_WAIT_SECONDS + delay = 0.01 + while True: + try: + operation() + return + except PermissionError as exc: + if exc.winerror not in _SHARING_VIOLATION_ERRORS or time.monotonic() >= deadline: + raise + time.sleep(delay) + delay = min(delay * 2, 0.2) + else: + operation() + + +def seal(data: bytes, *, purpose: str) -> bytes: + """Bind a stored secret to the current OS user. + + Windows encrypts it with DPAPI, keyed to the user and to `purpose`, so no + other user can read it and a file copied from another account or user + fails to open. POSIX stores it as is under mode 0600. + """ + + if sys.platform == "win32": + sealed = windows.protect(data, entropy=purpose.encode()) + else: + sealed = data + return sealed + + +def unseal(data: bytes, *, purpose: str) -> bytes: + if sys.platform == "win32": + opened = windows.unprotect(data, entropy=purpose.encode()) + else: + opened = data + return opened diff --git a/src/kwork_mcp/security.py b/src/kwork_mcp/security.py index 83e6faa..8e6e08f 100644 --- a/src/kwork_mcp/security.py +++ b/src/kwork_mcp/security.py @@ -4,7 +4,6 @@ import asyncio import contextlib -import fcntl import json import math import os @@ -18,12 +17,13 @@ from contextlib import asynccontextmanager from dataclasses import asdict, dataclass, field, is_dataclass from datetime import UTC, datetime -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any, cast from loguru import logger from pydantic import BaseModel, JsonValue +from kwork_mcp import private_fs from kwork_mcp.config import ( KworkConfig, canonicalize_percent_escape_case, @@ -271,6 +271,82 @@ def validate_lexical(candidate: Path, *, user_supplied: bool) -> None: def ensure_secure_directory(path: Path) -> Path: + """Create a private state directory, or check an existing one, and return its path.""" + + if sys.platform == "win32": + directory = _ensure_secure_windows_directory(path) + else: + directory = _ensure_secure_posix_directory(path) + return directory + + +def _ensure_secure_windows_directory(path: Path) -> Path: # pragma: no cover - Windows only + """Create missing components owner-only and check the final directory. + + Windows cannot open a directory as a file descriptor, so the POSIX + fd-anchored walk has no counterpart. Every component this creates gets an + owner-only protected DACL; the final directory must not be a junction or + symlink, and its owner and DACL must be private. Directories above it + outside the user's profile must not let anyone else rename or + re-permission them, as POSIX rejects shared-writable ancestors; Windows + itself keeps the profile and the directories above it closed. + """ + + raw = os.fspath(path) + if ( + not path.is_absolute() + or "\x00" in raw + or contains_unsafe_text_codepoint(raw) + or ".." in path.parts + or private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) + ): + raise _security_error("state_directory_path_invalid") + profile = _windows_profile() + + def check_ancestor(directory: Path, *, root: bool) -> None: + if profile is not None and ( + directory == profile or profile in directory.parents or directory in profile.parents + ): + return + problem = private_fs.ancestor_problem(directory, root=root) + if problem is not None: + raise _security_error(f"state_directory_{problem}") + + current = Path(path.anchor) + check_ancestor(current, root=True) + components = path.parts[1:] + for index, component in enumerate(components): + current = current / component + final = index == len(components) - 1 + try: + try: + info = current.lstat() if final else current.stat() + except FileNotFoundError: + with contextlib.suppress(FileExistsError): + private_fs.create_private_directory(current) + info = current.lstat() if final else current.stat() + except OSError as exc: + raise _security_error(f"state_directory_chain_error:{type(exc).__name__}") from exc + if final and private_fs.is_link(info): + raise _security_error("state_directory_final_symlink") + if not stat.S_ISDIR(info.st_mode): + raise _security_error("state_directory_component_not_directory") + if not final: + check_ancestor(current, root=False) + problem = private_fs.directory_problem(path) + if problem is not None: + raise _security_error(f"state_directory_{problem}") + return path + + +def _windows_profile() -> Path | None: # pragma: no cover - Windows only + try: + return Path.home() + except RuntimeError: + return None + + +def _ensure_secure_posix_directory(path: Path) -> Path: """Create a private directory through a trusted, fd-anchored ancestor chain.""" canonical = _resolve_trusted_state_aliases(path) @@ -349,12 +425,11 @@ def _validate_private_file(path: Path, *, allow_missing: bool = True) -> os.stat raise except OSError as exc: raise _security_error(f"private_file_error:{type(exc).__name__}") from exc - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + if private_fs.is_link(info) or not stat.S_ISREG(info.st_mode): raise _security_error("private_file_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): - raise _security_error("private_file_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: - raise _security_error("private_file_permissions_must_be_0600") + problem = private_fs.file_problem(info, path=path) + if problem is not None: + raise _security_error(f"private_file_{problem}") return info @@ -366,7 +441,7 @@ def read_private_secret_file(path: Path, *, max_bytes: int = 4096) -> str | None return None if info.st_size > max_bytes: raise _security_error("private_secret_file_too_large") - flags = os.O_RDONLY + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = -1 @@ -375,8 +450,7 @@ def read_private_secret_file(path: Path, *, max_bytes: int = 4096) -> str | None opened = os.fstat(fd) if ( not stat.S_ISREG(opened.st_mode) - or stat.S_IMODE(opened.st_mode) != 0o600 - or (hasattr(os, "geteuid") and opened.st_uid != os.geteuid()) + or private_fs.file_problem(opened, fd=fd) is not None or opened.st_dev != info.st_dev or opened.st_ino != info.st_ino ): @@ -478,8 +552,14 @@ def create( ) +def _token_seal_purpose(scope: str) -> str: + # Binds a sealed record to its account: a file copied to another + # account's name fails to open. + return f"kwork-mcp token {scope}" + + class SecureTokenStore: - """Account-scoped token files guarded by an interprocess flock.""" + """Account-scoped token files guarded by an interprocess lock.""" def __init__(self, state_dir: Path, *, lock_timeout: float = 20.0) -> None: self._state_dir = ensure_secure_directory(state_dir) @@ -512,18 +592,17 @@ def acquire_lock(self, scope: str) -> int: except FileExistsError: fd = os.open(lock_path, open_flags) else: - os.fchmod(fd, 0o600) + private_fs.restrict_file(fd) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): raise _security_error("token_lock_not_regular") - if hasattr(os, "geteuid") and info.st_uid != os.geteuid(): - raise _security_error("token_lock_wrong_owner") - if stat.S_IMODE(info.st_mode) != 0o600: - raise _security_error("token_lock_permissions_must_be_0600") + problem = private_fs.file_problem(info, fd=fd) + if problem is not None: + raise _security_error(f"token_lock_{problem}") deadline = time.monotonic() + self._lock_timeout while True: try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + private_fs.try_lock(fd) break except BlockingIOError as exc: if time.monotonic() >= deadline: @@ -548,7 +627,7 @@ def acquire_lock(self, scope: str) -> int: @staticmethod def release_lock(fd: int) -> None: try: - fcntl.flock(fd, fcntl.LOCK_UN) + private_fs.unlock(fd) finally: os.close(fd) @@ -559,7 +638,7 @@ def load_locked(self, scope: str) -> TokenRecord | None: return None if info.st_size > 65_536: raise _security_error("token_file_too_large") - flags = os.O_RDONLY + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = -1 @@ -568,16 +647,22 @@ def load_locked(self, scope: str) -> TokenRecord | None: opened = os.fstat(fd) if ( not stat.S_ISREG(opened.st_mode) - or stat.S_IMODE(opened.st_mode) != 0o600 - or (hasattr(os, "geteuid") and opened.st_uid != os.geteuid()) + or private_fs.file_problem(opened, fd=fd) is not None or opened.st_dev != info.st_dev or opened.st_ino != info.st_ino ): raise _security_error("token_file_changed_during_open") - handle = os.fdopen(fd, encoding="utf-8") + handle = os.fdopen(fd, "rb") fd = -1 with handle: - payload = json.load(handle) + sealed = handle.read() + try: + plain = private_fs.unseal(sealed, purpose=_token_seal_purpose(scope)) + except OSError as exc: # pragma: no cover - only DPAPI can fail here + # Another Windows user, a reset password or a moved profile: + # only a new login can replace the record. + raise GatewayError(ErrorCode.AUTH_EXPIRED, diagnostic="token_sealed_for_another_user") from exc + payload = json.loads(plain.decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc: raise _security_error(f"invalid_token_file:{type(exc).__name__}") from exc finally: @@ -616,12 +701,16 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: self._validate_scope_record(scope, record) target = self._token_path(scope) _validate_private_file(target) - payload = json.dumps( + encoded = json.dumps( asdict(record), ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode() + try: + payload = private_fs.seal(encoded, purpose=_token_seal_purpose(scope)) + except OSError as exc: # pragma: no cover - only DPAPI can fail here + raise _security_error(f"token_seal_error:{type(exc).__name__}") from exc if len(payload) > 65_536: raise _security_error("token_record_too_large") temp_fd = -1 @@ -629,7 +718,7 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: replaced = False try: temp_fd, temp_name = tempfile.mkstemp(prefix=".token-", dir=self._directory) - os.fchmod(temp_fd, 0o600) + private_fs.restrict_file(temp_fd) view = memoryview(payload) while view: written = os.write(temp_fd, view) @@ -639,13 +728,9 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: os.fsync(temp_fd) os.close(temp_fd) temp_fd = -1 - os.replace(temp_name, target) + private_fs.replace(temp_name, target) replaced = True - directory_fd = os.open(self._directory, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + private_fs.fsync_directory(self._directory) _validate_private_file(target, allow_missing=False) except GatewayError as exc: if replaced: @@ -667,8 +752,9 @@ def save_locked(self, scope: str, record: TokenRecord) -> None: if temp_fd >= 0: os.close(temp_fd) if temp_name: - with contextlib.suppress(FileNotFoundError): - os.unlink(temp_name) + # A failed cleanup must not replace the error being raised. + with contextlib.suppress(OSError): + private_fs.unlink(Path(temp_name)) def _validate_scope_record(self, scope: str, record: TokenRecord) -> None: safe_scope = self._safe_name(scope) @@ -684,12 +770,8 @@ def delete_locked(self, scope: str) -> None: if _validate_private_file(path) is None: return try: - path.unlink() - directory_fd = os.open(self._directory, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + private_fs.unlink(path) + private_fs.fsync_directory(self._directory) except OSError as exc: raise _security_error(f"token_delete_error:{type(exc).__name__}") from exc diff --git a/src/kwork_mcp/version.py b/src/kwork_mcp/version.py index 3981624..cf5013a 100644 --- a/src/kwork_mcp/version.py +++ b/src/kwork_mcp/version.py @@ -1,3 +1,3 @@ """Application version exposed in package metadata and the MCP handshake.""" -__version__ = "1.5.2" +__version__ = "1.6.0rc1" diff --git a/src/kwork_mcp/windows.py b/src/kwork_mcp/windows.py new file mode 100644 index 0000000..fa59d26 --- /dev/null +++ b/src/kwork_mcp/windows.py @@ -0,0 +1,375 @@ +"""Win32 security calls through ctypes: owners, DACLs, private directories and DPAPI. + +Only `private_fs` imports this module, and only on Windows. The assert keeps +mypy from checking it against POSIX stubs; CI checks it with --platform win32. +""" + +from __future__ import annotations + +import ctypes +import msvcrt +import sys +from collections.abc import Callable +from ctypes import wintypes +from functools import cache +from pathlib import Path +from typing import Any + +assert sys.platform == "win32" + +# (kind, sid, mask, inherit_only): kind is allow, deny or other; an +# inherit-only entry does not apply to the object itself, only to what is +# created inside it later. +AccessEntry = tuple[str, str | None, int, bool] + +_SE_FILE_OBJECT = 1 +_OWNER_SECURITY_INFORMATION = 0x00000001 +_DACL_SECURITY_INFORMATION = 0x00000004 +_TOKEN_QUERY = 0x0008 +_TOKEN_USER = 1 +_TOKEN_ELEVATION = 20 +_ACL_SIZE_INFORMATION = 2 +_SDDL_REVISION_1 = 1 +_CRYPTPROTECT_UI_FORBIDDEN = 0x1 +_MOVEFILE_REPLACE_EXISTING = 0x1 +_MOVEFILE_WRITE_THROUGH = 0x8 +_INHERIT_ONLY_ACE = 0x08 +# ACCESS_ALLOWED and ACCESS_ALLOWED_CALLBACK keep the SID right after the mask. +_ALLOWED_ACE_TYPES = frozenset({0x0, 0x9}) +# ACCESS_DENIED, ACCESS_DENIED_OBJECT, ACCESS_DENIED_CALLBACK, ACCESS_DENIED_CALLBACK_OBJECT. +_DENIED_ACE_TYPES = frozenset({0x1, 0x6, 0xA, 0xC}) + + +class _AclSizeInformation(ctypes.Structure): + _fields_ = ( + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ) + + +class _AceHeader(ctypes.Structure): + _fields_ = ( + ("AceType", ctypes.c_ubyte), + ("AceFlags", ctypes.c_ubyte), + ("AceSize", wintypes.WORD), + ) + + +class _SecurityAttributes(ctypes.Structure): + _fields_ = ( + ("nLength", wintypes.DWORD), + ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL), + ) + + +class _DataBlob(ctypes.Structure): + _fields_ = ( + ("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char)), + ) + + +def _function(library: ctypes.WinDLL, name: str, restype: Any, *argtypes: Any) -> Any: + function = getattr(library, name) + function.argtypes = argtypes + function.restype = restype + return function + + +_advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) +_crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) +_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +_out_pointer = ctypes.POINTER(ctypes.c_void_p) + +_GetSecurityInfo = _function( + _advapi32, + "GetSecurityInfo", + wintypes.DWORD, + wintypes.HANDLE, + ctypes.c_int, + wintypes.DWORD, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, +) +_GetNamedSecurityInfoW = _function( + _advapi32, + "GetNamedSecurityInfoW", + wintypes.DWORD, + wintypes.LPCWSTR, + ctypes.c_int, + wintypes.DWORD, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, + _out_pointer, +) +_ConvertSidToStringSidW = _function( + _advapi32, + "ConvertSidToStringSidW", + wintypes.BOOL, + ctypes.c_void_p, + ctypes.POINTER(wintypes.LPWSTR), +) +_GetAclInformation = _function( + _advapi32, + "GetAclInformation", + wintypes.BOOL, + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_int, +) +_GetAce = _function(_advapi32, "GetAce", wintypes.BOOL, ctypes.c_void_p, wintypes.DWORD, _out_pointer) +_OpenProcessToken = _function( + _advapi32, + "OpenProcessToken", + wintypes.BOOL, + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), +) +_GetTokenInformation = _function( + _advapi32, + "GetTokenInformation", + wintypes.BOOL, + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), +) +_ConvertStringSecurityDescriptorToSecurityDescriptorW = _function( + _advapi32, + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + wintypes.BOOL, + wintypes.LPCWSTR, + wintypes.DWORD, + _out_pointer, + ctypes.POINTER(wintypes.ULONG), +) +_CryptProtectData = _function( + _crypt32, + "CryptProtectData", + wintypes.BOOL, + ctypes.POINTER(_DataBlob), + wintypes.LPCWSTR, + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), +) +_CryptUnprotectData = _function( + _crypt32, + "CryptUnprotectData", + wintypes.BOOL, + ctypes.POINTER(_DataBlob), + ctypes.POINTER(wintypes.LPWSTR), + ctypes.POINTER(_DataBlob), + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), +) +_GetCurrentProcess = _function(_kernel32, "GetCurrentProcess", wintypes.HANDLE) +_CloseHandle = _function(_kernel32, "CloseHandle", wintypes.BOOL, wintypes.HANDLE) +_MoveFileExW = _function(_kernel32, "MoveFileExW", wintypes.BOOL, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD) +_LocalFree = _function(_kernel32, "LocalFree", ctypes.c_void_p, ctypes.c_void_p) +_CreateDirectoryW = _function( + _kernel32, + "CreateDirectoryW", + wintypes.BOOL, + wintypes.LPCWSTR, + ctypes.POINTER(_SecurityAttributes), +) + + +def _last_error() -> OSError: + return ctypes.WinError(ctypes.get_last_error()) + + +def _sid_string(sid: int | None) -> str: + text = wintypes.LPWSTR() + if not _ConvertSidToStringSidW(sid, ctypes.byref(text)): + raise _last_error() + try: + return str(text.value) + finally: + _LocalFree(ctypes.cast(text, ctypes.c_void_p)) + + +@cache +def current_user_sid() -> str: + """The SID of the user this process runs as, as S-1-5-21-...""" + + token = wintypes.HANDLE() + if not _OpenProcessToken(_GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)): + raise _last_error() + try: + size = wintypes.DWORD() + # The first call only reports the buffer size it needs. + _GetTokenInformation(token, _TOKEN_USER, None, 0, ctypes.byref(size)) + buffer = ctypes.create_string_buffer(size.value) + if not _GetTokenInformation(token, _TOKEN_USER, buffer, size, ctypes.byref(size)): + raise _last_error() + # TOKEN_USER starts with SID_AND_ATTRIBUTES, whose first field is the SID. + return _sid_string(ctypes.c_void_p.from_buffer(buffer).value) + finally: + _CloseHandle(token) + + +def is_elevated() -> bool: + """Whether this process runs with an elevated (administrator) token.""" + + token = wintypes.HANDLE() + if not _OpenProcessToken(_GetCurrentProcess(), _TOKEN_QUERY, ctypes.byref(token)): + raise _last_error() + try: + elevated = wintypes.DWORD() + size = wintypes.DWORD() + if not _GetTokenInformation( + token, _TOKEN_ELEVATION, ctypes.byref(elevated), ctypes.sizeof(elevated), ctypes.byref(size) + ): + raise _last_error() + return bool(elevated.value) + finally: + _CloseHandle(token) + + +def _access_entries(dacl: int) -> list[AccessEntry]: + size = _AclSizeInformation() + if not _GetAclInformation(dacl, ctypes.byref(size), ctypes.sizeof(size), _ACL_SIZE_INFORMATION): + raise _last_error() + entries: list[AccessEntry] = [] + for index in range(size.AceCount): + ace = ctypes.c_void_p() + if not _GetAce(dacl, index, ctypes.byref(ace)) or not ace.value: + raise _last_error() + header = _AceHeader.from_address(ace.value) + inherit_only = bool(header.AceFlags & _INHERIT_ONLY_ACE) + if header.AceType in _ALLOWED_ACE_TYPES: + mask_address = ace.value + ctypes.sizeof(_AceHeader) + mask = wintypes.DWORD.from_address(mask_address).value + sid = _sid_string(mask_address + ctypes.sizeof(wintypes.DWORD)) + entries.append(("allow", sid, int(mask), inherit_only)) + elif header.AceType in _DENIED_ACE_TYPES: + entries.append(("deny", None, 0, inherit_only)) + else: + entries.append(("other", None, 0, inherit_only)) + return entries + + +def _security(read: Callable[[Any, Any, Any], int]) -> tuple[str | None, list[AccessEntry] | None]: + owner = ctypes.c_void_p() + dacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + status = read(ctypes.byref(owner), ctypes.byref(dacl), ctypes.byref(descriptor)) + if status: + raise ctypes.WinError(status) + try: + owner_sid = _sid_string(owner.value) if owner.value else None + # A missing DACL grants everyone full access. + return owner_sid, _access_entries(dacl.value) if dacl.value else None + finally: + _LocalFree(descriptor) + + +_REQUESTED = _OWNER_SECURITY_INFORMATION | _DACL_SECURITY_INFORMATION + + +def handle_security(fd: int) -> tuple[str | None, list[AccessEntry] | None]: + """Owner SID and effective DACL entries of an open file.""" + + handle = msvcrt.get_osfhandle(fd) + return _security( + lambda owner, dacl, descriptor: _GetSecurityInfo( + handle, _SE_FILE_OBJECT, _REQUESTED, owner, None, dacl, None, descriptor + ) + ) + + +def path_security(path: Path) -> tuple[str | None, list[AccessEntry] | None]: + """Owner SID and effective DACL entries of a file or directory.""" + + return _security( + lambda owner, dacl, descriptor: _GetNamedSecurityInfoW( + str(path), _SE_FILE_OBJECT, _REQUESTED, owner, None, dacl, None, descriptor + ) + ) + + +def replace(source: str, target: Path) -> None: + """os.replace that returns only once the rename is on disk. + + NTFS journals the rename but may still lose it on power loss; the token + store has no directory fsync on Windows to make up for it. + """ + + if not _MoveFileExW(source, str(target), _MOVEFILE_REPLACE_EXISTING | _MOVEFILE_WRITE_THROUGH): + raise _last_error() + + +def create_private_directory(path: Path) -> None: + """Create `path` open only to the current user and SYSTEM. + + The protected DACL is set by CreateDirectoryW itself, so the directory + never exists with the inherited, possibly shared, DACL of its parent. + Raises FileExistsError when `path` already exists. + """ + + sddl = f"D:P(A;OICI;FA;;;{current_user_sid()})(A;OICI;FA;;;SY)" + descriptor = ctypes.c_void_p() + if not _ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, _SDDL_REVISION_1, ctypes.byref(descriptor), None + ): + raise _last_error() + try: + attributes = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), descriptor.value, False) + if not _CreateDirectoryW(str(path), ctypes.byref(attributes)): + raise _last_error() + finally: + _LocalFree(descriptor) + + +def _blob(data: bytes) -> tuple[_DataBlob, ctypes.Array[ctypes.c_char]]: + buffer = ctypes.create_string_buffer(data, len(data)) + return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char))), buffer + + +def _take_blob(blob: _DataBlob) -> bytes: + try: + return ctypes.string_at(blob.pbData, blob.cbData) + finally: + _LocalFree(ctypes.cast(blob.pbData, ctypes.c_void_p)) + + +def protect(data: bytes, *, entropy: bytes) -> bytes: + """Encrypt `data` with DPAPI for the current Windows user.""" + + source, _source_buffer = _blob(data) + salt, _salt_buffer = _blob(entropy) + sealed = _DataBlob() + if not _CryptProtectData( + ctypes.byref(source), None, ctypes.byref(salt), None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(sealed) + ): + raise _last_error() + return _take_blob(sealed) + + +def unprotect(data: bytes, *, entropy: bytes) -> bytes: + """Decrypt DPAPI data; fails for another user, other entropy or changed bytes.""" + + source, _source_buffer = _blob(data) + salt, _salt_buffer = _blob(entropy) + opened = _DataBlob() + if not _CryptUnprotectData( + ctypes.byref(source), None, ctypes.byref(salt), None, None, _CRYPTPROTECT_UI_FORBIDDEN, ctypes.byref(opened) + ): + raise _last_error() + return _take_blob(opened) diff --git a/tests/conftest.py b/tests/conftest.py index 4a4748e..2a462ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,3 +50,10 @@ def coordinator(config_factory: Callable[..., KworkConfig]) -> CoordinationStore def actor(user_id: int = 42, username: str = "fixture-user") -> Actor: return Actor(id=user_id, username=username) + + +@pytest.fixture(autouse=True) +def _no_real_client_configs(monkeypatch: pytest.MonkeyPatch) -> None: + # login offers to edit Claude Desktop and Cursor configs; no test may find + # the real ones on the machine that runs the suite. + monkeypatch.setattr("kwork_mcp.clients.find_clients", lambda: []) diff --git a/tests/platforms.py b/tests/platforms.py new file mode 100644 index 0000000..d02337b --- /dev/null +++ b/tests/platforms.py @@ -0,0 +1,39 @@ +"""Markers and checks for tests that depend on one operating system's file semantics.""" + +from __future__ import annotations + +import stat +import sys +from pathlib import Path + +import pytest + +from kwork_mcp import private_fs + +posix_only = pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX modes, uids, umask, symlinks or fd-relative directory walks", +) +windows_only = pytest.mark.skipif( + sys.platform != "win32", + reason="Windows ACLs, DPAPI or msvcrt locks", +) + + +def assert_private_file(path: Path) -> None: + """Mode 0600 on POSIX; an owner-only owner and DACL on Windows.""" + + info = path.stat() + if sys.platform == "win32": + assert private_fs.file_problem(info, path=path) is None + else: + assert stat.S_IMODE(info.st_mode) == 0o600 + + +def assert_private_directory(path: Path) -> None: + """Mode 0700 on POSIX; an owner-only owner and DACL on Windows.""" + + if sys.platform == "win32": + assert private_fs.directory_problem(path) is None + else: + assert stat.S_IMODE(path.stat().st_mode) == 0o700 diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py index 2ce08fd..82217e8 100644 --- a/tests/test_auth_bootstrap.py +++ b/tests/test_auth_bootstrap.py @@ -4,7 +4,6 @@ import io import json import os -import stat import threading from pathlib import Path from typing import Any @@ -16,6 +15,7 @@ import kwork_mcp.bootstrap as bootstrap_module import kwork_mcp.security as security_module +from kwork_mcp import private_fs from kwork_mcp.bootstrap import bootstrap_account, run_bootstrap_cli from kwork_mcp.config import KworkConfig, secret_server_environment_present from kwork_mcp.coordination import CoordinationStore @@ -28,6 +28,7 @@ ) from kwork_mcp.session import KworkSessionManager from kwork_mcp.version import __version__ +from tests.platforms import assert_private_file, posix_only class AuthClient: @@ -344,17 +345,15 @@ async def test_scope_record_mismatch_is_rejected_before_network(tmp_path: Path) config = credentialless_config(tmp_path / "state") store = SecureTokenStore(config.state_dir) token_path = config.state_dir / "tokens" / "account-42.json" - token_path.write_text( - json.dumps( - { - "version": 1, - "user_id": 99, - "username": "wrong", - "token": "wrong-token", - "saved_at": "2026-07-27T00:00:00+00:00", - } - ) - ) + payload = { + "version": 1, + "user_id": 99, + "username": "wrong", + "token": "wrong-token", + "saved_at": "2026-07-27T00:00:00+00:00", + } + sealed = private_fs.seal(json.dumps(payload).encode(), purpose=security_module._token_seal_purpose("account-42")) + token_path.write_bytes(sealed) os.chmod(token_path, 0o600) factory_calls = 0 @@ -493,7 +492,7 @@ async def test_bootstrap_fresh_login_atomically_replaces_only_after_identity_che assert saved.user_id == 42 assert saved.username == "verified" assert saved.token == "new-token" - assert stat.S_IMODE((config.state_dir / "tokens" / "account-42.json").stat().st_mode) == 0o600 + assert_private_file(config.state_dir / "tokens" / "account-42.json") @pytest.mark.asyncio @@ -824,6 +823,7 @@ def factory(config: KworkConfig) -> AuthClient: assert stored.proxy_url is None +@posix_only @pytest.mark.asyncio async def test_bootstrap_cli_validates_and_imports_legacy_token_explicitly( tmp_path: Path, @@ -881,6 +881,7 @@ def factory(config: KworkConfig) -> AuthClient: assert stored.proxy_url == "socks5://proxy-user:proxy-pass@proxy.example:1080" +@posix_only @pytest.mark.asyncio async def test_insecure_legacy_import_fails_before_network_and_preserves_store( tmp_path: Path, @@ -959,7 +960,7 @@ async def test_bootstrap_cli_rejects_non_tty_and_unknown_argv_without_reflection [ (["--help"], "kwork-mcp-bootstrap"), (["-h"], "kwork-mcp-bootstrap"), - (["--version"], "1.5.2"), + (["--version"], "1.6.0rc1"), ], ) async def test_bootstrap_help_and_version_need_no_tty_or_configuration( @@ -1560,17 +1561,11 @@ async def test_post_replace_unknown_survives_caller_cancel_during_close( store = SecureTokenStore(config.state_dir) old = TokenRecord.create(user_id=42, username="old", token="old-token") save_record(store, old) - real_fsync = os.fsync - fsync_calls = 0 - def fail_post_replace_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace durability failure") - real_fsync(fd) + def fail_post_replace_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace durability failure") - monkeypatch.setattr(security_module.os, "fsync", fail_post_replace_directory_fsync) + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_post_replace_directory_fsync) close_entered = asyncio.Event() close_release = asyncio.Event() client = BlockingCloseAuthClient( @@ -1627,17 +1622,11 @@ async def test_post_replace_primary_survives_release_error_and_unlocks( config.state_dir, protected_detail=protected_detail, ) - real_fsync = os.fsync - fsync_calls = 0 - def fail_post_replace_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace durability failure") - real_fsync(fd) + def fail_post_replace_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace durability failure") - monkeypatch.setattr(security_module.os, "fsync", fail_post_replace_directory_fsync) + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_post_replace_directory_fsync) client = AuthClient( Actor(id=42, username="new"), fresh_token="new-token", @@ -1897,17 +1886,40 @@ def test_token_replace_post_commit_failure_is_typed_as_ambiguous( username="new", token="new-token", ) - real_fsync = os.fsync - fsync_calls = 0 - def fail_directory_fsync(fd: int) -> None: - nonlocal fsync_calls - fsync_calls += 1 - if fsync_calls == 2: - raise OSError("synthetic post-replace failure") - real_fsync(fd) + def fail_directory_fsync(_directory: Path) -> None: + raise OSError("synthetic post-replace failure") + + monkeypatch.setattr(security_module.private_fs, "fsync_directory", fail_directory_fsync) + fd = store.acquire_lock("account-42") + try: + with pytest.raises(GatewayError) as caught: + store.save_locked("account-42", replacement) + finally: + store.release_lock(fd) + + assert caught.value.code is ErrorCode.CREDENTIAL_UPDATE_UNKNOWN + assert caught.value.reconciliation_required is True + assert load_record(store) == replacement + + +def test_token_replace_failing_its_final_check_is_typed_as_ambiguous( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # On Windows the re-check after replace (owner and DACL) is the only step + # that can fail once the new record is visible. + store = SecureTokenStore(tmp_path / "state") + save_record(store, TokenRecord.create(user_id=42, username="old", token="old-token")) + replacement = TokenRecord.create(user_id=42, username="new", token="new-token") + real_validate = security_module._validate_private_file + + def reject_after_replace(path: Path, *, allow_missing: bool = True) -> os.stat_result | None: + if not allow_missing: + raise GatewayError(ErrorCode.VALIDATION, diagnostic="private_file_acl_not_private") + return real_validate(path, allow_missing=allow_missing) - monkeypatch.setattr(security_module.os, "fsync", fail_directory_fsync) + monkeypatch.setattr(security_module, "_validate_private_file", reject_after_replace) fd = store.acquire_lock("account-42") try: with pytest.raises(GatewayError) as caught: @@ -1916,6 +1928,7 @@ def fail_directory_fsync(fd: int) -> None: store.release_lock(fd) assert caught.value.code is ErrorCode.CREDENTIAL_UPDATE_UNKNOWN + assert caught.value.diagnostic == "token_replace_commit_durability_unknown" assert caught.value.reconciliation_required is True assert load_record(store) == replacement @@ -1932,9 +1945,9 @@ def test_read_private_secret_file_accepts_one_terminal_newline(tmp_path: Path) - @pytest.mark.parametrize( "kind", [ - "symlink", + pytest.param("symlink", marks=posix_only), "directory", - "wrong_mode", + pytest.param("wrong_mode", marks=posix_only), "oversize", "invalid_utf8", "multiline", @@ -1950,31 +1963,31 @@ def test_read_private_secret_file_rejects_unsafe_sources( path = tmp_path / "legacy-token" if kind == "symlink": target = tmp_path / "target" - target.write_text("token") + target.write_text("token", encoding="utf-8") os.chmod(target, 0o600) path.symlink_to(target) elif kind == "directory": path.mkdir(mode=0o700) elif kind == "wrong_mode": - path.write_text("token") + path.write_text("token", encoding="utf-8") os.chmod(path, 0o640) elif kind == "oversize": - path.write_text("x" * 4097) + path.write_text("x" * 4097, encoding="utf-8") os.chmod(path, 0o600) elif kind == "invalid_utf8": path.write_bytes(b"\xff\xfe") os.chmod(path, 0o600) elif kind == "multiple_newlines": - path.write_text("token\n\n") + path.write_text("token\n\n", encoding="utf-8") os.chmod(path, 0o600) elif kind == "leading_tab": - path.write_text("\ttoken\n") + path.write_text("\ttoken\n", encoding="utf-8") os.chmod(path, 0o600) elif kind == "bidi_control": - path.write_text("token\u202e") + path.write_text("token\u202e", encoding="utf-8") os.chmod(path, 0o600) else: - path.write_text("first\nsecond\n") + path.write_text("first\nsecond\n", encoding="utf-8") os.chmod(path, 0o600) with pytest.raises(GatewayError) as caught: @@ -1982,6 +1995,7 @@ def test_read_private_secret_file_rejects_unsafe_sources( assert caught.value.code is ErrorCode.VALIDATION +@posix_only def test_read_private_secret_file_rejects_wrong_owner( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_client_setup.py b/tests/test_client_setup.py new file mode 100644 index 0000000..04e47d1 --- /dev/null +++ b/tests/test_client_setup.py @@ -0,0 +1,125 @@ +"""`kwork-mcp login` connects Claude Desktop and Cursor itself when the user agrees.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from kwork_mcp import clients +from kwork_mcp.config import KworkConfig +from kwork_mcp.version import __version__ +from tests.test_bootstrap_discovery import AuthClient, _environment, _run + + +@pytest.fixture +def claude_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + directory = tmp_path / "Claude" + directory.mkdir() + path = directory / "claude_desktop_config.json" + monkeypatch.setattr(clients, "find_clients", lambda: [clients.ClientConfig("Claude Desktop", path)]) + monkeypatch.setattr("kwork_mcp.bootstrap.shutil.which", lambda name: f"/opt/tools/bin/{name}") + _environment(monkeypatch, tmp_path / "state") + return path + + +def _expected_entry(tmp_path: Path) -> dict[str, Any]: + return { + "command": "/opt/tools/bin/uvx", + "args": [f"kwork-mcp@{__version__}"], + "env": {"KWORK_STATE_DIR": str(tmp_path / "state")}, + } + + +def _login(tmp_path: Path, answers: str) -> tuple[int, str, str]: + auth_clients: list[AuthClient] = [] + configs: list[KworkConfig] = [] + return asyncio.run(_run(tmp_path, confirmation=answers, clients=auth_clients, configs=configs)) + + +def test_login_adds_the_server_to_claude_desktop_after_a_yes(tmp_path: Path, claude_config: Path) -> None: + code, stdout, stderr = _login(tmp_path, "да\nда\n") + + assert code == 0 + assert "Подключить kwork-mcp к Claude Desktop? [y/N]" in stderr + assert json.loads(claude_config.read_text(encoding="utf-8")) == {"mcpServers": {"kwork": _expected_entry(tmp_path)}} + assert "Claude Desktop: kwork-mcp подключён." in stdout + assert "Другие агенты. Claude Code:" in stdout + # The manual block remains only for the client that was not set up. + assert "Claude Desktop и Cursor" not in stdout + assert 'Cursor: в "mcpServers" файла' in stdout + + +def test_login_keeps_other_servers_and_backs_the_config_up(tmp_path: Path, claude_config: Path) -> None: + claude_config.write_text(json.dumps({"mcpServers": {"files": {"command": "npx", "args": ["fs"]}}})) + + code, stdout, _stderr = _login(tmp_path, "да\nда\n") + + assert code == 0 + servers = json.loads(claude_config.read_text(encoding="utf-8"))["mcpServers"] + assert servers == {"files": {"command": "npx", "args": ["fs"]}, "kwork": _expected_entry(tmp_path)} + assert str(claude_config.with_name("claude_desktop_config.json.bak")) in stdout + + +def test_login_leaves_the_config_alone_after_a_no(tmp_path: Path, claude_config: Path) -> None: + code, stdout, _stderr = _login(tmp_path, "да\nнет\n") + + assert code == 0 + assert not claude_config.exists() + assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout + + +def test_login_asks_before_replacing_another_server_named_kwork(tmp_path: Path, claude_config: Path) -> None: + other = {"mcpServers": {"kwork": {"command": "node", "args": ["/opt/other-kwork/index.js"]}}} + claude_config.write_text(json.dumps(other)) + + code, _stdout, stderr = _login(tmp_path, "да\nнет\n") + + assert code == 0 + assert "уже есть другой сервер «kwork»" in stderr + assert json.loads(claude_config.read_text(encoding="utf-8")) == other + + +def test_login_never_edits_a_config_that_is_not_plain_json(tmp_path: Path, claude_config: Path) -> None: + claude_config.write_text('{"mcpServers": {},}') + + code, stdout, stderr = _login(tmp_path, "да\n") + + assert code == 0 + assert "Подключить" not in stderr + assert "не изменён" in stdout + assert claude_config.read_text() == '{"mcpServers": {},}' + assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout + + +def test_login_does_not_ask_again_when_the_server_is_already_there(tmp_path: Path, claude_config: Path) -> None: + claude_config.write_text(json.dumps({"mcpServers": {"kwork": _expected_entry(tmp_path)}})) + + code, stdout, stderr = _login(tmp_path, "да\n") + + assert code == 0 + assert "Подключить" not in stderr + assert "Claude Desktop: kwork-mcp уже подключён." in stdout + + +def test_login_names_the_error_when_the_config_cannot_be_written( + tmp_path: Path, + claude_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(*_args: Any) -> clients.Registration: + error = PermissionError(13, "in use") + error.winerror = 32 # type: ignore[attr-defined] + raise error + + monkeypatch.setattr(clients, "register_server", refuse) + + code, stdout, _stderr = _login(tmp_path, "да\nда\n") + + assert code == 0 + assert "не удалось записать" in stdout + assert "PermissionError, WinError 32" in stdout + assert 'Claude Desktop и Cursor: в "mcpServers" файла' in stdout diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..a72cb3d --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,217 @@ +"""Finding Claude Desktop and Cursor configs and adding kwork-mcp to them safely.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path + +import pytest + +from kwork_mcp import clients +from kwork_mcp.clients import ClientConfigError, find_clients, is_kwork_mcp_entry, register_server +from tests.platforms import posix_only + +ENTRY = {"command": "/opt/tools/bin/uvx", "args": ["kwork-mcp@1.6.0"]} + +# --- where the configs live + + +@pytest.fixture +def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + path = tmp_path / "home" + path.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: path)) + for name in ("APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME"): + monkeypatch.delenv(name, raising=False) + return path + + +def _names(found: list[clients.ClientConfig]) -> dict[str, Path]: + return {client.name: client.path for client in found} + + +def test_nothing_is_offered_when_no_client_was_ever_started(home: Path) -> None: + assert find_clients() == [] + + +def test_macos_claude_desktop_and_cursor(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "darwin") + (home / "Library" / "Application Support" / "Claude").mkdir(parents=True) + (home / ".cursor").mkdir() + + assert _names(find_clients()) == { + "Claude Desktop": home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json", + "Cursor": home / ".cursor" / "mcp.json", + } + + +def test_windows_claude_desktop_in_roaming_app_data(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(home / "AppData" / "Roaming")) + (home / "AppData" / "Roaming" / "Claude").mkdir(parents=True) + + assert _names(find_clients()) == { + "Claude Desktop": home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json", + } + + +def test_windows_store_claude_desktop_keeps_its_own_config(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # The Microsoft Store (MSIX) build sees a private copy of AppData, so a + # config written to the real one would be ignored. + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(home / "AppData" / "Roaming")) + monkeypatch.setenv("LOCALAPPDATA", str(home / "AppData" / "Local")) + (home / "AppData" / "Roaming" / "Claude").mkdir(parents=True) + packaged = home / "AppData" / "Local" / "Packages" / "Claude_pzs8sxrjxfjjc" / "LocalCache" / "Roaming" / "Claude" + packaged.mkdir(parents=True) + + assert _names(find_clients()) == {"Claude Desktop": packaged / "claude_desktop_config.json"} + + +def test_linux_community_claude_desktop_follows_xdg(home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / "config")) + (home / "config" / "Claude").mkdir(parents=True) + (home / ".cursor").mkdir() + + assert _names(find_clients()) == { + "Claude Desktop": home / "config" / "Claude" / "claude_desktop_config.json", + "Cursor": home / ".cursor" / "mcp.json", + } + + +# --- writing the entry + + +def test_a_missing_config_is_created_with_the_server(tmp_path: Path) -> None: + path = tmp_path / "claude_desktop_config.json" + + result = register_server(path, "kwork", ENTRY) + + assert result.outcome == "added" + assert result.backup is None + assert json.loads(path.read_text(encoding="utf-8")) == {"mcpServers": {"kwork": ENTRY}} + + +def test_other_servers_and_settings_are_kept_and_the_old_file_is_backed_up(tmp_path: Path) -> None: + path = tmp_path / "claude_desktop_config.json" + original = {"globalShortcut": "Ctrl+Space", "mcpServers": {"files": {"command": "npx", "args": ["fs"]}}} + path.write_text(json.dumps(original), encoding="utf-8") + + result = register_server(path, "kwork", ENTRY) + + assert result.outcome == "added" + assert json.loads(path.read_text(encoding="utf-8")) == { + "globalShortcut": "Ctrl+Space", + "mcpServers": {"files": {"command": "npx", "args": ["fs"]}, "kwork": ENTRY}, + } + assert result.backup == tmp_path / "claude_desktop_config.json.bak" + assert json.loads(result.backup.read_text(encoding="utf-8")) == original + + +def test_an_older_kwork_mcp_entry_is_updated(tmp_path: Path) -> None: + path = tmp_path / "mcp.json" + path.write_text(json.dumps({"mcpServers": {"kwork": {"command": "uvx", "args": ["kwork-mcp@1.5.2"]}}})) + + assert register_server(path, "kwork", ENTRY).outcome == "updated" + assert json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["kwork"] == ENTRY + + +def test_the_same_entry_is_left_untouched(tmp_path: Path) -> None: + path = tmp_path / "mcp.json" + path.write_text(json.dumps({"mcpServers": {"kwork": ENTRY}})) + before = path.read_bytes() + + result = register_server(path, "kwork", ENTRY) + + assert result.outcome == "unchanged" + assert path.read_bytes() == before + assert not (tmp_path / "mcp.json.bak").exists() + + +def test_a_byte_order_mark_from_notepad_is_accepted(tmp_path: Path) -> None: + path = tmp_path / "claude_desktop_config.json" + path.write_bytes(b"\xef\xbb\xbf" + json.dumps({"mcpServers": {}}).encode()) + + assert register_server(path, "kwork", ENTRY).outcome == "added" + assert not path.read_bytes().startswith(b"\xef\xbb\xbf") + + +@pytest.mark.parametrize( + "content", + [ + '{"mcpServers": {"files": {}},}', + "// my servers\n{}", + "[]", + '{"mcpServers": []}', + b"\xff\xfe".decode("latin-1"), + ], + ids=["trailing-comma", "comment", "array", "servers-not-object", "not-utf8"], +) +def test_a_config_that_is_not_plain_json_is_never_touched(tmp_path: Path, content: str) -> None: + path = tmp_path / "claude_desktop_config.json" + path.write_bytes(content.encode("latin-1")) + before = path.read_bytes() + + with pytest.raises(ClientConfigError): + register_server(path, "kwork", ENTRY) + + assert path.read_bytes() == before + assert sorted(item.name for item in tmp_path.iterdir()) == ["claude_desktop_config.json"] + + +@posix_only +def test_the_config_keeps_its_permissions(tmp_path: Path) -> None: + path = tmp_path / "mcp.json" + path.write_text("{}") + os.chmod(path, 0o600) + + register_server(path, "kwork", ENTRY) + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE((tmp_path / "mcp.json.bak").stat().st_mode) == 0o600 + + +@pytest.mark.parametrize( + ("entry", "ours"), + [ + (ENTRY, True), + ({"command": "uvx", "args": ["kwork-mcp@1.5.2"]}, True), + ({"command": "uv", "args": ["--directory", "/src/kwork-mcp", "run", "kwork-mcp"]}, True), + ({"command": "node", "args": ["/opt/other-kwork/index.js"]}, False), + ({"command": "uvx", "args": ["kwork-mcp-fork"]}, False), + ("not an object", False), + ], +) +def test_only_kwork_mcp_entries_count_as_ours(entry: object, ours: bool) -> None: + assert is_kwork_mcp_entry(entry) is ours + + +def test_a_config_that_cannot_be_replaced_is_written_in_place( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # An antivirus scan or the client itself may hold the file so that it + # cannot be replaced; it can still be rewritten, and the backup is made first. + path = tmp_path / "claude_desktop_config.json" + path.write_text(json.dumps({"mcpServers": {"files": {"command": "npx"}}}), encoding="utf-8") + + def refuse(_source: str, _target: Path) -> None: + raise PermissionError(13, "in use") + + monkeypatch.setattr(clients.private_fs, "replace", refuse) + + result = register_server(path, "kwork", ENTRY) + + assert result.outcome == "added" + assert json.loads(path.read_text(encoding="utf-8"))["mcpServers"]["kwork"] == ENTRY + assert json.loads((tmp_path / "claude_desktop_config.json.bak").read_text(encoding="utf-8")) == { + "mcpServers": {"files": {"command": "npx"}} + } + assert sorted(item.name for item in tmp_path.iterdir()) == [ + "claude_desktop_config.json", + "claude_desktop_config.json.bak", + ] diff --git a/tests/test_config_models_errors_more.py b/tests/test_config_models_errors_more.py index 8c20395..a37c9a2 100644 --- a/tests/test_config_models_errors_more.py +++ b/tests/test_config_models_errors_more.py @@ -34,8 +34,10 @@ WriteAction, ) from kwork_mcp.server import create_server +from tests.platforms import posix_only +@posix_only def test_default_state_dir_honors_xdg_and_falls_back_to_home( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_config_security.py b/tests/test_config_security.py index ea7fda6..8770427 100644 --- a/tests/test_config_security.py +++ b/tests/test_config_security.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import stat from pathlib import Path import pytest @@ -16,6 +15,7 @@ redact_text, sanitize_external, ) +from tests.platforms import assert_private_directory, assert_private_file, posix_only def test_configuration_never_loads_dotenv_from_cwd( @@ -69,6 +69,7 @@ def test_secret_inputs_are_hidden_in_validation_errors(tmp_path: Path) -> None: assert "input_value=" not in rendered +@posix_only def test_secure_directory_rejects_permissions_and_symlinks(tmp_path: Path) -> None: unsafe = tmp_path / "unsafe" unsafe.mkdir(mode=0o755) @@ -93,14 +94,15 @@ def test_token_store_roundtrip_is_private_atomic_and_account_scoped(tmp_path: Pa store.save_locked("account-42", record) assert store.load_locked("account-42") == record token_path = state_dir / "tokens" / "account-42.json" - assert stat.S_IMODE(token_path.stat().st_mode) == 0o600 - assert stat.S_IMODE((state_dir / "tokens").stat().st_mode) == 0o700 + assert_private_file(token_path) + assert_private_directory(state_dir / "tokens") store.delete_locked("account-42") assert store.load_locked("account-42") is None finally: store.release_lock(fd) +@posix_only def test_token_store_rejects_symlink_and_world_readable_token(tmp_path: Path) -> None: state_dir = tmp_path / "state" store = SecureTokenStore(state_dir) diff --git a/tests/test_coordination.py b/tests/test_coordination.py index 7d544b4..6ef5a8a 100644 --- a/tests/test_coordination.py +++ b/tests/test_coordination.py @@ -5,7 +5,6 @@ import multiprocessing import os import sqlite3 -import stat import time import warnings from collections.abc import Callable @@ -19,6 +18,7 @@ from kwork_mcp.coordination import CoordinationStore, CursorCodec, pages_from_paging from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode, WriteAction, WriteState +from tests.platforms import assert_private_file @pytest.mark.asyncio @@ -423,7 +423,7 @@ def test_rate_limit_is_shared_between_processes(tmp_path: Path) -> None: process.join(timeout=5) assert process.exitcode == 0 assert timestamps[1] - timestamps[0] >= 0.40 - assert stat.S_IMODE((state_dir / "coordination.sqlite3").stat().st_mode) == 0o600 + assert_private_file(state_dir / "coordination.sqlite3") def test_sqlite_connections_do_not_emit_resource_warnings( diff --git a/tests/test_coordination_more.py b/tests/test_coordination_more.py index 3ec9089..fdee727 100644 --- a/tests/test_coordination_more.py +++ b/tests/test_coordination_more.py @@ -16,6 +16,7 @@ from kwork_mcp.coordination import CoordinationStore, CursorCodec, pages_from_paging from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode, ErrorInfo, WriteAction, WriteState +from tests.platforms import posix_only def _create_legacy_database(path: Path, version: int) -> None: @@ -221,6 +222,7 @@ async def test_v2_committing_write_migrates_to_unknown_and_blocks_duplicates( assert claimed.state is WriteState.COMMITTING +@posix_only def test_coordination_rejects_symlink_permissions_and_post_init_tamper( config_factory: Callable[..., KworkConfig], tmp_path: Path, @@ -250,6 +252,7 @@ def test_coordination_rejects_symlink_permissions_and_post_init_tamper( assert changed.value.diagnostic == "coordination_db_security_changed" +@posix_only def test_fresh_coordination_database_is_private_under_restrictive_umask( tmp_path: Path, ) -> None: diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 85d1cdd..243a382 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -131,8 +131,9 @@ def test_main_reports_invalid_configuration_without_traceback( expected: str, ) -> None: _clear_kwork_environment(monkeypatch) - # The default state directory lives under HOME; never read the real one. + # The default state directory lives in the home directory; never read the real one. monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.delenv("XDG_STATE_HOME", raising=False) for name, value in env.items(): monkeypatch.setenv(name, value) diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 23f9611..112eba4 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -387,7 +387,7 @@ async def test_in_memory_handshake_tools_schemas_annotations_and_results( assert negotiated.protocol_version == protocol_version assert negotiated.server_info is not None assert negotiated.server_info.name == "kwork" - assert negotiated.server_info.version == __version__ == "1.5.2" + assert negotiated.server_info.version == __version__ == "1.6.0rc1" assert negotiated.instructions == server_instructions("auto") assert negotiated.server_capabilities is not None assert negotiated.server_capabilities.tools is not None diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index ec9b045..444002a 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -14,7 +14,7 @@ def test_registry_metadata_advertises_only_safe_server_environment() -> None: - metadata = json.loads((ROOT / "server.json").read_text()) + metadata = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) package = metadata["packages"][0] variables = package["environmentVariables"] by_name = {item["name"]: item for item in variables} @@ -34,7 +34,7 @@ def test_registry_metadata_advertises_only_safe_server_environment() -> None: def test_example_environment_has_no_active_secret_assignment() -> None: active_names = { line.partition("=")[0].strip() - for line in (ROOT / ".env.example").read_text().splitlines() + for line in (ROOT / ".env.example").read_text(encoding="utf-8").splitlines() if line.strip() and not line.lstrip().startswith("#") and "=" in line } assert active_names.isdisjoint(SERVER_SECRET_ENV_NAMES) @@ -42,11 +42,11 @@ def test_example_environment_has_no_active_secret_assignment() -> None: def test_versions_and_console_entrypoints_are_consistent() -> None: - project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] - registry = json.loads((ROOT / "server.json").read_text()) + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] + registry = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) assert project["dynamic"] == ["version"] - assert registry["version"] == __version__ == "1.5.2" + assert registry["version"] == __version__ == "1.6.0rc1" assert registry["packages"][0]["version"] == __version__ assert project["scripts"] == { "kwork-mcp": "kwork_mcp:main", @@ -56,7 +56,7 @@ def test_versions_and_console_entrypoints_are_consistent() -> None: def test_install_instructions_pin_the_current_version() -> None: for name in ("README.md", "site/index.html"): - text = (ROOT / name).read_text() + text = (ROOT / name).read_text(encoding="utf-8") assert set(re.findall(r"kwork-mcp(?:==|@)([0-9][0-9A-Za-z.]*)", text)) == {__version__}, name # The "Add to Cursor" link carries its own base64 copy of the config. configs = re.findall(r"install-mcp\?name=kwork&(?:amp;)?config=([A-Za-z0-9+/%=]+)", text) @@ -65,12 +65,12 @@ def test_install_instructions_pin_the_current_version() -> None: config = json.loads(base64.b64decode(unquote(encoded))) assert config == {"command": "uvx", "args": [f"kwork-mcp@{__version__}"]}, name - site = (ROOT / "site" / "index.html").read_text() + site = (ROOT / "site" / "index.html").read_text(encoding="utf-8") assert f'"softwareVersion": "{__version__}"' in site def test_release_workflow_fails_closed_for_prerelease_registry_publish() -> None: - workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") assert "source_is_prerelease = Version(__version__).is_prerelease" in workflow assert "source_is_prerelease != event_is_prerelease" in workflow @@ -82,12 +82,12 @@ def test_release_workflow_fails_closed_for_prerelease_registry_publish() -> None def test_workflows_use_explicit_locked_mode_without_conflicting_environment() -> None: for name in ("ci.yml", "release.yml"): - workflow = (ROOT / ".github" / "workflows" / name).read_text() + workflow = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") assert "UV_FROZEN" not in workflow assert "uv sync --locked --all-groups" in workflow def test_release_asset_upload_does_not_require_a_checkout() -> None: - workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text() + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") assert 'gh release upload "$RELEASE_TAG" dist/* --repo "$GITHUB_REPOSITORY"' in workflow diff --git a/tests/test_security_more.py b/tests/test_security_more.py index bbc7f46..5151ec8 100644 --- a/tests/test_security_more.py +++ b/tests/test_security_more.py @@ -16,6 +16,7 @@ from yarl import URL import kwork_mcp.security as security_module +from kwork_mcp import private_fs from kwork_mcp.config import KworkConfig, proxy_redaction_secrets from kwork_mcp.errors import GatewayError from kwork_mcp.models import ErrorCode @@ -28,6 +29,7 @@ redact_text, sanitize_external, ) +from tests.platforms import assert_private_directory, posix_only def _assert_validation(error: pytest.ExceptionInfo[GatewayError], diagnostic: str) -> None: @@ -45,15 +47,23 @@ def test_secure_directory_creates_private_parents_and_rejects_regular_file( ) -> None: nested = tmp_path / "private" / "nested" ensure_secure_directory(nested) - assert stat.S_IMODE(nested.stat().st_mode) == 0o700 + assert_private_directory(tmp_path / "private") + assert_private_directory(nested) regular = tmp_path / "regular" regular.write_text("not a directory") with pytest.raises(GatewayError) as caught: ensure_secure_directory(regular) - assert caught.value.diagnostic == "state_directory_chain_error:NotADirectoryError" + # POSIX opens the path as a directory; Windows looks at its type first. + expected = ( + "state_directory_component_not_directory" + if sys.platform == "win32" + else "state_directory_chain_error:NotADirectoryError" + ) + assert caught.value.diagnostic == expected +@posix_only def test_secure_directory_supports_execute_only_trusted_ancestor( tmp_path: Path, ) -> None: @@ -66,6 +76,7 @@ def test_secure_directory_supports_execute_only_trusted_ancestor( assert stat.S_IMODE(state.stat().st_mode) == 0o700 +@posix_only def test_secure_directory_creation_is_private_under_restrictive_umask( tmp_path: Path, ) -> None: @@ -79,6 +90,7 @@ def test_secure_directory_creation_is_private_under_restrictive_umask( assert stat.S_IMODE(state.stat().st_mode) == 0o700 +@posix_only def test_secure_directory_wraps_creation_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -92,6 +104,7 @@ def fail_mkdir(*_args: Any, **_kwargs: Any) -> None: _assert_validation(caught, "state_directory_chain_error:PermissionError") +@posix_only def test_secure_directory_rejects_real_nonsticky_writable_ancestor( tmp_path: Path, ) -> None: @@ -111,6 +124,7 @@ def test_secure_directory_rejects_real_nonsticky_writable_ancestor( ) +@posix_only def test_secure_directory_allows_one_sticky_temp_boundary_but_not_nested_shared( tmp_path: Path, ) -> None: @@ -132,6 +146,7 @@ def test_secure_directory_allows_one_sticky_temp_boundary_but_not_nested_shared( ) +@posix_only @pytest.mark.parametrize("unsafe_mode", [0o710, 0o1700, 0o2700, 0o4700]) def test_secure_directory_requires_exact_final_mode( tmp_path: Path, @@ -145,6 +160,7 @@ def test_secure_directory_requires_exact_final_mode( _assert_validation(caught, "state_directory_permissions_must_be_0700") +@posix_only def test_secure_directory_resolves_only_trusted_nonfinal_aliases( tmp_path: Path, ) -> None: @@ -167,6 +183,7 @@ def test_secure_directory_resolves_only_trusted_nonfinal_aliases( _assert_validation(caught, "state_directory_untrusted_symlink_ancestor") +@posix_only def test_secure_directory_checks_every_alias_hop_in_target_chain( tmp_path: Path, ) -> None: @@ -187,6 +204,7 @@ def test_secure_directory_checks_every_alias_hop_in_target_chain( assert not (target / "state").exists() +@posix_only def test_secure_directory_resolves_relative_alias_and_rejects_alias_loop( tmp_path: Path, ) -> None: @@ -205,6 +223,7 @@ def test_secure_directory_resolves_relative_alias_and_rejects_alias_loop( _assert_validation(caught, "state_directory_alias_loop") +@posix_only def test_secure_directory_wraps_readlink_failure_without_following( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -227,6 +246,7 @@ def fail_readlink(_path: Path) -> str: assert not (target / "state").exists() +@posix_only def test_secure_directory_symlink_injection_race_is_never_followed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -288,13 +308,17 @@ def test_secure_directory_rejects_ambiguous_lexical_paths(unsafe: Path) -> None: _assert_validation(caught, "state_directory_path_invalid") -def test_token_store_rejects_invalid_scope_and_non_regular_lock(tmp_path: Path) -> None: +def test_token_store_rejects_invalid_scope(tmp_path: Path) -> None: store = SecureTokenStore(tmp_path / "state") for scope in ("../escape", "UPPERCASE", "", "a" * 97): with pytest.raises(GatewayError) as caught: store.load_locked(scope) _assert_validation(caught, "invalid_token_scope") + +@posix_only +def test_token_store_rejects_a_directory_as_its_lock(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") lock = tmp_path / "state" / "tokens" / "account-1.lock" lock.mkdir(mode=0o700) with pytest.raises(GatewayError) as caught: @@ -302,6 +326,7 @@ def test_token_store_rejects_invalid_scope_and_non_regular_lock(tmp_path: Path) _assert_validation(caught, "token_lock_error:IsADirectoryError") +@posix_only def test_fresh_token_lock_is_private_under_restrictive_umask(tmp_path: Path) -> None: old_umask = os.umask(0o777) fd: int | None = None @@ -318,6 +343,7 @@ def test_fresh_token_lock_is_private_under_restrictive_umask(tmp_path: Path) -> assert stat.S_IMODE(lock.stat().st_mode) == 0o600 +@posix_only def test_existing_token_lock_permissions_are_rejected_without_repair( tmp_path: Path, ) -> None: @@ -623,7 +649,7 @@ def test_token_store_rejects_malformed_records( ) -> None: store = SecureTokenStore(tmp_path / "state") token_path = tmp_path / "state" / "tokens" / "account-42.json" - token_path.write_text(payload) + token_path.write_bytes(private_fs.seal(payload.encode(), purpose=security_module._token_seal_purpose("account-42"))) os.chmod(token_path, 0o600) with pytest.raises(GatewayError) as caught: store.load_locked("account-42") diff --git a/tests/test_short_commands.py b/tests/test_short_commands.py index b2c5d60..1b0bc1b 100644 --- a/tests/test_short_commands.py +++ b/tests/test_short_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import json import os from pathlib import Path from types import SimpleNamespace @@ -13,11 +14,12 @@ from kwork.schema.actor import Actor import kwork_mcp -from kwork_mcp.bootstrap import _connection_instructions, run_bootstrap_cli +from kwork_mcp.bootstrap import _account_connected, _connection_instructions, run_bootstrap_cli from kwork_mcp.config import ( AccountSelectionError, KworkConfig, bound_account_ids, + client_default_state_dir, load_server_config, select_bound_account, ) @@ -25,6 +27,7 @@ from kwork_mcp.security import SecureTokenStore, TokenRecord from kwork_mcp.server import create_server from kwork_mcp.version import __version__ +from tests.platforms import posix_only class TTYBuffer(io.StringIO): @@ -38,6 +41,7 @@ def state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: if name.upper().startswith("KWORK_"): monkeypatch.delenv(name, raising=False) monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "home")) monkeypatch.delenv("XDG_STATE_HOME", raising=False) path = tmp_path / "state" path.mkdir(mode=0o700) @@ -347,12 +351,12 @@ def test_login_prints_the_shortest_commands_for_the_default_setup( state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - default = Path(os.environ["HOME"]) / ".local" / "state" / "kwork-mcp" + default = client_default_state_dir() monkeypatch.setenv("KWORK_STATE_DIR", str(default)) lines = _instructions().splitlines() - assert lines[0] == "Аккаунт found-user (user_id 42) подключён." + assert _account_connected(Actor(id=42, username="found-user")) == "Аккаунт found-user (user_id 42) подключён.\n" assert f" claude mcp add kwork --scope user -- uvx kwork-mcp@{__version__}" in lines assert f" codex mcp add kwork -- uvx kwork-mcp@{__version__}" in lines assert any("https://simonether.github.io/kwork-mcp/" in line for line in lines) @@ -379,6 +383,7 @@ def deny(_self: Path) -> Any: assert "-e KWORK_EXPECTED_USER_ID=42" in _instructions() +@posix_only def test_login_quotes_a_state_directory_with_spaces( state_dir: Path, monkeypatch: pytest.MonkeyPatch, @@ -586,7 +591,7 @@ def test_login_prints_a_claude_desktop_entry_with_the_absolute_uvx_path( assert ( f'"kwork-com": {{"command": "/opt/tools/bin/uvx", "args": ["kwork-mcp@{__version__}"], ' - f'"env": {{"KWORK_SITE": "com", "KWORK_STATE_DIR": "{state_dir}"}}}}' + f'"env": {{"KWORK_SITE": "com", "KWORK_STATE_DIR": {json.dumps(str(state_dir), ensure_ascii=False)}}}}}' ) in output @@ -595,7 +600,7 @@ def test_login_falls_back_to_plain_uvx_when_it_is_not_on_path( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr("kwork_mcp.bootstrap.shutil.which", lambda _name: None) - default = Path(os.environ["HOME"]) / ".local" / "state" / "kwork-mcp" + default = client_default_state_dir() monkeypatch.setenv("KWORK_STATE_DIR", str(default)) assert f'"kwork": {{"command": "uvx", "args": ["kwork-mcp@{__version__}"]}}' in _instructions() diff --git a/tests/test_windows_support.py b/tests/test_windows_support.py new file mode 100644 index 0000000..ae95415 --- /dev/null +++ b/tests/test_windows_support.py @@ -0,0 +1,605 @@ +"""Windows support: owner and DACL checks, path syntax, locks, sealed tokens and client hints. + +The pure pieces run everywhere; the Windows-only tests exercise the real +Win32 calls on the Windows CI job. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import subprocess +import sys +import uuid +from collections.abc import Callable, Iterator +from pathlib import Path, PureWindowsPath +from typing import Any + +import pytest +from kwork.schema.actor import Actor + +import kwork_mcp +from kwork_mcp import private_fs +from kwork_mcp.bootstrap import _command_line, _connection_instructions, _warn_if_elevated, run_bootstrap_cli +from kwork_mcp.bootstrap import main as bootstrap_main +from kwork_mcp.config import KworkConfig, _default_state_dir, client_default_state_dir +from kwork_mcp.coordination import CoordinationStore +from kwork_mcp.errors import GatewayError +from kwork_mcp.models import ErrorCode +from kwork_mcp.security import SecureTokenStore, TokenRecord, ensure_secure_directory +from tests.platforms import posix_only, windows_only + +USER = "S-1-5-21-1004336348-1177238915-682003330-1001" +OTHER_USER = "S-1-5-21-1004336348-1177238915-682003330-1002" +USERS = "S-1-5-32-545" +EVERYONE = "S-1-1-0" + +# --- who may open a Windows object + +FULL = 0x1F01FF +MODIFY = 0x1301BF +READ_EXECUTE = 0x1200A9 +AUTHENTICATED_USERS = "S-1-5-11" + + +def allow(sid: str, mask: int = FULL, *, inherit_only: bool = False) -> tuple[str, str | None, int, bool]: + return ("allow", sid, mask, inherit_only) + + +def test_owner_only_dacl_with_system_and_administrators_is_private() -> None: + entries = [ + allow(USER), + allow(private_fs.SYSTEM_SID), + allow(private_fs.ADMINISTRATORS_SID), + ("deny", None, 0, False), + ] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(private_fs.ADMINISTRATORS_SID, entries, user_sid=USER) is None + + +@pytest.mark.parametrize("owner", [OTHER_USER, USERS, None]) +def test_an_object_owned_by_someone_else_is_not_private(owner: str | None) -> None: + assert private_fs.windows_acl_problem(owner, [allow(USER)], user_sid=USER) == "wrong_owner" + + +@pytest.mark.parametrize( + "entries", + [ + None, + [allow(USER), allow(USERS, READ_EXECUTE)], + [allow(EVERYONE)], + [allow(OTHER_USER, 0x1)], + [allow(USER), ("other", None, 0, False)], + ], + ids=["null-dacl", "users-read", "everyone-full", "other-user", "unknown-ace"], +) +def test_a_dacl_that_lets_anyone_else_in_is_not_private(entries: list[Any] | None) -> None: + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) == "acl_not_private" + + +def test_owner_rights_count_as_the_trusted_owner() -> None: + # os.mkdir(path, 0o700) on Windows grants SYSTEM, Administrators and OWNER RIGHTS. + entries = [allow(private_fs.SYSTEM_SID), allow(private_fs.ADMINISTRATORS_SID), allow(private_fs.OWNER_RIGHTS_SID)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(private_fs.OWNER_RIGHTS_SID, entries, user_sid=USER) == "wrong_owner" + + +def test_an_allow_entry_without_rights_grants_nothing() -> None: + entries = [allow(USER), allow(EVERYONE, 0)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + + +def test_a_directory_must_not_pass_anyone_else_on_to_new_files() -> None: + entries = [allow(USER), allow(USERS, READ_EXECUTE, inherit_only=True)] + + # A file ignores entries meant for what is created inside it later. + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER) is None + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER, directory=True) == "acl_not_private" + + +def test_a_directory_may_pass_rights_on_to_the_creator() -> None: + entries = [allow(USER), allow(private_fs.CREATOR_OWNER_SID, 0x10000000, inherit_only=True)] + + assert private_fs.windows_acl_problem(USER, entries, user_sid=USER, directory=True) is None + + +# --- who may swap a directory above the state directory + + +def test_a_system_managed_ancestor_is_trusted() -> None: + # C:\ on Windows 10/11: TrustedInstaller owns it; users may add folders and + # pass Modify on to them, but may not rename or re-permission the root. + entries = [ + allow(private_fs.ADMINISTRATORS_SID), + allow(private_fs.SYSTEM_SID), + allow(USERS, READ_EXECUTE), + allow(AUTHENTICATED_USERS, MODIFY, inherit_only=True), + allow(AUTHENTICATED_USERS, 0x4), + ] + + problem = private_fs.windows_ancestor_problem(private_fs.TRUSTED_INSTALLER_SID, entries, user_sid=USER, root=True) + + assert problem is None + + +def test_an_ancestor_others_may_rename_is_rejected() -> None: + # A folder made under a drive root inherits Authenticated Users: Modify. + entries = [allow(USER), allow(AUTHENTICATED_USERS, MODIFY)] + + assert ( + private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=False) == "untrusted_writable_ancestor" + ) + # Modify on a drive root grants DELETE, which cannot rename a root. + assert private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=True) is None + + +@pytest.mark.parametrize( + "entries", + [None, [allow(EVERYONE, 0x40)], [allow(OTHER_USER, 0x40000)], [("other", None, 0, False)]], + ids=["null-dacl", "delete-child", "write-dac", "unknown-ace"], +) +def test_an_ancestor_others_may_take_over_is_rejected(entries: list[Any] | None) -> None: + problem = private_fs.windows_ancestor_problem(USER, entries, user_sid=USER, root=True) + + assert problem == "untrusted_writable_ancestor" + + +def test_an_ancestor_owned_by_someone_else_is_rejected() -> None: + problem = private_fs.windows_ancestor_problem(OTHER_USER, [allow(USER)], user_sid=USER, root=False) + + assert problem == "untrusted_ancestor_owner" + + +# --- Windows path syntax that can name something else + + +@pytest.mark.parametrize( + "raw", + [ + r"C:\Users\Имя Фамилия\AppData\Local\kwork-mcp", + r"D:\state\kwork.mcp", + ], +) +def test_ordinary_windows_paths_are_accepted(raw: str) -> None: + assert private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) is False + + +@pytest.mark.parametrize( + "raw", + [ + r"\\server\share\kwork", + r"\\?\C:\kwork", + r"\\.\C:\kwork", + r"\kwork", + r"C:kwork", + r"C:\kwork:stream", + r"C:\state\NUL", + r"C:\state\con.txt", + r"C:\state\COM1", + r"C:\state\name.", + r"C:\state\name ", + r"C:\state\..\other", + ], +) +def test_ambiguous_windows_paths_are_rejected(raw: str) -> None: + assert private_fs.windows_path_is_ambiguous(PureWindowsPath(raw)) is True + + +# --- interprocess locks + + +def test_a_lock_is_exclusive_until_released(tmp_path: Path) -> None: + path = tmp_path / "state.lock" + first = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + second = os.open(path, os.O_RDWR) + try: + private_fs.try_lock(first) + with pytest.raises(BlockingIOError): + private_fs.try_lock(second) + private_fs.unlock(first) + private_fs.try_lock(second) + private_fs.unlock(second) + finally: + os.close(first) + os.close(second) + + +def test_closing_the_file_releases_its_lock(tmp_path: Path) -> None: + path = tmp_path / "state.lock" + first = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + private_fs.try_lock(first) + os.close(first) + + second = os.open(path, os.O_RDWR) + try: + private_fs.try_lock(second) + private_fs.unlock(second) + finally: + os.close(second) + + +# --- sealed secrets + + +def test_a_sealed_secret_opens_with_the_same_purpose() -> None: + secret = b'{"token":"sealed-token-sentinel"}' + + sealed = private_fs.seal(secret, purpose="kwork-mcp token account-42") + + assert private_fs.unseal(sealed, purpose="kwork-mcp token account-42") == secret + + +@posix_only +def test_posix_relies_on_file_modes_and_stores_secrets_as_is() -> None: + assert private_fs.seal(b"secret", purpose="any") == b"secret" + + +@windows_only +def test_windows_encrypts_a_sealed_secret_for_the_current_user_and_purpose() -> None: + sealed = private_fs.seal(b"sealed-token-sentinel", purpose="kwork-mcp token account-42") + + assert b"sealed-token-sentinel" not in sealed + with pytest.raises(OSError): + private_fs.unseal(sealed, purpose="kwork-mcp token account-77") + + +# --- where the state lives + + +def test_windows_keeps_state_in_the_profile_not_in_app_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + # Windows shows packaged (MSIX) apps such as Claude Desktop their own copy + # of AppData, so the server they start and the terminal would split state. + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg")) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "profile")) + + assert _default_state_dir() == tmp_path / "profile" / ".local" / "state" / "kwork-mcp" + assert client_default_state_dir() == _default_state_dir() + + +def test_posix_clients_start_the_server_without_xdg_state_home( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home")) + + assert client_default_state_dir() == tmp_path / "home" / ".local" / "state" / "kwork-mcp" + + +# --- what login prints on Windows + + +def test_windows_commands_are_quoted_for_powershell_and_cmd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "win32") + + command = _command_line( + ["claude", "mcp", "add", "kwork", "-e", r"KWORK_STATE_DIR=C:\Users\Имя Фамилия\kwork", "--", "uvx", "kwork"] + ) + + assert command == r'claude mcp add kwork -e "KWORK_STATE_DIR=C:\Users\Имя Фамилия\kwork" -- uvx kwork' + + +def test_windows_login_names_the_windows_client_config_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + for name in tuple(os.environ): + if name.upper().startswith("KWORK_"): + monkeypatch.delenv(name, raising=False) + uvx = r"C:\Users\Имя Фамилия\.local\bin\uvx.exe" + monkeypatch.setattr(shutil, "which", lambda _name: uvx) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "profile")) + state_dir = tmp_path / "profile" / ".local" / "state" / "kwork-mcp" + config = KworkConfig(expected_user_id=42, state_dir=state_dir) + + output = _connection_instructions(Actor(id=42, username="found-user"), config, legacy_file_retained=False) + + assert "KWORK_STATE_DIR" not in output + assert json.dumps(uvx, ensure_ascii=False) in output + # A packaged Claude Desktop keeps its config in its own AppData copy. + assert "Settings → Developer → Edit Config" in output + assert "%APPDATA%" not in output + assert r"%USERPROFILE%\.cursor\mcp.json" in output + assert "~/.cursor" not in output + + +@pytest.mark.asyncio +@pytest.mark.parametrize("argv", [[], ["logout"]]) +async def test_windows_points_a_non_terminal_run_to_powershell( + monkeypatch: pytest.MonkeyPatch, argv: list[str] +) -> None: + # Git Bash in its own mintty window hands programs pipes, not a console. + monkeypatch.setattr(sys, "platform", "win32") + stderr = io.StringIO() + + code = await run_bootstrap_cli(argv, stdin=io.StringIO(), stdout=io.StringIO(), stderr=stderr) + + assert code == 2 + assert "PowerShell" in stderr.getvalue() + + +@pytest.mark.parametrize(("elevated", "warned"), [(True, True), (False, False), (OSError("no token"), False)]) +def test_windows_login_warns_when_run_as_administrator( + monkeypatch: pytest.MonkeyPatch, + elevated: bool | OSError, + warned: bool, +) -> None: + # An elevated window may belong to another Windows account, whose profile + # and DPAPI key the client's server never sees. + def is_elevated() -> bool: + if isinstance(elevated, OSError): + raise elevated + return elevated + + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(private_fs, "is_elevated", is_elevated) + stderr = io.StringIO() + + _warn_if_elevated(stderr) + + assert ("администратора" in stderr.getvalue()) is warned + + +# --- console encoding + + +@pytest.mark.parametrize("name", ["stdout", "stderr"]) +def test_windows_output_streams_are_switched_to_utf8(monkeypatch: pytest.MonkeyPatch, name: str) -> None: + raw = io.BytesIO() + monkeypatch.setattr(sys, name, io.TextIOWrapper(raw, encoding="cp1252")) + + kwork_mcp.use_utf8_streams() + stream = getattr(sys, name) + stream.write("сервер запущен ✓") + stream.flush() + + assert raw.getvalue().decode("utf-8") == "сервер запущен ✓" + + +def test_unusual_output_streams_are_left_alone(monkeypatch: pytest.MonkeyPatch) -> None: + stream = io.StringIO() + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + + kwork_mcp.use_utf8_streams() + + assert sys.stdout is stream + assert sys.stderr is stream + + +def test_windows_cli_entry_point_switches_to_utf8_before_writing(monkeypatch: pytest.MonkeyPatch) -> None: + raw = io.BytesIO() + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(raw, encoding="cp1252")) + + bootstrap_main(["--help"]) + sys.stdout.flush() + + assert "login" in raw.getvalue().decode("utf-8") + + +# --- the real Win32 calls + + +def _grant(path: Path, grant: str) -> None: + subprocess.run(["icacls", str(path), "/grant", grant], check=True, capture_output=True) + + +def _validation_diagnostic(caught: pytest.ExceptionInfo[GatewayError]) -> str | None: + return caught.value.diagnostic + + +@windows_only +def test_windows_state_directory_gets_an_owner_only_protected_dacl(tmp_path: Path) -> None: + shared = tmp_path / "shared" + shared.mkdir() + # Everyone may write in the parent and would inherit into a plain mkdir. + _grant(shared, "*S-1-1-0:(OI)(CI)F") + + state = ensure_secure_directory(shared / "nested" / "state") + + assert state.is_dir() + assert private_fs.directory_problem(state) is None + assert private_fs.directory_problem(shared / "nested") is None + + +@windows_only +def test_windows_rejects_a_state_directory_that_others_can_open(tmp_path: Path) -> None: + state = tmp_path / "state" + state.mkdir() + _grant(state, "*S-1-1-0:(OI)(CI)F") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(state) + + assert _validation_diagnostic(caught) == "state_directory_acl_not_private" + + +@windows_only +def test_windows_rejects_a_junction_as_the_state_directory(tmp_path: Path) -> None: + target = ensure_secure_directory(tmp_path / "target") + junction = tmp_path / "junction" + subprocess.run(["cmd", "/c", "mklink", "/J", str(junction), str(target)], check=True, capture_output=True) + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(junction) + + assert _validation_diagnostic(caught) == "state_directory_final_symlink" + + +@windows_only +def test_windows_rejects_ambiguous_state_paths(tmp_path: Path) -> None: + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(tmp_path / "NUL") + + assert _validation_diagnostic(caught) == "state_directory_path_invalid" + + +def _save(store: SecureTokenStore, scope: str, record: TokenRecord) -> None: + lock = store.acquire_lock(scope) + try: + store.save_locked(scope, record) + finally: + store.release_lock(lock) + + +def _load(store: SecureTokenStore, scope: str) -> TokenRecord | None: + lock = store.acquire_lock(scope) + try: + return store.load_locked(scope) + finally: + store.release_lock(lock) + + +@windows_only +def test_windows_token_file_is_encrypted_and_bound_to_its_account(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + record = TokenRecord.create(user_id=42, username="found-user", token="stored-token-sentinel") + _save(store, "account-42", record) + stored = tmp_path / "state" / "tokens" / "account-42.json" + + assert b"stored-token-sentinel" not in stored.read_bytes() + assert _load(store, "account-42") == record + + (tmp_path / "state" / "tokens" / "account-77.json").write_bytes(stored.read_bytes()) + with pytest.raises(GatewayError) as caught: + _load(store, "account-77") + # A record DPAPI cannot open here asks for a new login, not a config fix. + assert caught.value.code is ErrorCode.AUTH_EXPIRED + assert caught.value.diagnostic == "token_sealed_for_another_user" + + +@windows_only +def test_windows_rejects_a_token_file_that_others_can_read(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + _save(store, "account-42", TokenRecord.create(user_id=42, username="found-user", token="stored-token-sentinel")) + _grant(tmp_path / "state" / "tokens" / "account-42.json", "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + _load(store, "account-42") + + assert _validation_diagnostic(caught) == "private_file_acl_not_private" + + +@windows_only +def test_windows_rejects_a_token_lock_that_others_can_open(tmp_path: Path) -> None: + store = SecureTokenStore(tmp_path / "state") + store.release_lock(store.acquire_lock("account-42")) + _grant(tmp_path / "state" / "tokens" / "account-42.lock", "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + store.acquire_lock("account-42") + + assert _validation_diagnostic(caught) == "token_lock_acl_not_private" + + +@windows_only +def test_windows_rejects_a_ledger_that_others_can_open(config_factory: Callable[..., KworkConfig]) -> None: + config = config_factory() + store = CoordinationStore(config) + _grant(store.path, "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + CoordinationStore(config) + + assert _validation_diagnostic(caught) == "coordination_db_permissions" + + +@windows_only +@pytest.mark.asyncio +async def test_windows_rejects_a_writer_lock_that_others_can_open( + config_factory: Callable[..., KworkConfig], +) -> None: + store = CoordinationStore(config_factory()) + async with store.writer_guard("account-42"): + pass + (lock,) = store.path.parent.glob("writer-*.lock") + _grant(lock, "*S-1-5-32-545:(R)") + + with pytest.raises(GatewayError) as caught: + async with store.writer_guard("account-42"): + pass + + assert _validation_diagnostic(caught) == "writer_lock_acl_not_private" + + +@windows_only +def test_windows_replace_waits_out_a_brief_sharing_violation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "new" + target = tmp_path / "current" + source.write_bytes(b"new") + target.write_bytes(b"old") + real_replace = private_fs.windows.replace + attempts = 0 + + def busy_twice(src: str, dst: Path) -> None: + nonlocal attempts + attempts += 1 + if attempts <= 2: + # ERROR_SHARING_VIOLATION, as when an antivirus holds the file. + raise OSError(None, "busy", None, 32) + real_replace(src, dst) + + monkeypatch.setattr(private_fs.windows, "replace", busy_twice) + + private_fs.replace(str(source), target) + + assert attempts == 3 + assert target.read_bytes() == b"new" + + +@windows_only +def test_windows_rejects_a_state_directory_that_passes_access_on(tmp_path: Path) -> None: + state = tmp_path / "state" + state.mkdir() + # Inherit-only: Users get nothing on the directory, but read on every new file. + _grant(state, "*S-1-5-32-545:(OI)(CI)(IO)(R)") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(state) + + assert _validation_diagnostic(caught) == "state_directory_acl_not_private" + + +@pytest.fixture +def outside_profile(tmp_path: Path) -> Iterator[Path]: + """A fresh directory name at the drive root, outside the user's profile.""" + + path = Path(tmp_path.anchor) / f"kwork-mcp-test-{uuid.uuid4().hex}" + try: + yield path + finally: + shutil.rmtree(path, ignore_errors=True) + + +@windows_only +def test_windows_accepts_a_state_directory_it_creates_outside_the_profile(outside_profile: Path) -> None: + state = ensure_secure_directory(outside_profile / "state") + + assert private_fs.directory_problem(state) is None + + +@windows_only +def test_windows_rejects_a_state_directory_under_a_folder_others_may_rename(outside_profile: Path) -> None: + outside_profile.mkdir() + _grant(outside_profile, "*S-1-5-11:(OI)(CI)M") + + with pytest.raises(GatewayError) as caught: + ensure_secure_directory(outside_profile / "state") + + assert _validation_diagnostic(caught) == "state_directory_untrusted_writable_ancestor"