Compare commits

...
Author SHA1 Message Date
codex 7f8f6cd297 docs: зафиксировать переход инструкций на Gitea MCP в changelog
Runner smoke test / runner-smoke (pull_request) Waiting to run
CI / tests-and-images (pull_request) In progress
2026-11-15 18:55:56 +00:00
codex 2a30def397 docs: устранить конфликтующие инструкции по результатам ревью
CI / tests-and-images (pull_request) Successful in 3m1s
Runner smoke test / runner-smoke (pull_request) Successful in 4s
2026-11-15 18:51:41 +00:00
codex d3f5b2b9d1 docs: перейти на Windows Codex и Gitea MCP
CI / tests-and-images (pull_request) Successful in 2m59s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-15 18:48:53 +00:00
agent 871bde1abd Merge pull request 'fix: enumerate Windows ACL entries by SID' (#63) from fix/windows-acl-sid-enumeration into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 09:11:40 +00:00
codex 5c26bbe8b5 docs: record SID-native Windows ACL checks
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 12:05:19 +03:00
codex 3d7df25d00 fix Windows ACL checks to enumerate SIDs directly
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 11:52:10 +03:00
agent 4d589eee95 Merge pull request 'Harden external release preparation and runtime compatibility' (#62) from fix/cd-release-runtime-readiness into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 3s
2026-11-03 07:48:07 +00:00
10 changed files with 195 additions and 129 deletions

No files matched your search

+30 -70
View File
@@ -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
открытым и укажи причину.
+9
View File
@@ -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. Передача корпуса требует атомарной публикации
+3 -2
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
+3 -2
View File
@@ -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 }
+3
View File
@@ -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):
+12 -10
View File
@@ -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)