Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8f6cd297 | ||
|
|
2a30def397 | ||
|
|
d3f5b2b9d1 | ||
|
|
871bde1abd | ||
|
|
5c26bbe8b5 | ||
|
|
3d7df25d00 | ||
|
|
4d589eee95 |
No files matched your search
@@ -1,24 +1,18 @@
|
||||
# Акылдаш — инструкции для агента
|
||||
|
||||
Проект: юридическая информационно-аналитическая платформа; Search API на
|
||||
Python, SQLite и OpenSearch. На 2026-10-25 публичный `/review` и прямой origin
|
||||
отвечают HTTP 200; состав production-контейнеров, готовность индекса и
|
||||
проверенность backup/restore не подтверждены. Сверяй актуальное состояние с
|
||||
[`CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md), не считай HTTP 200
|
||||
доказательством готовности поиска.
|
||||
|
||||
- Начинай с [инфраструктурного состояния](docs/operations/CURRENT_STATE.md) и
|
||||
[правил доступа](docs/operations/ACCESS.md). Не запрашивай доступы повторно.
|
||||
Python, SQLite и OpenSearch. Для инфраструктурных задач используй Skill
|
||||
`commerce-infra`, контур `commerce`, проект `akyldash`. Сверяй состояние с
|
||||
[`CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md) и
|
||||
[правилами доступа](docs/operations/ACCESS.md). HTTP 200 сам по себе не
|
||||
подтверждает готовность индекса или восстановимость данных.
|
||||
- Архитектура и процедуры: [инфраструктура](docs/operations/INFRASTRUCTURE.md),
|
||||
[деплой](docs/operations/DEPLOYMENT.md), [backup](docs/operations/BACKUP.md),
|
||||
[rollback](docs/operations/ROLLBACK.md), [восстановление](docs/operations/RECOVERY.md).
|
||||
- Работай в feature/fix/refactor-ветке. `main` защищена; изменения вноси через
|
||||
PR. DEV не одобряет и не сливает собственный PR. Условный merge выполняет
|
||||
только REVIEWER по правилам раздела «Автономный workflow PR» ниже. Сохраняй
|
||||
незакоммиченные изменения.
|
||||
- SSH alias — `akyldash_vps`; используй только read-only диагностику. Не давай
|
||||
`codex` общий sudo или доступ к Docker socket. Root-действия требует владелец.
|
||||
Для Gitea используй `gitea-api`, не читай файлы с учётными данными.
|
||||
- `main` защищена. Изменения проходят через PR по правилам ниже.
|
||||
- Доступ к VPS для диагностики ограничен read-only. Не давай серверному
|
||||
пользователю `codex` общий sudo или доступ к Docker socket. Root-действия
|
||||
выполняет владелец.
|
||||
- Репозиторный Gitea Actions runner описан в
|
||||
[CI_RUNNER.md](docs/operations/CI_RUNNER.md). Регистрация выполняется
|
||||
владельцем через Settings → Actions → Runners для `admin/akyldash`; токен
|
||||
@@ -66,59 +60,25 @@ Python, SQLite и OpenSearch. На 2026-10-25 публичный `/review` и п
|
||||
- Полная процедура, метрики, Gitea-ограничения и проверенные временные бюджеты:
|
||||
[DELIVERY_POLICY.md](docs/development/DELIVERY_POLICY.md).
|
||||
|
||||
## Автономный workflow PR
|
||||
## Согласование изменений
|
||||
|
||||
- Маршруты ниже указаны из контекста оркестратора/root: оркестратор — thread
|
||||
`01a144a5-8b95-77c3-b311-9a9afd062ca8`, host `local`; DEV — thread
|
||||
`01a138f1-3b34-7c91-a9ed-4b43c3c85f1f`, host
|
||||
`remote-ssh-codex-managed:712b8d4e-24e1-4dd5-92af-85d1fbd26449`, Gitea
|
||||
`codex` (ID 6); REVIEWER — thread `01a144f9-5b51-7923-8398-61d4a6a98306`,
|
||||
host `remote-ssh-codex-managed:b61555d7-8d1a-4cfa-988b-c8da4791d426`, Gitea
|
||||
`agent` (ID 7). Если этот же чат отображает REVIEWER как `local` внутри его
|
||||
собственного app-server, это self-label и не является маршрутом из контекста
|
||||
оркестратора.
|
||||
- DEV принимает только явно зарегистрированную оркестратором человеческую
|
||||
задачу с полями `task`, `repo`, `branch`, `pr`, `head_sha`, `phase`. Репозиторий
|
||||
должен быть `admin/akyldash`; работай только в назначенной ветке и PR. Веди
|
||||
одну задачу/PR за раз и не подхватывай старые PR самостоятельно.
|
||||
- Допустимые фазы: `initial_review` — реализация и устранение review findings;
|
||||
`final_review_and_merge` — только после первоначального успешного review,
|
||||
changelog-коммита и отдельного назначения оркестратора. Не переходи к следующей
|
||||
фазе без назначения. Если задача приостановлена, останови любые действия;
|
||||
после resume перепроверь назначение, PR, head/base, checks и review с нуля.
|
||||
- Повторная доставка одного назначения идемпотентна по ключу
|
||||
`task`+`pr`+`head_sha`+`stage`: не дублируй commit, push, review-запрос или
|
||||
merge; сообщи оркестратору о уже выполненном результате. Новый head или stage
|
||||
образует новое назначение и требует повторной проверки.
|
||||
- Межчатовое чтение недоступно: оркестратор через central relay сам читает
|
||||
результаты и выдаёт назначения. Завершай каждый этап отчётом с полями `task`,
|
||||
`repo`, `branch`, `pr`, `head_sha`, `stage`, `checks`, `findings`,
|
||||
`next_action`. Не настраивай отдельные webhook, daemon или транспорт.
|
||||
- DEV исследует, изменяет, проверяет, коммитит и отправляет изменения; REVIEWER
|
||||
не меняет код и независимо проверяет полный diff от base после каждого push,
|
||||
включая предыдущие findings. DEV не одобряет и не сливает свой PR.
|
||||
- Только после первоначального успешного формального review DEV добавляет
|
||||
значимые изменения в существующий changelog, коммитит и отправляет их в тот же
|
||||
PR. Затем REVIEWER полностью проверяет итоговый diff. Для docs-only задачи не
|
||||
придумывай изменения кода; правила версии применяй только если они явно
|
||||
относятся к этой документационной правке.
|
||||
- Слияние может выполнить только REVIEWER для зарегистрированной человеческой
|
||||
задачи, в фазе `final_review_and_merge`, после явного назначения оркестратора.
|
||||
Непосредственно перед ним REVIEWER подтверждает `admin/akyldash`, PR, base
|
||||
`main`, ожидаемую ветку, автора PR `codex` (ID 6), неизменность base и head,
|
||||
причём head должен совпадать с зарегистрированным `head_sha`, включая
|
||||
`head_commit_id` для merge-запроса. Требуются все успешные required CI/test
|
||||
checks и gates, отсутствие pending/failed/unknown checks, открытых
|
||||
findings и активных `REQUEST_CHANGES`, плюс актуальный формальный `APPROVED`
|
||||
от REVIEWER для точного текущего commit с параметрами `commit_id == head_sha`,
|
||||
`stale == false`, `dismissed == false`. При изменении head или base review и
|
||||
проверки выполняются заново. Комментарий с approval не является формальным
|
||||
review.
|
||||
Используй только обычный server-side merge с `head_commit_id`; не используй
|
||||
`force_merge` и не обходи branch protection. Если любой существенный gate,
|
||||
статус или защита неизвестны либо не проверяются — не сливай PR и сообщи
|
||||
оркестратору, что именно осталось неизвестным.
|
||||
- Все Gitea API-вызовы выполняй только через `gitea-api` с личным окружением;
|
||||
Git используй с обычным credential helper. Не читай credentials из файлов
|
||||
репозитория. Запускай Git- и API-команды отдельно, без объединения с другими
|
||||
командами.
|
||||
- Создавай ветку от актуальной `main`, коммиты и PR через Gitea Codex MCP.
|
||||
Обновления вноси в тот же PR. Автор не одобряет и не сливает свой PR.
|
||||
- После каждого push отдельный ревьюер проверяет полный diff относительно
|
||||
base и ранее найденные дефекты. Формальное ревью публикуется через Gitea
|
||||
Agent MCP. Разные учётные записи обеспечивают разделение полномочий.
|
||||
Независимость требует отдельного исполнителя ревью.
|
||||
- После первого успешного ревью и CI внеси значимое изменение в существующий
|
||||
changelog и отправь коммит в тот же PR. Повтори полное ревью итогового diff
|
||||
и CI. Для docs-only задачи версию приложения повышай только при явном
|
||||
требовании к этой правке.
|
||||
- Merge через Gitea Agent MCP допускается после независимого ревью и только
|
||||
при явном разрешении владельца репозитория. Перед слиянием проверь `admin/akyldash`, номер PR, автора `codex`, base
|
||||
`main`, ожидаемую ветку и точные SHA head/base. Нужны все успешные
|
||||
обязательные checks и gates, отсутствие открытых блокеров и действующих
|
||||
`REQUEST_CHANGES`, формальный `APPROVED` от `agent` с
|
||||
`commit_id == head_sha`, `stale == false` и `dismissed == false`.
|
||||
При изменении head или base повтори проверки и ревью. Используй обычный
|
||||
server-side merge с `head_commit_id`, без force и обхода branch protection.
|
||||
При неизвестном, ожидающем или неуспешном обязательном условии оставь PR
|
||||
открытым и укажи причину.
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Документация: инструкции Акылдаша переведены на нативный Windows Codex,
|
||||
`commerce-infra` и Gitea MCP. Сохранены независимое ревью, обязательные
|
||||
CI-проверки, одобрение текущего head и ограничения production-доступа.
|
||||
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
|
||||
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
|
||||
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
|
||||
@@ -10,6 +13,12 @@
|
||||
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
|
||||
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
|
||||
регистрация задач, SSH и production не выполнялись и не активировались.
|
||||
- Infrastructure: Windows release installer и external receiver читают владельцев
|
||||
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
|
||||
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
|
||||
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
|
||||
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
|
||||
менялись.
|
||||
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
|
||||
внешняя backup-retention политика и обязательный owner-approved maintenance
|
||||
window для cold backup. Передача корпуса требует атомарной публикации
|
||||
|
||||
@@ -173,5 +173,6 @@ regressions и rollback baseline остаются `N/A`, пока не появ
|
||||
|
||||
Каждый блокер содержит файл/участок, шаги воспроизведения или точное условие,
|
||||
последствие и минимальный безопасный fix. Остальные замечания — отдельный
|
||||
неблокирующий список. Review и approval остаются за людьми; агент не начинает
|
||||
review сам и не сливает PR.
|
||||
неблокирующий список. Независимый ревьюер проверяет полный diff и передаёт
|
||||
результат через Gitea Agent MCP. Формальное одобрение и разрешённый Merge
|
||||
следуют условиям [AGENTS.md](../../AGENTS.md#согласование-изменений).
|
||||
+24
-39
@@ -2,20 +2,14 @@
|
||||
|
||||
## Discover existing access first
|
||||
|
||||
In a new Codex session, inspect the SSH alias and key file metadata without
|
||||
printing key contents, then check the connection:
|
||||
For repository work in Windows Codex, use `commerce-infra` to read the
|
||||
`commerce/akyldash` context through Gitea Infrastructure Registry MCP. Use
|
||||
Gitea Codex MCP to read the project sources and prepare changes.
|
||||
Use the repository PR rules for formal review and permitted merge.
|
||||
|
||||
```bash
|
||||
ssh -F ~/.ssh/config -G akyldash_vps | awk '$1 == "hostname" || $1 == "user" || $1 == "port" || $1 == "identityfile" { print }'
|
||||
test -r ~/.ssh/akyldash_codex && echo 'SSH key is available'
|
||||
ssh -F ~/.ssh/config -o BatchMode=yes akyldash_vps id
|
||||
ssh -F ~/.ssh/config -o BatchMode=yes akyldash_vps hostname -f
|
||||
gitea-api GET /version
|
||||
gitea-api GET /repos/admin/akyldash
|
||||
```
|
||||
|
||||
Do not ask for the host, login, password, token, or key again before these
|
||||
checks. Never print a private key, token, or complete environment file.
|
||||
Host access is needed only for separately authorized runtime diagnostics.
|
||||
Check the configured SSH alias and key metadata without reading key contents.
|
||||
Do not request passwords, tokens, or keys, or print a complete environment file.
|
||||
|
||||
## VPS
|
||||
|
||||
@@ -42,36 +36,27 @@ checks. Never print a private key, token, or complete environment file.
|
||||
It does not start Compose or create volumes.
|
||||
- Never connect to other VPS hosts or Synology as part of this deployment.
|
||||
|
||||
This execution environment is WSL2. The repository is on the Windows-mounted
|
||||
`/mnt/d` filesystem, while SSH config and the identity file are in the Linux
|
||||
home directory. Do not assume Windows SSH credentials are available or copy
|
||||
keys between environments. Plain `ssh akyldash_vps` failed because the system
|
||||
SSH config include had unsafe permissions. Use `ssh -F ~/.ssh/config ...`; in a
|
||||
network-restricted session this read-only command may also require approved
|
||||
execution. If the key is missing in a future environment, report that
|
||||
limitation and do not retrieve it elsewhere.
|
||||
For authorized runtime diagnostics, use the configured read-only SSH access.
|
||||
Validate the resolved alias, hostname, user and port before connecting. Only
|
||||
use an existing approved key for this environment. If access is unavailable,
|
||||
record the limitation. Do not copy keys between environments.
|
||||
|
||||
## Gitea
|
||||
|
||||
- Repository: `https://git.vakanaut.ru/admin/akyldash`.
|
||||
- Use the installed `gitea-api` helper; it reads credentials from the
|
||||
environment. Never inspect local credential files, print environment values,
|
||||
or construct HTTP authorization headers.
|
||||
- The audited API token can read/write this repository and create issues, but
|
||||
does not have `read:user` or `read:package`. Listing repository Actions
|
||||
runners returns `403` because the API principal is not the repository owner.
|
||||
An API `403` caused by missing scope/ownership is not evidence that no runner
|
||||
exists.
|
||||
- Protected `main` requires one approval. Do not push directly to `main`, merge
|
||||
automatically, or start a review. Work on a feature branch and use a PR.
|
||||
- Gitea Actions and package features are enabled for the repository. The Actions
|
||||
task API currently returns no runs, but runner availability and package
|
||||
publishing permissions have not been confirmed. The repository-level runner
|
||||
is prepared by `infra/runner/install-runner.sh`, but is not installed or
|
||||
registered yet. Only a repository owner may obtain its registration token in
|
||||
`admin/akyldash` Settings → Actions → Runners; do not use organization or
|
||||
instance tokens and never put the token in Git, a command argument, or a
|
||||
report. See [CI_RUNNER.md](CI_RUNNER.md).
|
||||
- Use Gitea Codex MCP to read the repository, prepare changes in a separate
|
||||
branch and open the PR. Use Gitea Agent MCP for formal review, approval and
|
||||
permitted merge according to [AGENTS.md](../../AGENTS.md#согласование-изменений).
|
||||
Do not inspect credential files, print secret environment values, or construct
|
||||
authorization headers.
|
||||
- Protected `main` requires at least one independent approval and the required
|
||||
CI checks. Direct push to `main` is prohibited. Merge requires the current
|
||||
head approval and the checks listed in AGENTS.md.
|
||||
- A `403` can indicate missing scope or owner permissions. It does not establish
|
||||
runner absence. Only a repository owner may obtain the repository runner
|
||||
registration token in `admin/akyldash` Settings → Actions → Runners. Do not use
|
||||
organization or instance tokens or put the token in Git, a command argument,
|
||||
or a report. See [CI_RUNNER.md](CI_RUNNER.md).
|
||||
|
||||
## Who can do what
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Infrastructure current state
|
||||
|
||||
## Windows ACL SID resolution — 2026-11-03
|
||||
|
||||
- Read-only PowerShell 5.1 inspection found that display-name conversion of
|
||||
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
|
||||
is readable. The installer and external-receiver guards now enumerate access
|
||||
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
|
||||
principals remain visible to the existing fail-closed policy. Regression
|
||||
coverage exercises synthetic unmapped SIDs through the actual installer guard
|
||||
functions. These repository changes are pending review; no Windows ACL,
|
||||
installer, task, VPS, or production state was changed. The native PowerShell
|
||||
harness still requires execution on Windows before this behavior is confirmed
|
||||
in that runtime.
|
||||
|
||||
## External deployment runtime binding — 2026-11-03
|
||||
|
||||
- PR #59 is merged to `main`; it contains the root-only read-only status
|
||||
|
||||
@@ -83,8 +83,9 @@ $allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544')
|
||||
$aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
if ($aclOwner -notin $allowed) { exit 6 }
|
||||
$ownerAllowed = $false
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 }
|
||||
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 }
|
||||
if ($sid -eq $owner) { $ownerAllowed = $true }
|
||||
|
||||
@@ -203,6 +203,9 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1])
|
||||
|
||||
def test_private_acl_rejects_an_unapproved_acl_owner(self):
|
||||
|
||||
@@ -50,7 +50,8 @@ function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" }
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
if ($AllowOwnerWrite) {
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
@@ -60,24 +61,24 @@ function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
|
||||
if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" }
|
||||
if ($AllowOwnerWrite) {
|
||||
$allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or
|
||||
($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {
|
||||
throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path"
|
||||
}
|
||||
}
|
||||
$ownerHasFullControl = @($acl.Access | Where-Object {
|
||||
$ownerHasFullControl = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq $identity.User.Value -and
|
||||
$_.IdentityReference.Value -eq $identity.User.Value -and
|
||||
($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl
|
||||
}).Count -gt 0
|
||||
if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" }
|
||||
} else {
|
||||
$writableUntrusted = @($acl.Access | Where-Object {
|
||||
$writableUntrusted = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
(([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and
|
||||
$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
$_.IdentityReference.Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
})
|
||||
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
|
||||
}
|
||||
@@ -102,13 +103,14 @@ function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) {
|
||||
throw "Protected path contains a reparse point: $($current.FullName)"
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $current.FullName
|
||||
$ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Protected path has an untrusted owner: $($current.FullName)"
|
||||
}
|
||||
$checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights }
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }
|
||||
if ($rule.AccessControlType -eq 'Allow' -and
|
||||
(([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and
|
||||
|
||||
@@ -17,8 +17,7 @@ if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne
|
||||
throw 'Temporary fixture root must not be a reparse point.'
|
||||
}
|
||||
$temporaryRootCreated = $temporaryRootItem.CreationTimeUtc
|
||||
$temporaryAclOwner = (Get-Acl -LiteralPath $temporaryRoot).Owner
|
||||
$temporaryOwnerSid = ([Security.Principal.NTAccount]$temporaryAclOwner).Translate(
|
||||
$temporaryOwnerSid = (Get-Acl -LiteralPath $temporaryRoot).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' }
|
||||
@@ -44,8 +43,7 @@ function Get-VerifiedFixtureRoot([string]$Candidate) {
|
||||
$item.CreationTimeUtc -ne $temporaryRootCreated) {
|
||||
throw 'Fixture root was replaced, moved, or resolved outside its original identity.'
|
||||
}
|
||||
$ownerName = (Get-Acl -LiteralPath $candidatePath).Owner
|
||||
$ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate(
|
||||
$ownerSid = (Get-Acl -LiteralPath $candidatePath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture root owner changed.'
|
||||
@@ -63,8 +61,7 @@ function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) {
|
||||
if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Fixture entry resolved outside its verified temporary root.'
|
||||
}
|
||||
$ownerName = (Get-Acl -LiteralPath $resolved).Owner
|
||||
$ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate(
|
||||
$ownerSid = (Get-Acl -LiteralPath $resolved).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture entry is not owned by the current user.'
|
||||
@@ -104,6 +101,88 @@ try {
|
||||
if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' }
|
||||
. ([scriptblock]::Create($validatorAst.Extent.Text))
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$mutationRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
foreach ($guardName in @('Assert-ProtectedPath', 'Assert-TrustedPath')) {
|
||||
$guardAst = $installerAst.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -ceq $guardName
|
||||
}, $true)
|
||||
if ($null -eq $guardAst) { throw "Installer ACL guard was not found: $guardName" }
|
||||
. ([scriptblock]::Create($guardAst.Extent.Text))
|
||||
}
|
||||
|
||||
$unknownSid = [Security.Principal.SecurityIdentifier]::new(
|
||||
'S-1-5-21-3141592653-2718281828-1618033988-987654')
|
||||
$unmapped = $false
|
||||
try { $null = $unknownSid.Translate([Security.Principal.NTAccount]) }
|
||||
catch [Security.Principal.IdentityNotMappedException] { $unmapped = $true }
|
||||
if (-not $unmapped) { throw 'The synthetic ACL SID unexpectedly resolves to an NTAccount.' }
|
||||
|
||||
$savedMockAcl = $script:akyldashAclFixture
|
||||
function Get-Acl([string]$LiteralPath) { return $script:akyldashAclFixture }
|
||||
try {
|
||||
$sidSystem = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
||||
$sidAdmins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$privateFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$privateFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$privateFixtureAcl.SetOwner($identity.User)
|
||||
foreach ($sid in @($sidSystem, $sidAdmins, $identity.User)) {
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
}
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $privateFixtureAcl
|
||||
$privateRejected = $false
|
||||
try { Assert-ProtectedPath -Path $temporaryRoot -AllowOwnerWrite $true }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Private state path ACL must grant only full control*') { throw }
|
||||
$privateRejected = $true
|
||||
}
|
||||
if (-not $privateRejected) { throw 'Unmapped SID was not rejected by the protected-path guard.' }
|
||||
|
||||
$trustedFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$trustedFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$trustedFixtureAcl.SetOwner($sidSystem)
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sidSystem, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::WriteData,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $trustedFixtureAcl
|
||||
$trustedRejected = $false
|
||||
try { Assert-TrustedPath -Path $temporaryRoot }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Protected path grants replacement/write access*') { throw }
|
||||
$trustedRejected = $true
|
||||
}
|
||||
if (-not $trustedRejected) { throw 'Unmapped SID was not rejected by the trusted-path guard.' }
|
||||
} finally {
|
||||
Remove-Item Function:\Get-Acl -ErrorAction SilentlyContinue
|
||||
if ($null -eq $savedMockAcl) { Remove-Variable -Name akyldashAclFixture -Scope Script -ErrorAction SilentlyContinue }
|
||||
else { $script:akyldashAclFixture = $savedMockAcl }
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$stream = [IO.File]::OpenRead($bundlePath)
|
||||
try {
|
||||
|
||||
@@ -113,6 +113,10 @@ class WindowsReleaseBootstrapTests(unittest.TestCase):
|
||||
script.index("$bundleStream = [IO.File]::Open"))
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script)
|
||||
self.assertIn("$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value", script)
|
||||
self.assertGreaterEqual(script.count("$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])"), 2)
|
||||
self.assertIn("$rule.IdentityReference.Value", script)
|
||||
self.assertNotIn(".Translate(", script)
|
||||
self.assertIn("Assert-TrustedPath $appRoot", script)
|
||||
self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script)
|
||||
self.assertIn("Set-TrustedCodeOwner $app", script)
|
||||
@@ -120,6 +124,15 @@ class WindowsReleaseBootstrapTests(unittest.TestCase):
|
||||
for name in release_bundle.FILES:
|
||||
self.assertIn(f"files/{name}", script)
|
||||
|
||||
def test_windows_acl_harness_exercises_unmapped_sid_with_real_installer_guards(self):
|
||||
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("Assert-ProtectedPath", harness)
|
||||
self.assertIn("Assert-TrustedPath", harness)
|
||||
self.assertIn("IdentityNotMappedException", harness)
|
||||
self.assertIn("$unknownSid", harness)
|
||||
self.assertIn("$guardAst.Extent.Text", harness)
|
||||
self.assertIn("Unmapped SID was not rejected by the protected-path guard", harness)
|
||||
|
||||
def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
||||
Reference in new issue
Block a user