diff --git a/.github/workflows/adaptive-project-audit.yml b/.github/workflows/adaptive-project-audit.yml new file mode 100644 index 0000000..5f1a794 --- /dev/null +++ b/.github/workflows/adaptive-project-audit.yml @@ -0,0 +1,72 @@ +name: Adaptive full-project audit + +on: + pull_request: + paths: + - 'audit/**' + - '.github/workflows/adaptive-project-audit.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: adaptive-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + audit: + name: Audit (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + CI: 'true' + PYTHONIOENCODING: utf-8 + PYTHONUTF8: '1' + steps: + - name: Checkout audit harness + uses: actions/checkout@v4 + with: + path: harness + persist-credentials: false + - name: Checkout exact GitMir baseline + uses: actions/checkout@v4 + with: + ref: 0c9db4635407cb30faab7a3f1887634db097324e + path: target + fetch-depth: 0 + persist-credentials: false + - name: Checkout exact Adaptive quality-engine source + uses: actions/checkout@v4 + with: + repository: Dimkox/adaptive-grok-build-pro + ref: be752872f3e5a9d6fe179872d9c8bdaec4338238 + path: adaptive + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22.18.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install audit-only Python dependencies + run: python -m pip install PyYAML==6.0.2 + - name: Install isolated browser test tooling + if: runner.os == 'Linux' + run: | + python -m pip install playwright==1.55.0 + python -m playwright install --with-deps chromium + - name: Run all checks without changing product source + run: python harness/audit/adaptive_run.py --target target --factory adaptive --output audit-results + - name: Preserve exact-SHA evidence, including failures + if: always() + uses: actions/upload-artifact@v4 + with: + name: adaptive-audit-${{ runner.os }} + path: audit-results + if-no-files-found: error + retention-days: 14 diff --git a/audit/README.md b/audit/README.md new file mode 100644 index 0000000..defbb69 --- /dev/null +++ b/audit/README.md @@ -0,0 +1,169 @@ +# GitMir: аудит через Adaptive — 10 сентября 2026 + +**Вердикт: приёмка не пройдена.** Завершён воспроизводимый прогон на Windows, macOS и Linux. Подтверждены шесть дефектов GitMir, отдельно — неработающий legacy CI и несовместимость проверяющего механизма Adaptive с Windows. Успешные 20 тестов Google Cloud не доказывают работоспособность всего приложения. + +Это аудит и набор регрессионных проверок, **не исправление продукта, не релиз и не аттестация Trust CI**. Ветка не должна сливаться автоматически. + +## 1. Что именно проверялось + +| Объект | Зафиксированная версия | +|---|---| +| GitMir | `Dimkox/gitmir-codex-control@0c9db4635407cb30faab7a3f1887634db097324e` | +| Проверяющий код Adaptive | `Dimkox/adaptive-grok-build-pro@be752872f3e5a9d6fe179872d9c8bdaec4338238` | +| Код стенда окончательного прогона | `2152d81b136a83e17b6516c62800f4473928c68f` | +| Blob `adaptive_run.py` | `5bad65c2533a549af04776ad18ef09ba5bfe86d4` | +| PR аудита | [#5](https://github.com/Dimkox/gitmir-codex-control/pull/5) | +| Основной прогон | [34522486519](https://github.com/Dimkox/gitmir-codex-control/actions/runs/34522486519) | +| Первый диагностический прогон | [34521485096](https://github.com/Dimkox/gitmir-codex-control/actions/runs/34521485096) | + +Все три job основного прогона завершились с `failure`, а выгрузка артефактов — с `success`. Результаты получены на hosted runners GitHub Actions, не на пользовательском ноутбуке или сервере claw. Node — 22.18.0; Python — 3.12.10 на Windows/macOS и 3.12.14 на Linux. + +Проверялись полный список из **81 отслеживаемого файла**, зависимости, типы, синтаксис, регистрация навыков, существующие тесты, установщики, выбранные HTTP-сценарии, регрессии безопасности и сохранности данных. Дополнительно выполнена загрузка панели в Chromium. Это не утверждение о ручном построчном ревью каждого файла или полном прохождении каждой функции интерфейса. + +## 2. Измеренные результаты + +Числа ниже — записи проверок стенда, а не количество уникальных unit-тестов и не процент готовности продукта. Запуск `node --test` с 20 вложенными тестами считается одной записью. + +| ОС | PASS | FAIL | BLOCKED | SKIP | INFO | +|---|---:|---:|---:|---:|---:| +| Linux | 55 | 6 | 0 | 4 | 2 | +| macOS | 54 | 5 | 0 | 6 | 2 | +| Windows | 43 | 5 | 1 | 6 | 2 | + +В каждой строке один `FAIL` относится к эвристике поиска секретов: она обнаружила синтетическую тестовую строку, а не подтверждённую утечку. Исходные результаты сканера сохранены без перекрашивания в зелёный. Windows `BLOCKED` — падение самого Adaptive, описанное ниже. + +**Успешно на всех трёх ОС:** `npm ci --ignore-scripts`, `npm run typecheck`, все 20 автоматически найденных Node-тестов, `npm audit --omit=dev --json`, `npm pack --dry-run --json --ignore-scripts`, синтаксические проверки восьми JS/MJS/TS-файлов, разбор PowerShell, JSON и регистра навыков. Чистая и повторная установка 12 навыков во временный HOME прошли; сохранность постороннего каталога проверялась отдельно и на Windows нарушена. + +HTTP-проверки подтвердили запуск панели, отдачу статики и 12 текстов навыков, чтение состояния, добавление/редактирование/удаление временного проекта и защиту от обычного cross-origin запроса. На Linux Chromium загрузил начальную панель без `pageerror` и ответов HTTP 4xx/5xx; снимок есть в артефакте. + +`npm audit` сообщил ноль известных уязвимостей в проверенных production-зависимостях. Это не сканирование vendored ELK, не проверка всей цепочки поставки и не доказательство отсутствия уязвимостей приложения. Упаковка dry-run не равнозначна успешной публикации пакета. + +## 3. Подтверждённые дефекты GitMir + +Приоритеты ниже — оценка этого ревью, не рассчитанный CVSS. P1 означает исправление до расширения функциональности и использования на важных данных; P2 — обязательное исправление в ближайшей стабилизации. + +### GM-01 · P1 · Linux: интерпретация имени папки как shell-кода + +**Место:** [server.ts, openInTerminal](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/server.ts). **Проверка:** `linux-terminal-treats-project-path-literally` — FAIL на Linux. + +Путь помещается через `JSON.stringify` в строку `bash -lc`. Двойные кавычки не превращают shell-подстановки в обычный текст. Стенд создал существующий временный каталог с безвредной подстановкой в имени, вызвал настоящий `/api/open`, перехватил аргументы запуска терминала и выполнил полученную строку в изолированном каталоге. В результате появился контрольный файл, которого не должно было быть. + +Настоящий графический терминал и Codex не запускались. Доказан разбор части имени папки как команды; удалённая эксплуатация через браузер этим экспериментом не доказывается. + +**Исправление:** не встраивать путь в shell-строку. Передавать каталог отдельным аргументом либо через `cwd`; если shell действительно нужен, использовать постоянный текст команды и позиционные параметры. Критерий приёмки: пути с пробелами, апострофами, `$()`, обратными кавычками и Unicode остаются буквальными, контрольные файлы не появляются. + +### GM-02 · P1 · Неполная проверка Host + +**Место:** [server.ts, sameOrigin](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/server.ts). **Проверка:** `http-reject-host-suffix` — FAIL на всех трёх ОС. + +Запрос к `/api/projects` с Host `localhost.attacker.invalid:` и `Sec-Fetch-Site: same-origin` получил **200 вместо ожидаемого 403**. Регулярное выражение принимает начало `localhost`, не требуя окончания имени хоста. + +Это дефект границы доверия локального HTTP API. Не следует автоматически превращать его в утверждение о доказанном захвате машины: полная DNS-rebinding цепочка и браузерная эксплуатация не проверялись; отдельная проверка чужого Origin успешно возвращает 403. + +**Исправление:** строго разбирать authority и сравнивать точные разрешённые hostname и порт, отдельно проверяя Origin и Fetch Metadata. Критерий приёмки: домены с разрешённым префиксом отклоняются; документированный loopback-доступ продолжает работать. + +### GM-03 · P1 · Windows-установщик удаляет существующие пользовательские данные + +**Место:** [install.ps1](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/install.ps1). **Проверка:** `installer-preserves-existing-directory` — FAIL на Windows. + +Если в `.agents/skills/` уже находится обычный каталог, установщик рекурсивно удаляет его перед созданием junction. В тесте исчез пользовательский контрольный файл. Удаление воспроизведено только в специально созданном временном HOME; реальные файлы пользователя не затрагивались. + +**Исправление:** отличать собственные ссылки/junction от обычных каталогов и чужих ссылок. На коллизии сохранять данные и завершаться с понятным отказом либо использовать явно согласованный backup. Критерий приёмки: чужой каталог и его содержимое сохраняются; повторная установка собственной ссылки идемпотентна. + +### GM-04 · P1 · Создание задачи может затереть предыдущую + +**Место:** [server.ts, POST /api/task](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/server.ts). **Проверка:** `http-task-creation-no-overwrite` — FAIL на всех трёх ОС. + +Два успешных запроса с одинаковым заголовком в пределах одной секунды возвращают одно и то же имя файла. Сервер использует секундную метку времени и обычную перезаписывающую запись. Первая задача не получает отдельного файла. + +**Исправление:** уникальный идентификатор и эксклюзивное создание с обработкой коллизии; одной замены секунд миллисекундами недостаточно для гарантии. Критерий приёмки: серия параллельных одноимённых задач сохраняет все записи и их содержимое, без одинаковых идентификаторов. + +### GM-05 · P2 · Unix-скрипты не готовы к документированному прямому запуску + +**Место:** `install.sh`, `start.command`, `install-shortcut.command`. **Проверка:** `unix-documented-entrypoints-executable` — FAIL на Linux и macOS. + +В checkout у этих файлов отсутствует executable bit. Поэтому инструкция с непосредственным запуском `./install.sh` не соответствует состоянию репозитория. Запуск через `bash install.sh` в отдельной проверке проходит — это другой способ вызова, не опровержение проблемы. + +**Исправление:** сохранить исполняемый режим файлов в Git либо явно изменить инструкции на вызов интерпретатора. Критерий приёмки: новая чистая копия запускается ровно командами из README, без ручного исправления прав. + +### GM-06 · P2 · Валидный JSON неверного типа приводит к 500 + +**Место:** [server.ts, readBody и POST /api/update](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/server.ts). **Проверка:** `http-reject-non-object-json` — FAIL на всех трёх ОС. + +Тело `null` синтаксически является JSON, но сервер пытается деструктурировать его как объект. Получен HTTP 500 с внутренним сообщением TypeError вместо клиентской ошибки. После запроса сервер продолжал обслуживать остальные проверки; падение всего процесса не зафиксировано. + +**Исправление:** единая проверка формы тела и типов полей до выполнения обработчика; отдельно ограничить размер тела. Критерий приёмки: `null`, массив, строка и неверные типы дают документированный 4xx без внутреннего исключения и без записи данных. Лимит размера — дополнительное требование, не отдельная воспроизведённая здесь DoS-атака. + +## 4. CI и сам проверяющий механизм + +### CI-01 · Legacy Webpack workflow не проверяет реальный путь поставки + +[Прогон 34522486453](https://github.com/Dimkox/gitmir-codex-control/actions/runs/34522486453), job `103023128112`: `npx webpack` предлагает интерактивно установить отсутствующий `webpack-cli` и завершается кодом 1. В матрице также есть Node 18/20 при требовании пакета `>=22.18.0`; лог Node 20 содержит `EBADENGINE`. Остальные matrix jobs отменены fail-fast, не считаются успешными. + +Приложение запускает TypeScript непосредственно в Node и не обязано получать искусственную Webpack-сборку. Рекомендация — заменить нерелевантный pipeline на реальные проверки типов, тестов, запуска и упаковки, согласовав Node matrix с `engines`. Workflow в рамках аудита не отключался и не изменялся. + +### AD-01 · Windows: Adaptive падает в управлении подпроцессом + +Во втором прогоне Windows снова получены `WinError 10038` в `selector.select` для pipe и затем `AttributeError: module 'os' has no attribute 'killpg'` при очистке. Трассировка проходит через `.grok-stack/adaptive_grok/architecture_diff.py`, `_run_capped`, `_stop_process`, вызванные проверкой архитектурной привязки. + +Это дефект переносимости проверяющего кода, не GitMir и не отсутствие установленного SDK. Стенд сохранил его как **BLOCKED**, после чего независимо выполнил доступные whole-tree scans и HTTP/installer-проверки. Код фабрики не подменялся и не исправлялся ради зелёного результата. + +Рекомендация: переносимый bounded subprocess runner с ограничением вывода, дедлайном и платформенным завершением дерева процессов; тесты на Windows и POSIX. До исправления полноценный запуск этого verifier на Windows не подтверждён. + +### AD-02 · Срабатывание эвристики на тестовой заглушке + +Whole-tree secret scan указал на [tests/google-cloud.test.mjs](https://github.com/Dimkox/gitmir-codex-control/blob/0c9db4635407cb30faab7a3f1887634db097324e/tests/google-cloud.test.mjs). В нём заданы синтетические значения вроде `SECRET-REFRESH`, чтобы убедиться, что они не попадают в отчёты. Срабатывание классифицировано как **false positive для этой тестовой строки**, не утечка действующего ключа. + +Нельзя ради этого исключать все тесты из проверки секретов. Нужны узкое обоснованное исключение конкретной фикстуры или улучшение классификации; исходный результат сканера оставлен в артефактах. + +## 5. Как использована фабрика и где заканчивается проверка + +Из отдельного неизменённого checkout импортируется настоящий `adaptive_grok.verification.verify`, запускаемый с `mode='pr'`, профилями `base/frontend` и `record=False`. Дополнительно `_secret_scan`, `_contracts`, `_sql_safety` получают весь список отслеживаемых файлов, а не только diff. + +Для проверки snapshot local `origin/HEAD` привязан к уже проверенному `origin/master`, указывающему на исходный SHA. Это локальные Git-метаданные, не новая ветка продукта или изменение удалённого репозитория. Поскольку baseline совпадает с master, проверки changes/specs видят пустой diff; они не выданы за full-tree анализ. Whole-tree сканирование выполнено отдельно. + +На Linux/macOS verifier завершает работу; архитектура и governance не настроены и возвращают SKIP. Ноль проверенных контрактов/SQL и пропущенные проверки не означают подтверждённую enterprise-готовность. На Windows действует ограничение AD-01. + +**Не выполнялись:** оркестрация независимых модельных агентов, реальный Codex-turn, запуск provider execution, подписание receipts, человеческие approvals, Trust CI на claw, `adaptive-trust-ci/verified@...`, production deploy или merge. Не создавались фиктивные `active-route.json`, аттестации или доказательства ревью. + +Фабрика используется как источник исполняемых quality checks. Это не равнозначно доказательству работоспособности всей «автономной фабрики» на произвольном проекте. + +## 6. Покрытие и непроверенные границы + +Из 29 HTTP-маршрутов с точным сравнением pathname стенд вызвал **18 на Linux и 17 на Windows/macOS**. В числе них — отказ отключённого preview и отрицательные сценарии; само наличие вызова не означает покрытие всех ветвей. Динамические маршруты и middleware не входят в знаменатель. Полный список фактически вызванных и пропущенных маршрутов сохранён в `summary.json`. + +На всех трёх ОС не выполнялись: `GET /api/preview-bridge.js`, `GET /api/share/export`, `GET /api/task-file`, `POST /api/pick`, `POST /api/preview-find`, `POST /api/reorder`, `POST /api/reveal`, `POST /api/team/connect`, `POST /api/team/send-task`, `POST /api/team/share-model`, `POST /api/team/share-view`. `/api/open` проверен только на Linux через перехват команды, без GUI терминала. + +Не проверены действующее облачное подключение, ADC, права GCP, реальное обновление Cloud SDK и запись Windows-реестра, live WebSocket relay, графические диалоги на пользовательских desktop-системах, все состояния графа модели и UI, а также включённый preview/proxy. Browser smoke проверял начальную загрузку панели, не каждый элемент интерфейса. + +Отдельные следующие security-review задачи: ограничения чтения тела/таймаутов preview, обработка симлинков и replay входящих relay-сообщений, доступ к локальным файлам через разрешённые маршруты. Они **не входят в шесть подтверждённых дефектов** и не объявлены доказанными уязвимостями без воспроизведения. + +## 7. Артефакты и сохранность исходников + +| Артефакт | GitHub ID | SHA-256 ZIP | +|---|---:|---| +| Windows | `10170244905` | `c40acadb5560d104e81003d1df174e15f68956a94c1eb0bdc1027ab81035eeb1` | +| Linux | `10170244510` | `8f66174d2a217f7cd6387cc3d86c369210e18ffdba878af69e15dcc65bc82c6a` | +| macOS | `10170232479` | `bbae0af08a9f4bdb5e7db3d78af329a681d33be357184b20299630a35ede7cc4` | + +Артефакты доступны в [основном прогоне](https://github.com/Dimkox/gitmir-codex-control/actions/runs/34522486519). В них находятся `summary.json`, инвентаризация SHA-256 81 файла, отчёты доступных проверок фабрики, журналы команд/сервера и Linux-снимок панели. SHA-256 скачанных ZIP проверены и совпадают с опубликованными GitHub digest. Workflow хранит артефакты 14 дней; это срок хранения GitHub, не обещание бессрочного архива. + +Перед и после каждого прогона хеши всех 81 исходного файла сравнивались **в пределах той же ОС**: изменений нет. Изолированные тесты использовали временные каталоги, тестовые данные и отдельный HOME. В продукт, его зависимости, обычные установщики и запуск изменения не внесены. В GitMir добавлены только три файла аудита: этот отчёт, `audit/adaptive_run.py` и `.github/workflows/adaptive-project-audit.yml`. Репозиторий Adaptive не изменён. + +## 8. Воспроизведение и план исправлений + +Стенд доступен в [adaptive_run.py](./adaptive_run.py), workflow — в [adaptive-project-audit.yml](../.github/workflows/adaptive-project-audit.yml). Workflow получает точные исходные SHA, использует `contents: read`, не сохраняет checkout credentials и выгружает evidence даже при ошибках. + +Для локального повторения нужны обе зафиксированные копии репозиториев, Node 22.18.0, Python 3.12, PowerShell и зависимости проекта. На Unix нужен Bash. Audit-only Python зависимости — PyYAML 6.0.2; Linux browser smoke требует Playwright 1.55.0 и установленный Chromium. Отсутствие инструмента отражается как BLOCKED, не PASS. + +При размещении каталогов `target`, `adaptive` и `audit-results` рядом с каталогом checkout аудита команда из корня последнего: + +```text +python audit/adaptive_run.py --target ../target --factory ../adaptive --output ../audit-results +``` + +Это **snapshot-стенд** с закреплёнными SHA, не готовый динамический gate для всех будущих PR. После исправления продукта нужно отдельно выбрать проверяемый candidate SHA и повторить те же регрессии; зелёный результат на старом baseline невозможен, пока он содержит перечисленные дефекты. + +Порядок работ: сначала GM-01/GM-02 — границы исполнения и HTTP-доверия; затем GM-03/GM-04 — сохранность данных; далее GM-05/GM-06 и CI-01. Параллельно исправить AD-01 в фабрике и добавить native Windows regression. После этого расширить функциональные проверки на пропущенные маршруты, relay, включённый preview и UI, а реальные Google Cloud/Codex проверки выполнять в согласованном изолированном окружении. + +**Итог:** приложение проходит существующий узкий тестовый набор, но общий аудит выявляет блокирующие дефекты. Считать его полностью проверенным, безопасным или готовым к production по имеющимся зелёным тестам нельзя. Исправления продукта в этот PR не включены. diff --git a/audit/adaptive_run.py b/audit/adaptive_run.py new file mode 100644 index 0000000..5bad65c --- /dev/null +++ b/audit/adaptive_run.py @@ -0,0 +1,381 @@ +"""Read-only-source, disposable-runtime audit of a pinned GitMir checkout. + +This invokes Adaptive's quality engine, not its deployed Trust CI authority. +No provider, account login, cloud API, human approval, or merge is performed. +""" +from __future__ import annotations + +import argparse +import hashlib +import http.client +import json +import os +from pathlib import Path +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import traceback +from urllib.parse import urlencode + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument('--target', type=Path, required=True) + parser.add_argument('--factory', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + root, factory, out = args.target.resolve(), args.factory.resolve(), args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + results: list[dict] = [] + + def record(name: str, status: str, detail: object) -> None: + if isinstance(detail, bytes): + detail = {'bytes': len(detail), 'sha256': hashlib.sha256(detail).hexdigest()} + item = {'name': name, 'status': status, 'detail': detail} + results.append(item) + print(json.dumps(item, ensure_ascii=True), flush=True) + + def command(name: str, argv: list[str], cwd: Path = root, + env: dict | None = None, timeout: int = 180) -> subprocess.CompletedProcess | None: + try: + actual = argv[:] + if os.name == 'nt' and actual[0] == 'npm': + actual = [os.environ.get('ComSpec', 'cmd.exe'), '/d', '/c', *actual] + proc = subprocess.run(actual, cwd=cwd, env=env, capture_output=True, + text=True, encoding='utf-8', errors='replace', timeout=timeout) + (out / (name + '.log')).write_text(proc.stdout + '\n' + proc.stderr, encoding='utf-8') + record(name, 'pass' if proc.returncode == 0 else 'fail', + {'exit_code': proc.returncode, 'tail': (proc.stdout + proc.stderr)[-6500:]}) + return proc + except Exception as exc: + record(name, 'blocked', type(exc).__name__ + ': ' + str(exc)) + return None + + def probe(name: str, fn) -> None: + try: + value = fn() + record(name, 'pass', value if value is not None else 'assertions satisfied') + except AssertionError as exc: + record(name, 'fail', str(exc)) + except Exception as exc: + record(name, 'blocked', type(exc).__name__ + ': ' + str(exc)) + + def git(*parts: str, cwd: Path = root) -> str: + return subprocess.check_output(['git', *parts], cwd=cwd, text=True, encoding='utf-8').strip() + + metadata = {'target_sha': git('rev-parse', 'HEAD'), + 'factory_sha': git('rev-parse', 'HEAD', cwd=factory), + 'platform': sys.platform, 'python': sys.version, + 'node': subprocess.check_output(['node', '--version'], text=True).strip(), + 'trust_ci_attestation': False} + print('AUDIT_IDENTITY ' + json.dumps(metadata), flush=True) + files = [f for f in git('ls-files', '-z').split('\0') if f] + before = {f: hashlib.sha256((root / f).read_bytes()).hexdigest() for f in files if (root / f).is_file()} + (out / 'inventory.json').write_text(json.dumps(before, indent=2), encoding='utf-8') + record('tracked-inventory', 'pass', {'files': len(files), 'hashed': len(before)}) + command('npm-ci', ['npm', 'ci', '--ignore-scripts'], timeout=240) + command('typecheck', ['npm', 'run', 'typecheck']) + command('all-discovered-node-tests', ['node', '--test'], timeout=300) + command('npm-audit-production', ['npm', 'audit', '--omit=dev', '--json'], timeout=120) + command('npm-pack-dry-run', ['npm', 'pack', '--dry-run', '--json', '--ignore-scripts']) + + def syntax() -> dict: + failures = [] + checked = [] + for rel in files: + if Path(rel).suffix not in {'.js', '.mjs', '.cjs', '.ts'}: + continue + p = subprocess.run(['node', '--check', str(root / rel)], capture_output=True, text=True) + checked.append(rel) + if p.returncode: + failures.append({'path': rel, 'error': p.stderr[-1500:]}) + assert not failures, json.dumps(failures) + return {'files': checked} + probe('javascript-typescript-syntax', syntax) + + def structured() -> dict: + checked = [] + for rel in files: + if rel.endswith('.json') and rel != 'tsconfig.json': + json.loads((root / rel).read_text(encoding='utf-8')) + checked.append(rel) + registry = json.loads((root / 'skills.json').read_text(encoding='utf-8')) + missing = [s['file'] for s in registry if not (root / s['file']).is_file()] + assert not missing, 'Missing registered skill files: ' + repr(missing) + names = [s['name'] for s in registry] + assert len(names) == len(set(names)), 'Duplicate registered skill names' + return {'json_files': checked, 'registered_skills': len(names), + 'jsonc': 'tsconfig.json is checked by the real TypeScript compiler in typecheck'} + probe('json-and-skill-registry', structured) + + for rel in files: + if rel.endswith(('.sh', '.command')): + if os.name == 'nt': + record('bash-syntax-' + Path(rel).name, 'skip', 'POSIX scripts are syntax-checked on both Unix runners, not Windows WSL') + else: + command('bash-syntax-' + Path(rel).name, ['bash', '-n', str(root / rel)]) + ps_parser = out / 'parse-powershell.ps1' + ps_parser.write_text("param([string]$Root)\n$bad=0; Get-ChildItem -LiteralPath $Root -Filter *.ps1 -Recurse | Where-Object { $_.FullName -notmatch '[\\\\/]node_modules[\\\\/]' } | ForEach-Object { $t=$null; $e=$null; [System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$t,[ref]$e) | Out-Null; foreach($x in $e){ Write-Output ($_.FullName+': '+$x.Message); $bad++ } }; if($bad){exit 1}\n", encoding='utf-8') + command('powershell-syntax', ['pwsh', '-NoProfile', '-File', str(ps_parser), str(root)]) + + # Set only checkout metadata to the already verified repository default branch. + # A full snapshot is not a code-change PR; whole-tree scanners run separately. + def select_factory_base() -> dict: + baseline = git('rev-parse', 'HEAD') + remote_master = git('rev-parse', 'refs/remotes/origin/master') + assert remote_master == baseline, 'Remote master changed; refuse to infer a new baseline' + git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master') + return {'default_branch': 'master', 'sha': baseline} + probe('factory-default-branch-binding', select_factory_base) + + # The factory source remains a separate, unchanged checkout. No route is forged. + sys.path.insert(0, str(factory / '.grok-stack')) + try: + from adaptive_grok.verification import verify + old = os.getcwd() + try: + os.chdir(root) + report = verify(root, mode='pr', profiles=['base', 'frontend'], record=False) + finally: + os.chdir(old) + (out / 'factory-verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8') + for item in report['checks']: + record('adaptive-' + item['name'], item['status'], + {'summary': item['summary'], 'details': item.get('details', []), + 'stdout': item.get('stdout', '')[-3500:], 'stderr': item.get('stderr', '')[-3500:]}) + except Exception: + record('adaptive-engine', 'blocked', traceback.format_exc()[-5000:]) + # A platform failure in orchestration must not suppress independent tree scans. + try: + from adaptive_grok.verification import _secret_scan, _contracts, _sql_safety + scans = [fn(root, files).to_dict() for fn in (_secret_scan, _contracts, _sql_safety)] + (out / 'factory-entire-tree.json').write_text(json.dumps(scans, indent=2), encoding='utf-8') + for item in scans: + record('entire-tree-' + item['name'], item['status'], + {'summary': item['summary'], 'details': item.get('details', [])}) + except Exception: + record('adaptive-tree-scans', 'blocked', traceback.format_exc()[-3500:]) + + with tempfile.TemporaryDirectory(prefix='gitmir audit ') as tmp: + work = Path(tmp) + home = work / 'home' + home.mkdir() + env = os.environ.copy() + env.update(HOME=str(home), USERPROFILE=str(home), CI='true') + def executable_installers() -> None: + missing = [name for name in ('install.sh', 'start.command', 'install-shortcut.command') + if not os.access(root / name, os.X_OK)] + assert not missing, 'Documented direct execution is denied by checkout modes: ' + ', '.join(missing) + if os.name != 'nt': + probe('unix-documented-entrypoints-executable', executable_installers) + else: + record('unix-documented-entrypoints-executable', 'skip', 'Executable bits are checked on native Unix filesystems') + skills = sorted(p.name for p in (root / 'plugin' / 'skills').iterdir() if p.is_dir()) + def install() -> None: + if os.name == 'nt': + wrapper = work / 'install-wrapper.ps1' + wrapper.write_text("param([string]$SandboxHome,[string]$Installer)\n$ErrorActionPreference='Stop'; Set-Variable -Name HOME -Value $SandboxHome -Force; & $Installer\n", encoding='utf-8') + argv = ['pwsh', '-NoProfile', '-File', str(wrapper), str(home), str(root / 'install.ps1')] + else: + argv = ['bash', str(root / 'install.sh')] + p = subprocess.run(argv, env=env, capture_output=True, text=True, timeout=60) + assert p.returncode == 0, (p.stdout + p.stderr)[-2000:] + def install_fresh() -> dict: + install() + for name in skills: + assert (home / '.agents' / 'skills' / name / 'SKILL.md').is_file(), name + ' missing' + install() + return {'skills': len(skills), 'repeat_install': True} + probe('native-installer-fresh-and-repeat', install_fresh) + # An unrelated pre-existing directory must not be destroyed by installation. + def install_collision() -> None: + dest = home / '.agents' / 'skills' / skills[0] + if dest.is_symlink(): + dest.unlink() + elif os.name == 'nt': + os.rmdir(dest) # Remove junction only; never recurse through its target. + else: + raise RuntimeError('Expected an installed symlink, refusing cleanup') + dest.mkdir() + sentinel = dest / 'operator-owned.txt' + sentinel.write_text('operator-owned-data', encoding='utf-8') + install() + assert sentinel.is_file() and sentinel.read_text() == 'operator-owned-data', 'Installer deleted pre-existing operator-owned directory contents' + probe('installer-preserves-existing-directory', install_collision) + + runtime = work / 'runtime' + shutil.copytree(root, runtime, ignore=shutil.ignore_patterns('.git', 'node_modules')) + (runtime / 'projects.json').write_text('[]', encoding='utf-8') + project = work / 'sample project' + project.mkdir() + (project / '.codex').mkdir() + (project / '.codex' / 'tasks.json').write_text('[]', encoding='utf-8') + # Only automatic desktop-browser launch is suppressed; HTTP handlers are unchanged. + preload = work / 'headless.mjs' + preload.write_text("import cp from 'node:child_process'; import {syncBuiltinESMExports} from 'node:module'; const original=cp.execFile; cp.execFile=function(file,args,...rest){ if(['open','xdg-open','cmd'].includes(file) && args.some(x=>String(x).startsWith('http://localhost:'))){ const cb=rest.at(-1); if(typeof cb==='function') queueMicrotask(()=>cb(null,'','')); return; } return original.call(this,file,args,...rest); }; syncBuiltinESMExports();\n", encoding='utf-8') + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + env['GITMIR_PORT'] = str(port) + env['GITMIR_PREVIEW'] = '0' + if sys.platform == 'linux': + bin_dir = work / 'bin' + bin_dir.mkdir() + terminal = bin_dir / 'x-terminal-emulator' + terminal.write_text('#!/bin/sh\nprintf \'%s\' "$4" > "$GITMIR_AUDIT_CAPTURE"\n', encoding='utf-8') + terminal.chmod(0o700) + env['PATH'] = str(bin_dir) + os.pathsep + env['PATH'] + env['GITMIR_AUDIT_CAPTURE'] = str(work / 'terminal-command.txt') + log = (out / 'server.log').open('w', encoding='utf-8') + server = subprocess.Popen(['node', '--import', preload.as_uri(), 'server.ts'], cwd=runtime, + env=env, stdout=log, stderr=subprocess.STDOUT) + observed_routes = set() + def request(route: str, method: str = 'GET', payload=None, headers: dict | None = None) -> tuple[int, bytes]: + observed_routes.add(method + ' ' + route.split('?')[0]) + body = None if payload is None else json.dumps(payload) + h = {'Host': f'localhost:{port}'} + if body is not None: + h['Content-Type'] = 'application/json' + h.update(headers or {}) + c = http.client.HTTPConnection('127.0.0.1', port, timeout=8) + try: + c.request(method, route, body=body, headers=h) + r = c.getresponse() + return r.status, r.read() + finally: + c.close() + def expect(route: str, code: int = 200, **kwargs) -> bytes: + status, body = request(route, **kwargs) + assert status == code, f'{route}: expected {code}, got {status}; {body[:300]!r}' + return body + try: + for _ in range(100): + try: + request('/api/env') + break + except (OSError, http.client.HTTPException): + if server.poll() is not None: + raise RuntimeError('Server exited: inspect server.log') + time.sleep(.1) + else: + raise RuntimeError('Server startup timed out') + probe('http-dashboard', lambda: expect('/')) + for asset in ['/app.js', '/vendor/fonts.css']: + probe('http-asset-' + Path(asset).name, lambda a=asset: expect(a)) + query = '?' + urlencode({'path': str(project)}) + for route in ['/api/env', '/api/projects', '/api/skills', '/api/tasks' + query, + '/api/queue' + query, '/api/model' + query, '/api/team/status']: + probe('http-read-' + route.split('?')[0].replace('/', '-'), lambda r=route: json.loads(expect(r))) + for skill in json.loads((root / 'skills.json').read_text(encoding='utf-8')): + probe('http-skill-' + skill['name'], lambda s=skill: bool(json.loads(expect('/api/skill?' + urlencode({'name': s['name']})))['text'])) + probe('http-reject-cross-origin', lambda: expect('/api/projects', 403, headers={'Origin': 'https://example.invalid', 'Sec-Fetch-Site': 'cross-site'})) + probe('http-reject-host-suffix', lambda: expect('/api/projects', 403, headers={'Host': f'localhost.attacker.invalid:{port}', 'Sec-Fetch-Site': 'same-origin'})) + probe('http-reject-preview-origin', lambda: expect('/api/projects', 403, headers={'Host': f'127.0.0.1:{port}'})) + def projects_roundtrip() -> None: + added = json.loads(expect('/api/add', method='POST', payload={'path': str(project)})) + assert added.get('added') is True, repr(added) + dup = json.loads(expect('/api/add', method='POST', payload={'path': str(project)})) + assert dup.get('duplicate') is True, repr(dup) + expect('/api/update', method='POST', payload={'path': str(project), 'name': 'Audit fixture', 'description': 'disposable'}) + items = json.loads(expect('/api/projects'))['projects'] + assert any(i['name'] == 'Audit fixture' for i in items), repr(items) + probe('http-project-add-update-duplicate', projects_roundtrip) + def tasks_unique() -> None: + if time.time() % 1 > .25: + time.sleep(1.02 - time.time() % 1) + a = json.loads(expect('/api/task', method='POST', payload={'path': str(project), 'title': 'same title', 'content': '# First'})) + b = json.loads(expect('/api/task', method='POST', payload={'path': str(project), 'title': 'same title', 'content': '# Second'})) + assert a['file'] != b['file'], 'Two successful task creations returned the same filename; first task overwritten: ' + a['file'] + probe('http-task-creation-no-overwrite', tasks_unique) + def null_body() -> None: + c = http.client.HTTPConnection('127.0.0.1', port, timeout=8) + try: + c.request('POST', '/api/update', 'null', {'Host': f'localhost:{port}', 'Content-Type': 'application/json'}) + r = c.getresponse() + body = r.read() + assert 400 <= r.status < 500, f'JSON null returned {r.status}, expected 4xx; {body[:200]!r}' + finally: + c.close() + probe('http-reject-non-object-json', null_body) + def linux_literal_path() -> None: + candidate = work / 'literal$(printf AUDIT>audit-marker.txt)' + candidate.mkdir() + expect('/api/open', method='POST', payload={'path': str(candidate)}) + captured = work / 'terminal-command.txt' + for _ in range(50): + if captured.is_file(): + break + time.sleep(.02) + assert captured.is_file(), 'Terminal subprocess did not receive arguments' + inner = captured.read_text(encoding='utf-8') + # The captured command is evaluated with only an inert marker payload + # in a disposable directory, without an interactive terminal or Codex. + proc = subprocess.run(['bash', '--noprofile', '--norc', '-c', inner], + cwd=work, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5) + assert not (work / 'audit-marker.txt').exists(), 'Project folder name executed shell substitution in the Linux terminal command' + if sys.platform == 'linux': + probe('linux-terminal-treats-project-path-literally', linux_literal_path) + else: + record('linux-terminal-treats-project-path-literally', 'skip', 'Linux-specific terminal command; no desktop GUI is opened') + probe('http-ping', lambda: expect('/api/ping')) + probe('http-preview-disabled', lambda: expect('/api/preview?url=https://example.invalid', 404)) + probe('http-team-disconnect-idempotent', lambda: expect('/api/team/disconnect', method='POST', payload={})) + probe('http-project-remove', lambda: expect('/api/remove', method='POST', payload={'path': str(project)})) + if sys.platform == 'linux': + def browser_smoke() -> dict: + from playwright.sync_api import sync_playwright + errors, bad_responses = [], [] + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={'width': 1440, 'height': 1000}) + page.on('pageerror', lambda e: errors.append(str(e))) + page.on('response', lambda r: bad_responses.append({'url': r.url, 'status': r.status}) if r.status >= 400 else None) + page.goto(f'http://localhost:{port}', wait_until='networkidle') + page.screenshot(path=str(out / 'dashboard.png'), full_page=True) + assert not errors, repr(errors) + assert not bad_responses, repr(bad_responses) + return {'title': page.title(), 'page_errors': errors, 'http_errors': bad_responses} + finally: + browser.close() + probe('chromium-dashboard-smoke', browser_smoke) + else: + record('chromium-dashboard-smoke', 'skip', 'Browser smoke is performed on Linux; native API tests run on every OS') + source = (root / 'server.ts').read_text(encoding='utf-8') + declared = sorted(set(m + ' ' + p for m, p in re.findall(r"req\.method === '(GET|POST)' && url\.pathname === '([^']+)'", source))) + record('http-route-coverage', 'info', {'declared_exact_routes': declared, 'exercised_routes': sorted(observed_routes), + 'not_exercised': sorted(set(declared) - observed_routes)}) + except Exception: + record('http-runtime', 'blocked', traceback.format_exc()[-3500:]) + finally: + server.terminate() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + log.close() + if any(r['name'] == 'http-runtime' and r['status'] == 'blocked' for r in results): + print('SERVER_STARTUP_LOG ' + (out / 'server.log').read_text(encoding='utf-8', errors='replace')[-4500:], flush=True) + + after = {f: hashlib.sha256((root / f).read_bytes()).hexdigest() for f in before if (root / f).is_file()} + record('baseline-source-unchanged', 'pass' if before == after else 'fail', + {'changed': [f for f in before if before[f] != after.get(f)]}) + record('scope-boundaries', 'info', ['No real Codex/model turn', 'No real Google Cloud credentials or API calls', + 'No native terminal/picker GUI interaction', 'No live relay connection', + 'No SDK update or registry mutation', 'No Trust CI attestation or merge']) + summary = {'metadata': metadata, 'checks': results, + 'counts': {s: sum(r['status'] == s for r in results) for s in ['pass', 'fail', 'blocked', 'skip', 'info']}} + (out / 'summary.json').write_text(json.dumps(summary, ensure_ascii=True, indent=2), encoding='utf-8') + print('AUDIT_NONPASS ' + json.dumps([r for r in results if r['status'] in {'fail', 'blocked', 'skip'}]), flush=True) + print('AUDIT_COUNTS ' + json.dumps(summary['counts']), flush=True) + return 1 if summary['counts']['fail'] or summary['counts']['blocked'] else 0 + + +if __name__ == '__main__': + raise SystemExit(main())