Compare commits
53
Commits
@@ -0,0 +1,486 @@
|
||||
# Agent Production Deployment Requirements
|
||||
|
||||
## Status entering deployment
|
||||
|
||||
```yaml
|
||||
code_development: COMPLETED
|
||||
repository_commit: NOT_CREATED
|
||||
production_deployment: NOT_STARTED
|
||||
reason_repository_commit_missing:
|
||||
current ChatGPT GitOps runtime has AIOS_GITOPS_WRITES_ENABLED=false
|
||||
```
|
||||
|
||||
The code bundle is an input artifact. The Agent must place it on a non-main
|
||||
integration branch, review it, run acceptance tests, merge it through protected
|
||||
main and deploy the API adapter. The Agent must not regenerate or materially
|
||||
rewrite the supplied implementation without recording the new diff and tests.
|
||||
|
||||
## Frozen production state
|
||||
|
||||
Before any change, verify:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
V2R1C5:
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If authoritative `main` no longer equals the approved base OID, stop with
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED / AUTHORIZED_BASE_OID_DRIFT`. Do not silently
|
||||
rebase the bundle onto a different base.
|
||||
|
||||
## Bootstrap sequence — no additional Owner interaction
|
||||
|
||||
Use the following order so that the approved V2R1C6 base OID remains valid and
|
||||
the bridge does not create its own authorization deadlock:
|
||||
|
||||
1. Verify the supplied archive/patch SHA-256.
|
||||
2. Install the bundle into a temporary root-owned staging directory outside the
|
||||
Git worktrees.
|
||||
3. Start a localhost-only temporary instance of
|
||||
`aios_authorized_task_writer_api.py` on a dedicated port or Unix socket.
|
||||
4. Bind it to the existing authenticated Owner registry and the exact
|
||||
`writer:provision` scope. Do not expose it through the public gateway.
|
||||
5. Execute the live V2R1C6 provisioning request while `main` still equals
|
||||
`875d41c3101093149414292067fd700dc214d4d3`.
|
||||
6. Verify `READY_FOR_CODE_AUTHORING`, the exact branch OID, writer scope and
|
||||
negative policy tests.
|
||||
7. Use the newly created scoped writer to commit this reviewed bundle onto:
|
||||
`agent/v2r1c6-mainline-reconciliation-20260805`.
|
||||
8. Stop and remove the temporary bootstrap service and revoke its temporary
|
||||
runtime credential. Preserve its signed grant receipt and audit logs.
|
||||
9. Open a protected PR from the V2R1C6 branch. Deploy the permanent bridge only
|
||||
from the reviewed merge commit.
|
||||
|
||||
The temporary service is a control-plane bootstrap, not a production release.
|
||||
It must retain:
|
||||
|
||||
```yaml
|
||||
listen_scope: localhost_only
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
This order avoids asking the Owner to approve a second base OID merely because
|
||||
the bridge implementation itself advanced `main`.
|
||||
|
||||
### Alternative platform-maintenance path
|
||||
|
||||
If policy prohibits a temporary localhost bootstrap service, use an existing
|
||||
Owner-approved platform-maintenance executor to create:
|
||||
|
||||
```yaml
|
||||
branch: patch/owner-authorization-writer-bridge-20260806
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
direct_main_push: false
|
||||
force_push: false
|
||||
tag_write: false
|
||||
baseline_write: false
|
||||
production_deploy_during_integration: false
|
||||
```
|
||||
|
||||
Merging that branch before V2R1C6 provisioning changes the authoritative base
|
||||
OID. In that alternative only, a new exact Owner base-OID amendment is required.
|
||||
The Agent must never substitute the newer `main` OID automatically.
|
||||
|
||||
## Files to install on the branch
|
||||
|
||||
Copy with the same relative paths:
|
||||
|
||||
```text
|
||||
active-runtime/app/authorized_task_writer_provisioning.py
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
docs/gitops/authorized-task-writer-provisioning.md
|
||||
docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
scripts/smoke_authorized_task_writer_provisioning.py
|
||||
tests/test_authorized_task_writer_provisioning.py
|
||||
```
|
||||
|
||||
Merge the OpenAPI fragment into:
|
||||
|
||||
```text
|
||||
docs/gitops/openapi-chatgpt-actions.yaml
|
||||
```
|
||||
|
||||
The final schema must expose:
|
||||
|
||||
```yaml
|
||||
operationId: provisionAuthorizedTaskWriter
|
||||
path: POST /api/task-writer/provision-authorized
|
||||
x-openai-isConsequential: true
|
||||
```
|
||||
|
||||
Do not deploy the fragment as a second standalone OpenAPI document.
|
||||
|
||||
## Required review of existing API integration
|
||||
|
||||
The adapter expects the existing API handler to provide:
|
||||
|
||||
```python
|
||||
handler.need(scope)
|
||||
handler.body()
|
||||
handler.sendj(value, status=200)
|
||||
api.H.do_POST
|
||||
api.main()
|
||||
```
|
||||
|
||||
Confirm these contracts against the branch before merge. If the live API has a
|
||||
different authentication object shape, make the smallest adapter-only change
|
||||
and rerun every test. The core provisioning policy must remain unchanged.
|
||||
|
||||
The API authorization registry must grant `writer:provision` only to the
|
||||
authenticated Owner role. Agents and ordinary writer tokens must not receive
|
||||
this scope.
|
||||
|
||||
## Privilege boundary
|
||||
|
||||
The API service must remain non-root.
|
||||
|
||||
Install a root-owned sudoers rule that allows the API service identity to run
|
||||
only these exact commands with `sudo -n --`:
|
||||
|
||||
```text
|
||||
/usr/local/libexec/aios-grant-signer-adapter
|
||||
/usr/local/libexec/aios-grant-validator
|
||||
/usr/local/libexec/aios-task-writer-provisioner
|
||||
```
|
||||
|
||||
Do not allow:
|
||||
|
||||
```text
|
||||
/bin/sh
|
||||
/bin/bash
|
||||
/usr/bin/env
|
||||
/usr/bin/git
|
||||
wildcard executable paths
|
||||
SETENV
|
||||
arbitrary environment preservation
|
||||
```
|
||||
|
||||
The governed tools themselves must validate their `--grant-dir` and
|
||||
`--result-json` paths. Their binaries must be root-owned, non-symlink, regular,
|
||||
executable and not group/world writable.
|
||||
|
||||
The signer must access its private signing material only through a runtime
|
||||
secret reference. No signing key or token may enter Git, API output, the
|
||||
provisioning receipt or systemd environment text.
|
||||
|
||||
## Runtime paths and permissions
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/authorized-writer-bridge
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/receipts
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/locks
|
||||
```
|
||||
|
||||
Required properties:
|
||||
|
||||
```yaml
|
||||
owner: API service identity
|
||||
mode: "0700"
|
||||
symlink: false
|
||||
persistent: true
|
||||
```
|
||||
|
||||
The directory contains redacted receipts only. Temporary signed grant
|
||||
directories are deleted after success or failure.
|
||||
|
||||
## Required staging environment
|
||||
|
||||
Configure the API service:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
AIOS_GITOPS_OWNER_PROVISION_SCOPE=writer:provision
|
||||
AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
```
|
||||
|
||||
Use `aios_authorized_task_writer_api.py` as the API entrypoint. It delegates
|
||||
all pre-existing routes and adds only the new endpoint.
|
||||
|
||||
## Pre-merge tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
unit_tests:
|
||||
total: 9
|
||||
failures: 0
|
||||
errors: 0
|
||||
smoke: PASS
|
||||
py_compile: PASS
|
||||
```
|
||||
|
||||
Also run all existing GitOps API and isolated writer tests affected by:
|
||||
|
||||
```text
|
||||
aios_chatgpt_actions_api
|
||||
aios_isolated_writer_api
|
||||
isolated_writer_authz
|
||||
isolated_writer_token_registry
|
||||
single_owner_governance
|
||||
OpenAPI schema validation
|
||||
```
|
||||
|
||||
Any regression blocks merge.
|
||||
|
||||
## Staging API acceptance
|
||||
|
||||
### Negative authentication
|
||||
|
||||
An Agent or non-Owner credential calling the endpoint must receive:
|
||||
|
||||
```yaml
|
||||
http_status: 403
|
||||
error: OWNER_AUTHORIZATION_REQUIRED
|
||||
```
|
||||
|
||||
### Negative scope tests
|
||||
|
||||
Requests enabling any of the following must be denied before signer execution:
|
||||
|
||||
```text
|
||||
production_change_authorized=true
|
||||
tag_change_authorized=true
|
||||
baseline_change_authorized=true
|
||||
main_write_authorized=true
|
||||
allowed_operations containing tag/main/baseline/admin operations
|
||||
missing required denied operation
|
||||
one_time_use=false
|
||||
expiry over 1800 seconds
|
||||
```
|
||||
|
||||
### Drift and conflict tests
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
wrong_base_oid:
|
||||
http_status: 409
|
||||
error: AUTHORIZED_BASE_OID_DRIFT
|
||||
|
||||
existing_unreceipted_target_branch:
|
||||
http_status: 409
|
||||
error: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
### Governed tool failures
|
||||
|
||||
Independently inject and verify fail-closed behavior for:
|
||||
|
||||
```text
|
||||
signer unavailable
|
||||
signer signature absent
|
||||
validator signature_valid=false
|
||||
validator signer_trusted=false
|
||||
validator plan expired
|
||||
provisioner grant_consumed=false
|
||||
provisioner writer scope enables main/tag/baseline/force push
|
||||
any required negative policy test false
|
||||
```
|
||||
|
||||
None may create or modify a Git ref.
|
||||
|
||||
## Live V2R1C6 provisioning request
|
||||
|
||||
After protected merge and API deployment, authenticate as the Owner and submit
|
||||
exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"owner_authorization_reference": "chat-owner-authorization:v2r1c6:20260806",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This request must be executed by the localhost-only bootstrap instance before
|
||||
the bridge code advances `main`. The endpoint must never infer “current main”.
|
||||
|
||||
If the Agent chooses the alternative merge-first path, it must stop after the
|
||||
merge and obtain an exact Owner amendment for the new merge OID. It must not
|
||||
silently replace the approved OID.
|
||||
|
||||
## Success response
|
||||
|
||||
Require:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
grant_consumed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: <exact amended authorized base OID>
|
||||
base_oid: <same exact OID>
|
||||
main_unchanged: true
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: <exact amended authorized base OID>
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
All seven negative policy results must be true.
|
||||
|
||||
Submit the identical request a second time. It must return the same grant and
|
||||
branch attestation with:
|
||||
|
||||
```yaml
|
||||
idempotent: true
|
||||
```
|
||||
|
||||
It must not run the provisioner again.
|
||||
|
||||
## Production postchecks
|
||||
|
||||
After API deployment and writer provisioning, independently verify:
|
||||
|
||||
```yaml
|
||||
existing_routes_regression: PASS
|
||||
new_endpoint: PASS
|
||||
main_ref: expected reviewed merge commit
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
V2R1C5_baseline_unchanged: true
|
||||
new_release_tag_created: false
|
||||
production_baseline_changed: false
|
||||
```
|
||||
|
||||
The deployment of this control-plane bridge does not authorize a V2R1C6
|
||||
production release. It only enables scoped code authoring.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the new API route fails:
|
||||
|
||||
1. Stop accepting new provisioning calls.
|
||||
2. Restore the previous API entrypoint.
|
||||
3. Restart and verify all existing routes.
|
||||
4. Preserve receipts and logs.
|
||||
5. Revoke any task writer capability already minted.
|
||||
6. Do not delete a branch or rewrite a tag.
|
||||
7. Revert the protected-main bridge commit through an ordinary reviewed revert
|
||||
PR if source rollback is required.
|
||||
|
||||
## Required final Agent report
|
||||
|
||||
```yaml
|
||||
STATUS: COMPLETED | PARTIALLY_COMPLETED | BLOCKED | FAILED
|
||||
|
||||
INTEGRATION:
|
||||
branch:
|
||||
commit:
|
||||
protected_pr:
|
||||
merge_commit:
|
||||
files_changed:
|
||||
|
||||
TESTS:
|
||||
unit:
|
||||
smoke:
|
||||
py_compile:
|
||||
regression:
|
||||
fault_injection:
|
||||
|
||||
DEPLOYMENT:
|
||||
service:
|
||||
entrypoint:
|
||||
runtime_status:
|
||||
endpoint_status:
|
||||
rollback_verified:
|
||||
|
||||
PROVISIONING:
|
||||
owner_authorization_reference:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signing_key_identity:
|
||||
branch:
|
||||
branch_oid:
|
||||
workspace_path:
|
||||
grant_consumed:
|
||||
idempotent_replay:
|
||||
|
||||
CONSISTENCY:
|
||||
main_oid:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target_oid:
|
||||
baseline_version:
|
||||
baseline_changed:
|
||||
tags_changed:
|
||||
|
||||
SECRETS:
|
||||
runtime_secret_reference_used:
|
||||
plaintext_secret_logged:
|
||||
plaintext_secret_committed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -0,0 +1,401 @@
|
||||
# Agent Deployment Requirements: Owner Authorization Broker V2
|
||||
|
||||
## Objective
|
||||
|
||||
Deploy the audited Owner Authorization Broker that closes the bootstrap gap
|
||||
without issuing a new general-purpose Owner credential and without modifying
|
||||
`/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
|
||||
The broker reuses one existing, non-wildcard Owner credential only to
|
||||
authenticate the Owner identity through one pre-existing exact scope. It then
|
||||
records an immutable task authorization event and directly invokes the
|
||||
existing signed grant -> validator -> one-shot provisioner chain.
|
||||
|
||||
Production deployment of V2R1C6 is **not** authorized by this task.
|
||||
|
||||
## Fixed bindings
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
The existing V2R1C5 objects are frozen:
|
||||
|
||||
```yaml
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
```
|
||||
|
||||
## 1. Package verification
|
||||
|
||||
Verify `MANIFEST.sha256` before installation. Reject any mismatch.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
tests/test_authorized_task_writer_provisioning.py \
|
||||
tests/test_owner_authorization_broker.py
|
||||
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
All checks must pass.
|
||||
|
||||
## 2. Existing Owner credential selection
|
||||
|
||||
Read only the non-secret registry metadata. Select one existing credential
|
||||
that satisfies all of the following:
|
||||
|
||||
```yaml
|
||||
role: owner
|
||||
wildcard_scope: false
|
||||
account: exact known Owner identity
|
||||
contains_exact_intake_scope: true
|
||||
```
|
||||
|
||||
Use one existing exact scope such as `commit:create` or `file:write`. Prefer
|
||||
the narrowest scope already assigned to the intended Owner.
|
||||
|
||||
Do not:
|
||||
|
||||
- use the historical wildcard Owner credential;
|
||||
- add `writer:provision` to any bearer token;
|
||||
- create or modify a bearer credential;
|
||||
- print, copy or commit bearer-token material.
|
||||
|
||||
Record only:
|
||||
|
||||
```yaml
|
||||
owner_account: <non-secret account>
|
||||
credential_id: <non-secret ID, when available>
|
||||
intake_scope: <one exact existing scope>
|
||||
scope_readback_available: true|false
|
||||
```
|
||||
|
||||
If `handler.need()` returns the exact scope set, configure the Owner account
|
||||
allowlist and leave the credential ID allowlist empty.
|
||||
|
||||
If scope metadata is not returned, configure the exact non-secret credential
|
||||
ID allowlist. If neither exact scope metadata nor a stable non-secret
|
||||
credential ID can be proven, stop:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE
|
||||
```
|
||||
|
||||
## 3. Staging installation
|
||||
|
||||
Install the four application modules in a root-owned staging directory:
|
||||
|
||||
```text
|
||||
authorized_task_writer_provisioning.py
|
||||
aios_authorized_task_writer_api.py
|
||||
owner_authorization_broker.py
|
||||
aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
The directory and files must not be group/world writable.
|
||||
|
||||
Prepare environment values:
|
||||
|
||||
```yaml
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED: "true"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES: <one exact existing scope>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES: <exact owner account>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS: <exact ID or empty>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS: "900"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT:
|
||||
/var/lib/aios-gitops/owner-authorization-broker
|
||||
```
|
||||
|
||||
Use the existing signer, validator and provisioner paths. Do not change their
|
||||
root governance configuration or disable signature checks.
|
||||
|
||||
Start a localhost-only staging instance on a temporary port. Do not replace
|
||||
the production service yet.
|
||||
|
||||
## 4. Authentication fail-closed tests
|
||||
|
||||
The staging endpoint is:
|
||||
|
||||
```text
|
||||
POST /api/governance/owner-authorizations
|
||||
```
|
||||
|
||||
Required tests:
|
||||
|
||||
```yaml
|
||||
unauthenticated:
|
||||
expected: 401
|
||||
|
||||
wrong_role:
|
||||
expected: 403
|
||||
|
||||
wrong_scope:
|
||||
expected: 403
|
||||
|
||||
wildcard_owner:
|
||||
expected: 403
|
||||
error: WILDCARD_OWNER_CREDENTIAL_REJECTED
|
||||
|
||||
unallowlisted_owner:
|
||||
expected: 403
|
||||
error: OWNER_IDENTITY_NOT_ALLOWED
|
||||
|
||||
production_capability_true:
|
||||
expected: 403
|
||||
error: PRODUCTION_CAPABILITY_REJECTED
|
||||
```
|
||||
|
||||
A connection failure, 404 or 500 is not an authorization-policy pass.
|
||||
|
||||
## 5. Formal provisioning request
|
||||
|
||||
Use the selected existing non-wildcard Owner credential through its runtime
|
||||
secret reference. Never place the bearer token in the request body, command
|
||||
line, shell history, output report or Git.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Owner authorization reference is generated by the broker. The caller must
|
||||
not submit or invent one.
|
||||
|
||||
Expected result:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_event_id: generated
|
||||
owner_authorization_reference: generated
|
||||
owner_authorization_digest_sha256: generated
|
||||
owner_authorization_consumed: true
|
||||
approval_plan_id: generated
|
||||
grant_digest_sha256: generated
|
||||
grant_consumed: true
|
||||
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
## 6. Independent post-provisioning verification
|
||||
|
||||
Verify the Authority ref:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify `main` remains unchanged, V2R1C5 tag object and peeled target remain
|
||||
unchanged, and the Production Baseline remains V2R1C5.
|
||||
|
||||
Verify the broker audit event under:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/owner-authorization-broker/events/
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
status: CONSUMED
|
||||
wildcard_scope_used: false
|
||||
grant_consumed: true
|
||||
permissions:
|
||||
directory: "0700"
|
||||
json_files: "0600"
|
||||
```
|
||||
|
||||
Do not return bearer-token material in the report.
|
||||
|
||||
## 7. Commit the broker through the new writer
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`, use only the writer's
|
||||
`POST /api/commit-file` route to put this code bundle on:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit the bridge and broker as separate intentional commits when practical:
|
||||
|
||||
1. Owner-authorized task-writer bridge.
|
||||
2. Audited Owner authorization broker and deployment docs.
|
||||
|
||||
Do not merge to `main` directly. Create a protected PR after branch commits
|
||||
and tests are complete.
|
||||
|
||||
## 8. Permanent service deployment
|
||||
|
||||
Only after protected review and merge:
|
||||
|
||||
1. Install the reviewed files from the merge commit.
|
||||
2. Apply a reviewed systemd drop-in based on the provided example.
|
||||
3. Start the wrapper entrypoint:
|
||||
`aios_owner_authorization_broker_api.py`.
|
||||
4. Verify all existing API routes still work.
|
||||
5. Verify the broker remains localhost/reverse-proxy constrained as intended.
|
||||
6. Re-run authentication and policy tests.
|
||||
7. Remove the temporary staging instance.
|
||||
|
||||
This permanent bridge deployment does not authorize a V2R1C6 production tag,
|
||||
Baseline update or application deployment.
|
||||
|
||||
## 9. Fail-closed conditions
|
||||
|
||||
Stop with `BLOCKED` for any of the following:
|
||||
|
||||
- Authority `main` no longer equals the fixed base OID.
|
||||
- Target branch exists at a different OID.
|
||||
- Only a wildcard Owner credential can authenticate.
|
||||
- Exact credential scope cannot be proven.
|
||||
- Owner identity is not explicitly allow-listed.
|
||||
- Signer, validator or provisioner self-test fails.
|
||||
- Grant is not marked one-time and consumed.
|
||||
- Writer allows main, tag, Baseline, force-push or branch-delete operations.
|
||||
- Audit event contains sensitive material.
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
## 10. Required Agent result
|
||||
|
||||
```yaml
|
||||
STATUS: READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
manifest_verified:
|
||||
unit_tests:
|
||||
smoke_tests:
|
||||
py_compile:
|
||||
|
||||
OWNER_CREDENTIAL:
|
||||
account:
|
||||
credential_id:
|
||||
intake_scope:
|
||||
exact_scope_proven:
|
||||
wildcard_used: false
|
||||
token_logged: false
|
||||
|
||||
BROKER:
|
||||
staging_listener:
|
||||
endpoint_status:
|
||||
event_id:
|
||||
authorization_reference:
|
||||
authorization_digest_sha256:
|
||||
event_consumed:
|
||||
audit_permissions_valid:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_digest_sha256:
|
||||
signer_identity:
|
||||
signature_valid:
|
||||
one_time_use:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commit_oids:
|
||||
protected_pr:
|
||||
merged:
|
||||
permanent_bridge_deployed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
# Agent execution requirements: V2R1C6 task identity overlay
|
||||
|
||||
## Required result
|
||||
|
||||
The Agent must return one of:
|
||||
|
||||
- `READY_FOR_CODE_AUTHORING`
|
||||
- `BLOCKED`
|
||||
- `FAILED`
|
||||
|
||||
The Agent may not report success until the task branch, isolated worktree and
|
||||
task writer are independently verified.
|
||||
|
||||
This stage is **not** a production deployment. It must not modify `main`,
|
||||
V2R1C5, tags, Registry, Production Baseline or production services.
|
||||
|
||||
## Fixed authority
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
owner_identity: aiman
|
||||
one_time_use: true
|
||||
maximum_ttl_seconds: 900
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
## 1. Verify package
|
||||
|
||||
Verify the externally supplied package against the published SHA-256 values and
|
||||
then verify every entry in `MANIFEST.sha256`.
|
||||
|
||||
Do not synthesize a missing archive. Use either the verified ZIP or TAR.GZ.
|
||||
|
||||
Required checks:
|
||||
|
||||
```yaml
|
||||
package_hash_match: true
|
||||
manifest_entries_match: true
|
||||
unit_tests_pass: true
|
||||
smoke_tests_pass: true
|
||||
py_compile_pass: true
|
||||
```
|
||||
|
||||
## 2. Create root-owned staging
|
||||
|
||||
Extract to a fresh directory such as:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/staging/v2r1c6-task-identity-overlay-<nonce>
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
owner: root
|
||||
group: root
|
||||
directory_mode: "0700"
|
||||
files_not_world_writable: true
|
||||
symlinks_rejected: true
|
||||
```
|
||||
|
||||
Do not overwrite the active runtime tree.
|
||||
|
||||
## 3. Re-run target-host tests
|
||||
|
||||
From the staging root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 scripts/smoke_task_identity_overlay.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/task_identity_overlay.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
scripts/call_task_identity_overlay.py
|
||||
```
|
||||
|
||||
Any failure is `BLOCKED`; do not proceed.
|
||||
|
||||
## 4. Prestart Git and production freeze checks
|
||||
|
||||
Read from the authority repository:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/heads/main
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
show-ref --verify --quiet \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/tags/V2R1C5
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse 'refs/tags/V2R1C5^{}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch_exists: false
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
```
|
||||
|
||||
Read Baseline and require:
|
||||
|
||||
```yaml
|
||||
version: V2R1C5
|
||||
status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If `main` has drifted, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: AUTHORIZED_BASE_OID_DRIFT
|
||||
```
|
||||
|
||||
If the target branch already exists at another OID, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
Do not delete, reset or force-push the branch.
|
||||
|
||||
## 5. Discover active secret references
|
||||
|
||||
The overlay does not read or modify `gitops.tokens`, but its issuer must compare
|
||||
the new random secret against all active bearer secret references.
|
||||
|
||||
Discover references without printing secret contents. At minimum include the
|
||||
currently active ChatGPT/GitOps bearer secret reference and any other bearer
|
||||
files consumed by the active API.
|
||||
|
||||
Pass them as a comma-separated list in:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS
|
||||
```
|
||||
|
||||
Evidence must contain only paths and SHA-256 match counts, not token values.
|
||||
|
||||
If active secret references cannot be enumerated, return:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: ACTIVE_SECRET_REFERENCE_SET_UNRESOLVED
|
||||
```
|
||||
|
||||
## 6. Install the transient systemd service
|
||||
|
||||
Use the provided template:
|
||||
|
||||
```text
|
||||
deploy/systemd/aios-v2r1c6-task-identity-overlay.service.example
|
||||
```
|
||||
|
||||
Substitute only:
|
||||
|
||||
- verified root-owned staging directory;
|
||||
- verified active secret-ref path list.
|
||||
|
||||
Do not change task ID, repository, base OID, target branch or denied
|
||||
capabilities.
|
||||
|
||||
The service must listen only on:
|
||||
|
||||
```text
|
||||
127.0.0.1:19864
|
||||
```
|
||||
|
||||
Before start, run:
|
||||
|
||||
```bash
|
||||
systemd-analyze verify \
|
||||
/etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
```bash
|
||||
curl --fail --silent http://127.0.0.1:19864/healthz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
service: aios-task-identity-overlay
|
||||
status: active
|
||||
global_token_registry_used: false
|
||||
loopback_only: true
|
||||
```
|
||||
|
||||
Do not expose the port externally.
|
||||
|
||||
## 7. Issue the one-time overlay
|
||||
|
||||
Use the root-only CLI. The authorization assertion is an audit label for this
|
||||
already approved Owner task; it must not be described as an external signed
|
||||
event.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
sudo env \
|
||||
PYTHONPATH=<staging>/active-runtime/app \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT=/var/lib/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT=/run/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID=V2R1C6_TASK_SCOPED_WRITER_PROVISIONING \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY=aiman \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF=refs/heads/main \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID=875d41c3101093149414292067fd700dc214d4d3 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH=agent/v2r1c6-mainline-reconciliation-20260805 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS=<verified-secret-ref-list> \
|
||||
python3 <staging>/active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
issue \
|
||||
--ttl-seconds 900 \
|
||||
--authorization-assertion-ref owner-session:V2R1C6:approved-20260806 \
|
||||
--operator-identity <audited-agent-identity> \
|
||||
--result-json /run/aios-gitops/v2r1c6-overlay-issued.json
|
||||
```
|
||||
|
||||
The result must contain:
|
||||
|
||||
```yaml
|
||||
status: OVERLAY_ACTIVE
|
||||
idempotent: false
|
||||
overlay_id: present
|
||||
secret_ref: under /run/aios-gitops/task-identity-overlays
|
||||
expires_at: within 900 seconds
|
||||
```
|
||||
|
||||
It must not contain a token.
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
secret_file_mode: "0600"
|
||||
metadata_contains_raw_token: false
|
||||
token_hash_unique_across_configured_secret_refs: true
|
||||
```
|
||||
|
||||
## 8. Negative tests
|
||||
|
||||
Before the valid request:
|
||||
|
||||
1. No Authorization header must return HTTP 401.
|
||||
2. A random wrong bearer must return HTTP 401.
|
||||
3. A request with `production_change_authorized=true` must be rejected.
|
||||
4. A request with another base OID or branch must be rejected.
|
||||
5. The valid overlay must remain `ACTIVE` after pre-auth request-validation
|
||||
failures.
|
||||
|
||||
Do not use the global Owner or ChatGPT bearer credential for this endpoint.
|
||||
|
||||
## 9. Send the valid request
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
deploy/v2r1c6-task-writer-authorization-request.json
|
||||
```
|
||||
|
||||
Use the provided client so the bearer is not placed in argv, shell history or
|
||||
logs:
|
||||
|
||||
```bash
|
||||
sudo python3 <staging>/scripts/call_task_identity_overlay.py \
|
||||
--secret-ref <secret_ref_from_issue_result> \
|
||||
--request-json <staging>/deploy/v2r1c6-task-writer-authorization-request.json \
|
||||
--url http://127.0.0.1:19864/api/governance/owner-authorizations \
|
||||
--result-json /var/lib/aios-gitops/v2r1c6-overlay-provision-result.json
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
http_status: 200
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_consumed: true
|
||||
grant_consumed: true
|
||||
identity_overlay_consumed: true
|
||||
identity_overlay_secret_removed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
The result must not include:
|
||||
|
||||
- bearer token;
|
||||
- private key;
|
||||
- signer secret;
|
||||
- raw signature;
|
||||
- credential value.
|
||||
|
||||
## 10. Independent acceptance
|
||||
|
||||
Verify the branch:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
rev-parse refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify worktree evidence returned by the provisioner:
|
||||
|
||||
```yaml
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
head_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
detached_head: false
|
||||
worktree_clean: true
|
||||
writer_status: active
|
||||
writes_enabled: true
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
Run policy negative tests against the task writer and require explicit policy
|
||||
denials for:
|
||||
|
||||
- main write;
|
||||
- another branch;
|
||||
- tag create/update;
|
||||
- Baseline write;
|
||||
- force push;
|
||||
- branch delete.
|
||||
|
||||
Network errors, 404, 500 and stopped-service failures do not count as policy
|
||||
denials.
|
||||
|
||||
## 11. Stop and clean up the overlay
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl disable aios-v2r1c6-task-identity-overlay.service || true
|
||||
sudo rm -f /etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
overlay_listener_stopped: true
|
||||
overlay_secret_removed: true
|
||||
global_token_registry_unchanged: true
|
||||
disabled_owner_record_unchanged: true
|
||||
active_chatgpt_bot_record_unchanged: true
|
||||
audit_metadata_retained: true
|
||||
```
|
||||
|
||||
The root-owned staging directory may be retained until the code is committed,
|
||||
then removed after checksum-confirmed repository integration.
|
||||
|
||||
## 12. Commit the developed code through the task writer
|
||||
|
||||
Only after writer acceptance, use `POST /api/commit-file` to commit the
|
||||
verified package contents to:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit in reviewable groups:
|
||||
|
||||
1. authorized task-writer bridge;
|
||||
2. Owner authorization broker;
|
||||
3. task identity overlay;
|
||||
4. tests, docs, OpenAPI and deployment templates.
|
||||
|
||||
Do not include:
|
||||
|
||||
- package archives;
|
||||
- runtime tokens;
|
||||
- `/run` or `/var/lib` state;
|
||||
- test result files containing host paths;
|
||||
- secret references containing values;
|
||||
- generated credentials.
|
||||
|
||||
Run the complete 40-test suite from the branch after commits.
|
||||
|
||||
## 13. Protected PR
|
||||
|
||||
Create a protected PR from:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
The PR must state:
|
||||
|
||||
- this change closes Owner authorization bootstrap gaps;
|
||||
- the overlay is localhost-only and one-time;
|
||||
- the global token registry is not modified;
|
||||
- no production release is authorized;
|
||||
- V2R1C5 tag and Baseline remain unchanged.
|
||||
|
||||
The Agent must not merge without the repository's normal protected review.
|
||||
|
||||
## 14. Required Agent report
|
||||
|
||||
```yaml
|
||||
STATUS:
|
||||
READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
zip_sha256:
|
||||
tar_gz_sha256:
|
||||
manifest_entries:
|
||||
manifest_matched:
|
||||
|
||||
TESTS:
|
||||
unit_total:
|
||||
unit_passed:
|
||||
smoke_bridge:
|
||||
smoke_broker:
|
||||
smoke_overlay:
|
||||
py_compile:
|
||||
|
||||
PRESTART:
|
||||
main_oid:
|
||||
target_branch_previously_absent:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target:
|
||||
baseline_unchanged:
|
||||
|
||||
OVERLAY:
|
||||
overlay_id:
|
||||
status:
|
||||
secret_ref:
|
||||
secret_file_mode:
|
||||
token_hash_unique:
|
||||
global_registry_used: false
|
||||
listener:
|
||||
expires_at:
|
||||
consumed:
|
||||
secret_removed:
|
||||
|
||||
NEGATIVE_TESTS:
|
||||
unauthenticated_401:
|
||||
wrong_token_401:
|
||||
production_capability_denied:
|
||||
wrong_base_denied:
|
||||
wrong_branch_denied:
|
||||
|
||||
OWNER_AUTHORIZATION:
|
||||
event_id:
|
||||
reference:
|
||||
consumed:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signature_valid:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commits:
|
||||
tests_after_commit:
|
||||
protected_pr:
|
||||
merged: false
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
global_token_registry_unchanged:
|
||||
overlay_listener_stopped:
|
||||
overlay_secret_removed:
|
||||
|
||||
SECRETS:
|
||||
plaintext_logged: false
|
||||
plaintext_committed: false
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
|
||||
## Fail-closed conditions
|
||||
|
||||
Return `BLOCKED` and stop on any of:
|
||||
|
||||
- authoritative main OID drift;
|
||||
- target branch conflict;
|
||||
- inability to enumerate active secret references;
|
||||
- secret hash collision;
|
||||
- overlay not bound to the exact task/base/branch;
|
||||
- non-loopback listener;
|
||||
- raw secret in result or logs;
|
||||
- Owner Authorization Broker event not created;
|
||||
- signer/validator/provisioner failure;
|
||||
- grant not consumed;
|
||||
- branch OID mismatch;
|
||||
- writer exposes main/tag/Baseline/force-push permissions;
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
Never fall back to the shared `gitops-write-bot.token`, a wildcard credential,
|
||||
manual `gitops.tokens` edits or a direct Git ref write.
|
||||
@@ -0,0 +1,10 @@
|
||||
bec7da1adb75bf5feaa63c60ec6d03adf15d7508742a486207033d000f72d4ff AGENT_DEPLOYMENT_REQUIREMENTS.md
|
||||
251b46e51ce09aa2fd843bee48ef24a5608f31d24c599841f086ceb25c1a1715 README.md
|
||||
783f68ec4f00a11d43ceed536196bfedccaf6614f6db9805f7b36d24e5fa7bc2 TEST_RESULTS.json
|
||||
c4b24b65195077716983acdc8611f705324413a74453858994ba51541957afd4 active-runtime/app/aios_authorized_task_writer_api.py
|
||||
8a1935fa098d47ac02ce51bbd9fd5fad7cf9ab9374ea3de5f630679b96681d84 active-runtime/app/authorized_task_writer_provisioning.py
|
||||
a05d4e8038fdb866ce17d4fb8a54798a268a1e8d44ff2cbc559069fa6ab2a667 deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
a60dac51802bff67c2cfff61acdeb0a3a05d74a4f6cb47e3f78295ceb5c6acd9 docs/gitops/authorized-task-writer-provisioning.md
|
||||
2690f76452bceddda90c728278c600bcebd533db24776e2966423c5a8655e4b5 docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
54e2845d2c3c026323ad45515e082d661e3b461feb3bfefedf5adc52db2d7b78 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
3cce2b744326fe12b89bcad3cd352c77738950964bf8519e72cb5e9c7a5dfbfc tests/test_authorized_task_writer_provisioning.py
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"git_branch_created": false,
|
||||
"git_commit_created": false,
|
||||
"production_mutation": false,
|
||||
"py_compile": {
|
||||
"files": [
|
||||
"active-runtime/app/authorized_task_writer_provisioning.py",
|
||||
"active-runtime/app/aios_authorized_task_writer_api.py"
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"schema": "aios.authorized-task-writer-bridge-test-report.v1",
|
||||
"secrets_included": false,
|
||||
"smoke": {
|
||||
"command": "python3 scripts/smoke_authorized_task_writer_provisioning.py",
|
||||
"status": "PASS"
|
||||
},
|
||||
"status": "PASS",
|
||||
"unit_tests": {
|
||||
"command": "python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py",
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"total": 9
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Fail-closed Agent Pull Runner primitives for AIOS autonomy v1.
|
||||
|
||||
The runner owns task discovery and reporting only. It never accepts shell
|
||||
strings: executors receive argv, an allowlisted cwd and an allowlisted env.
|
||||
Durable state is intentionally small so restart recovery and duplicate
|
||||
suppression can be verified without coupling the runner to one database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class RunnerError(RuntimeError):
|
||||
"""Raised when a runner contract would be violated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunnerConfig:
|
||||
worker_id: str
|
||||
capabilities: tuple[str, ...] = ()
|
||||
long_poll_seconds: int = 30
|
||||
fallback_seconds: int = 60
|
||||
jitter_seconds: int = 5
|
||||
max_backoff_seconds: int = 300
|
||||
executable_allowlist: tuple[str, ...] = ()
|
||||
working_directory_allowlist: tuple[str, ...] = ()
|
||||
environment_allowlist: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerState:
|
||||
inflight: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
terminal: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RunnerTransport(Protocol):
|
||||
def register_worker(self, worker_id: str, capabilities: Sequence[str]) -> Mapping[str, Any]: ...
|
||||
def heartbeat(self, worker_id: str) -> Mapping[str, Any]: ...
|
||||
def long_poll(self, worker_id: str, timeout_seconds: int) -> Mapping[str, Any] | None: ...
|
||||
def poll(self, worker_id: str) -> Mapping[str, Any] | None: ...
|
||||
def claim(self, worker_id: str, task: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def renew(self, worker_id: str, task_id: str) -> Mapping[str, Any]: ...
|
||||
def report_progress(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_result(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_evidence(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class JsonStateStore:
|
||||
"""Atomic JSON state store for restart recovery."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RunnerState:
|
||||
if not self.path.exists():
|
||||
return RunnerState()
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RunnerError("runner state must be an object")
|
||||
return RunnerState(
|
||||
inflight=dict(value.get("inflight", {})),
|
||||
terminal=dict(value.get("terminal", {})),
|
||||
)
|
||||
|
||||
def save(self, state: RunnerState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=self.path.name + ".", dir=self.path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump({"inflight": state.inflight, "terminal": state.terminal}, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, self.path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class AgentPullRunner:
|
||||
"""Pull, claim, execute and report one task at a time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RunnerConfig,
|
||||
transport: RunnerTransport,
|
||||
state_store: JsonStateStore | None = None,
|
||||
*,
|
||||
random_fn: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if not config.worker_id.strip():
|
||||
raise RunnerError("worker_id is required")
|
||||
self.config = config
|
||||
self.transport = transport
|
||||
self.state_store = state_store
|
||||
self.random_fn = random_fn
|
||||
self.state = state_store.load() if state_store else RunnerState()
|
||||
|
||||
def _save(self) -> None:
|
||||
if self.state_store:
|
||||
self.state_store.save(self.state)
|
||||
|
||||
def register(self) -> Mapping[str, Any]:
|
||||
return self.transport.register_worker(self.config.worker_id, self.config.capabilities)
|
||||
|
||||
def heartbeat(self) -> Mapping[str, Any]:
|
||||
return self.transport.heartbeat(self.config.worker_id)
|
||||
|
||||
def discovery_delay(self, failures: int) -> float:
|
||||
failures = max(0, int(failures))
|
||||
base = min(self.config.max_backoff_seconds, self.config.fallback_seconds * (2**failures))
|
||||
jitter = self.random_fn() * max(0, self.config.jitter_seconds)
|
||||
return min(self.config.max_backoff_seconds, base + jitter)
|
||||
|
||||
def discover_once(self) -> Mapping[str, Any] | None:
|
||||
try:
|
||||
task = self.transport.long_poll(self.config.worker_id, self.config.long_poll_seconds)
|
||||
except Exception:
|
||||
task = None
|
||||
if task is None:
|
||||
task = self.transport.poll(self.config.worker_id)
|
||||
if not task:
|
||||
return None
|
||||
task_id = str(task.get("task_id", ""))
|
||||
if not task_id:
|
||||
raise RunnerError("task_id is required")
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_suppressed", "task_id": task_id}
|
||||
claimed = dict(self.transport.claim(self.config.worker_id, task))
|
||||
self.state.inflight[task_id] = claimed
|
||||
self._save()
|
||||
return claimed
|
||||
|
||||
def renew(self, task_id: str) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.renew(self.config.worker_id, task_id)
|
||||
|
||||
def report_progress(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_progress(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_evidence(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_evidence(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_result(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_terminal_suppressed", "task_id": task_id}
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
result = dict(self.transport.report_result(self.config.worker_id, task_id, payload))
|
||||
self.state.terminal[task_id] = {"result": result, "reported_at": time.time()}
|
||||
self.state.inflight.pop(task_id, None)
|
||||
self._save()
|
||||
return result
|
||||
|
||||
def recover_after_restart(self) -> list[Mapping[str, Any]]:
|
||||
recovered: list[Mapping[str, Any]] = []
|
||||
for task_id in tuple(self.state.inflight):
|
||||
recovered.append(self.renew(task_id))
|
||||
return recovered
|
||||
|
||||
def validate_execution(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> None:
|
||||
if not argv or any(not isinstance(item, str) or not item for item in argv):
|
||||
raise RunnerError("argv_only_execution_required")
|
||||
if any(ch in argv[0] for ch in (";", "|", "&", "`", "\n")):
|
||||
raise RunnerError("shell_syntax_denied")
|
||||
executable = shutil.which(argv[0])
|
||||
if not executable:
|
||||
raise RunnerError("executable_not_found")
|
||||
if self.config.executable_allowlist and not any(
|
||||
os.path.realpath(executable).startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
or os.path.realpath(executable) == os.path.realpath(prefix)
|
||||
for prefix in self.config.executable_allowlist
|
||||
):
|
||||
raise RunnerError("executable_not_allowlisted")
|
||||
real_cwd = os.path.realpath(os.fspath(cwd))
|
||||
if self.config.working_directory_allowlist and not any(
|
||||
real_cwd == os.path.realpath(prefix)
|
||||
or real_cwd.startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
for prefix in self.config.working_directory_allowlist
|
||||
):
|
||||
raise RunnerError("working_directory_not_allowlisted")
|
||||
if self.config.environment_allowlist and any(key not in self.config.environment_allowlist for key in env):
|
||||
raise RunnerError("environment_key_not_allowlisted")
|
||||
|
||||
def execute_argv(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.validate_execution(argv, cwd, env)
|
||||
completed = subprocess.run(list(argv), cwd=os.fspath(cwd), env=dict(env), shell=False, text=True, capture_output=True, check=False)
|
||||
return {"returncode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Small operational CLI for the Agent Pull Runner contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from agent_pull_runner import AgentPullRunner, RunnerConfig
|
||||
|
||||
|
||||
class _NoopTransport:
|
||||
def register_worker(self, worker_id, capabilities): return {"status": "registered", "worker_id": worker_id, "capabilities": list(capabilities)}
|
||||
def heartbeat(self, worker_id): return {"status": "alive", "worker_id": worker_id}
|
||||
def long_poll(self, worker_id, timeout_seconds): return None
|
||||
def poll(self, worker_id): return None
|
||||
def claim(self, worker_id, task): return task
|
||||
def renew(self, worker_id, task_id): return {"status": "renewed", "task_id": task_id}
|
||||
def report_progress(self, worker_id, task_id, payload): return {"status": "progress_recorded", "task_id": task_id}
|
||||
def report_result(self, worker_id, task_id, payload): return {"status": "result_recorded", "task_id": task_id}
|
||||
def report_evidence(self, worker_id, task_id, payload): return {"status": "evidence_recorded", "task_id": task_id}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Agent Pull Runner")
|
||||
parser.add_argument("--worker-id", default="self-test-worker")
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
runner = AgentPullRunner(RunnerConfig(worker_id=args.worker_id, fallback_seconds=60), _NoopTransport())
|
||||
if args.self_test:
|
||||
print(json.dumps({"status": "PASS", "registration": runner.register(), "fallback_seconds": runner.config.fallback_seconds}, sort_keys=True))
|
||||
return 0
|
||||
print(json.dumps({"status": "READY", "worker_id": args.worker_id}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,73 @@
|
||||
"""HTTP adapter for Owner-authorized task writer provisioning.
|
||||
|
||||
Run this module instead of aios_chatgpt_actions_api.py. All existing routes are
|
||||
delegated unchanged; only POST /api/task-writer/provision-authorized is added.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
from authorized_task_writer_provisioning import (
|
||||
AuthorizedTaskWriterProvisioner,
|
||||
OwnerContext,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/task-writer/provision-authorized"
|
||||
|
||||
|
||||
def _owner_authorization(handler):
|
||||
scope = os.getenv(
|
||||
"AIOS_GITOPS_OWNER_PROVISION_SCOPE", "writer:provision"
|
||||
)
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_AUTHORIZATION_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
return auth
|
||||
|
||||
|
||||
def _authorized_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
auth = _owner_authorization(self)
|
||||
if not auth:
|
||||
return
|
||||
data = self.body()
|
||||
owner_reference = str(data.get("owner_authorization_reference") or "")
|
||||
try:
|
||||
result = AuthorizedTaskWriterProvisioner().provision(
|
||||
data,
|
||||
OwnerContext(
|
||||
identity=str(auth["account"]),
|
||||
role=str(auth["role"]),
|
||||
authorization_reference=owner_reference,
|
||||
),
|
||||
)
|
||||
return self.sendj(result)
|
||||
except ProvisioningError as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Do not expose tool output, paths containing credentials, or traceback data.
|
||||
return self.sendj({"error": "AUTHORIZED_WRITER_BRIDGE_INTERNAL_ERROR"}, 500)
|
||||
|
||||
|
||||
api.H.do_POST = _authorized_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Governed API facade for branch-bound repair leases.
|
||||
|
||||
The facade is transport-neutral so the permanent control plane can expose it
|
||||
through its existing authenticated loopback/API seam without creating a second
|
||||
Git writer or a global credential registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from branch_repair_lease import LeaseError, RepairLease, RepairLeaseManager, sanitized_lease_view
|
||||
|
||||
|
||||
class BranchRepairLeaseAPI:
|
||||
"""Validate operation envelopes and return sanitized lease metadata."""
|
||||
|
||||
def __init__(self, manager: RepairLeaseManager) -> None:
|
||||
self.manager = manager
|
||||
|
||||
@staticmethod
|
||||
def _required(request: Mapping[str, Any], name: str) -> Any:
|
||||
value = request.get(name)
|
||||
if value is None or value == "":
|
||||
raise LeaseError(f"missing required field: {name}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _view(lease: RepairLease) -> dict[str, Any]:
|
||||
return {"status": "ok", "lease": sanitized_lease_view(lease)}
|
||||
|
||||
def handle(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
operation = self._required(request, "operation")
|
||||
owner = str(self._required(request, "owner_identity"))
|
||||
if operation == "issue":
|
||||
lease = self.manager.issue(
|
||||
task_id=str(self._required(request, "task_id")),
|
||||
owner_identity=owner,
|
||||
branch=str(self._required(request, "branch")),
|
||||
description=str(self._required(request, "description")),
|
||||
allowed_paths=tuple(self._required(request, "allowed_paths")),
|
||||
ttl_seconds=int(request.get("ttl_seconds", 3600)),
|
||||
)
|
||||
elif operation == "attach_existing_branch":
|
||||
lease = self.manager.attach_existing_branch(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
)
|
||||
elif operation == "authorize_commit":
|
||||
lease = self.manager.authorize_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
content=request.get("content"),
|
||||
)
|
||||
elif operation == "record_commit":
|
||||
lease = self.manager.record_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
new_head_oid=str(self._required(request, "new_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
)
|
||||
elif operation == "recover":
|
||||
lease = self.manager.recover(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "renew":
|
||||
lease = self.manager.renew(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
ttl_seconds=int(self._required(request, "ttl_seconds")),
|
||||
)
|
||||
elif operation == "complete":
|
||||
lease = self.manager.complete(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "revoke":
|
||||
lease = self.manager.revoke(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
else:
|
||||
raise LeaseError("unsupported lease operation")
|
||||
return self._view(lease)
|
||||
except (LeaseError, TypeError, ValueError):
|
||||
return {"status": "blocked", "error": "lease_request_rejected"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Root-only CLI for the branch-bound repair lease API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aios_branch_repair_lease_api import BranchRepairLeaseAPI
|
||||
from branch_repair_lease import GitProbe, LeaseStore
|
||||
from branch_repair_lease import RepairLeaseManager
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS branch repair lease CLI")
|
||||
parser.add_argument("--store", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--request-file")
|
||||
parser.add_argument("--request-json")
|
||||
args = parser.parse_args()
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print(json.dumps({"status": "blocked", "error": "root_required"}))
|
||||
return 2
|
||||
if bool(args.request_file) == bool(args.request_json):
|
||||
print(json.dumps({"status": "blocked", "error": "one_request_source_required"}))
|
||||
return 2
|
||||
try:
|
||||
raw = (
|
||||
Path(args.request_file).read_text(encoding="utf-8")
|
||||
if args.request_file
|
||||
else str(args.request_json)
|
||||
)
|
||||
request = json.loads(raw)
|
||||
if not isinstance(request, dict):
|
||||
raise ValueError("request must be an object")
|
||||
api = BranchRepairLeaseAPI(RepairLeaseManager(LeaseStore(args.store), GitProbe(args.bare_repo)))
|
||||
response = api.handle(request)
|
||||
print(json.dumps(response, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||
return 0 if response.get("status") == "ok" else 1
|
||||
except Exception:
|
||||
print(json.dumps({"status": "blocked", "error": "lease_cli_failed"}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""CLI client for the governed local writer service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Send one governed-local-writer request")
|
||||
parser.add_argument("--socket", required=True, help="AF_UNIX socket path")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--request", help="Request JSON string")
|
||||
group.add_argument("--request-file", help="Path to JSON request file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_request_text(args: argparse.Namespace) -> str:
|
||||
if args.request is not None:
|
||||
return args.request
|
||||
request_path = os.fspath(args.request_file)
|
||||
with open(request_path, "r", encoding="utf-8") as request_file:
|
||||
return request_file.read()
|
||||
|
||||
|
||||
def send_request(socket_path: str, request_text: str) -> dict[str, object]:
|
||||
"""Open one Unix socket, send one NDJSON request, and parse one NDJSON response."""
|
||||
payload = json.loads(request_text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request must be a JSON object")
|
||||
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.connect(socket_path)
|
||||
client.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
response_chunks: list[str] = []
|
||||
while True:
|
||||
chunk = client.recv(1024 * 1024).decode("utf-8")
|
||||
if not chunk:
|
||||
break
|
||||
response_chunks.append(chunk)
|
||||
if "\n" in chunk:
|
||||
break
|
||||
response_text = "".join(response_chunks)
|
||||
|
||||
response_line = response_text.split("\n", 1)[0].strip()
|
||||
if not response_line:
|
||||
raise RuntimeError("empty response")
|
||||
response = json.loads(response_line)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
request_text = _load_request_text(args)
|
||||
response = send_request(args.socket, request_text)
|
||||
print(json.dumps(response, separators=(",", ":"), sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # pragma: no cover - CLI entrypoint behavior
|
||||
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,931 @@
|
||||
"""Unix-socket service for governed local writer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from governed_local_writer import (
|
||||
AppendCommitRequest,
|
||||
AuthorizationError,
|
||||
BranchPolicy,
|
||||
CreateBranchRequest,
|
||||
GovernedAuthorization,
|
||||
PeerIdentity,
|
||||
PRINCIPAL,
|
||||
TASK_ID,
|
||||
extract_peer_identity,
|
||||
contains_secret_like_content,
|
||||
load_authorization_file,
|
||||
validate_file_changes,
|
||||
)
|
||||
|
||||
|
||||
class ExistingGitOpsHandler:
|
||||
"""Delegate repository mutations to the installed GitOps handler seam."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_by_branch: Mapping[str, str | os.PathLike[str]],
|
||||
bare_repo: str | os.PathLike[str],
|
||||
api_source: str | os.PathLike[str],
|
||||
) -> None:
|
||||
self.workspace_by_branch = {str(k): Path(v) for k, v in workspace_by_branch.items()}
|
||||
self.bare_repo = Path(bare_repo)
|
||||
self.api_source = Path(api_source)
|
||||
|
||||
@staticmethod
|
||||
def _restore_env(previous: Mapping[str, str | None]) -> None:
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
def _load_api(self, workspace: Path):
|
||||
if not self.api_source.is_file() or self.api_source.is_symlink():
|
||||
raise RuntimeError("installed GitOps handler source is unavailable")
|
||||
keys = (
|
||||
"AIOS_GITOPS_WORKSPACE",
|
||||
"AIOS_GITOPS_BARE_REPO",
|
||||
"AIOS_GITOPS_WRITES_ENABLED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_CONFIG_KEY_1",
|
||||
"GIT_CONFIG_VALUE_1",
|
||||
)
|
||||
previous = {key: os.environ.get(key) for key in keys}
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_WORKSPACE": str(workspace),
|
||||
"AIOS_GITOPS_BARE_REPO": str(self.bare_repo),
|
||||
"AIOS_GITOPS_WRITES_ENABLED": "true",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"GIT_CONFIG_COUNT": "2",
|
||||
"GIT_CONFIG_KEY_0": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_0": str(workspace),
|
||||
"GIT_CONFIG_KEY_1": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_1": str(self.bare_repo),
|
||||
}
|
||||
)
|
||||
module_name = f"_governed_gitops_api_{uuid.uuid4().hex}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, self.api_source)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("unable to load installed GitOps handler")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
decision = module.evaluate_git_write(module.WS)
|
||||
if not decision.allowed:
|
||||
raise RuntimeError("existing GitOps write gate denied bootstrap")
|
||||
return module, previous, module_name
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
raise
|
||||
|
||||
def _workspace(self, branch: str) -> Path:
|
||||
workspace = self.workspace_by_branch.get(branch)
|
||||
if workspace is None or not workspace.is_dir():
|
||||
raise RuntimeError("branch workspace is not authorized")
|
||||
return workspace
|
||||
|
||||
@staticmethod
|
||||
def _git(workspace: Path, args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=workspace, text=True, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("governed handler git probe failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
# A prior handler call may have completed the fast-forward sync before
|
||||
# the process observed a post-commit error. Reconcile only an exact,
|
||||
# metadata-only result; never overwrite or move an existing ref here.
|
||||
try:
|
||||
existing = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"rev-parse",
|
||||
f"refs/heads/{request.branch}",
|
||||
],
|
||||
)
|
||||
except RuntimeError:
|
||||
existing = ""
|
||||
if existing:
|
||||
parent = self._git(workspace, ["--git-dir", str(self.bare_repo), "rev-parse", f"{existing}^1"])
|
||||
changed = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"diff-tree",
|
||||
"--no-commit-id",
|
||||
"--name-only",
|
||||
"-r",
|
||||
existing,
|
||||
],
|
||||
).splitlines()
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
if parent != request.base_oid or changed != [f"patches/{safe_branch}.branch.json"]:
|
||||
raise RuntimeError("existing branch is not an exact metadata-only bootstrap")
|
||||
return {"head_oid": existing, "parent_oid": parent, "handler_status": "reconciled"}
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_base"
|
||||
parent = self._git(
|
||||
workspace,
|
||||
["--git-dir", str(self.bare_repo), "rev-parse", "refs/heads/main"],
|
||||
)
|
||||
if parent != request.base_oid:
|
||||
raise RuntimeError("authorized base OID drift")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.base_oid, create=True)
|
||||
phase = "write_metadata"
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
metadata_path = f"patches/{safe_branch}.branch.json"
|
||||
module.write_json(
|
||||
metadata_path,
|
||||
{
|
||||
"schema": "aios.governed-local-writer-bootstrap.v1",
|
||||
"task_id": TASK_ID,
|
||||
"principal": PRINCIPAL,
|
||||
"branch": request.branch,
|
||||
"parent_oid": request.base_oid,
|
||||
"authorization_sha256": request.authorization_digest,
|
||||
},
|
||||
)
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(
|
||||
[metadata_path], "chore(governance): bootstrap repair lease branch"
|
||||
)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.base_oid or (request.head_oid and head_oid != request.head_oid):
|
||||
raise RuntimeError("bootstrap branch lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_head"
|
||||
current = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current != request.expected_head_oid:
|
||||
raise RuntimeError("branch head advanced or is stale")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.expected_head_oid, create=False)
|
||||
phase = "write_files"
|
||||
paths: list[str] = []
|
||||
for change in request.changes:
|
||||
paths.append(module.write_file(change.path, change.content))
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(paths, request.commit_message)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.expected_head_oid or head_oid == first_parent:
|
||||
raise RuntimeError("append commit lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
|
||||
class GovernedWriterHandler(Protocol):
|
||||
"""Small adapter interface for real writer backend handlers."""
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
"""Create a branch using internal repo services."""
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
"""Append a commit using internal repo services."""
|
||||
|
||||
|
||||
class AuditSink(Protocol):
|
||||
"""Pluggable sink for sanitized audit records."""
|
||||
|
||||
def append(self, record: "AuditRecord") -> None:
|
||||
"""Persist one audit record."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditRecord:
|
||||
"""Sanitized audit log entry."""
|
||||
|
||||
authorization_digest: str
|
||||
peer_pid: int
|
||||
peer_uid: int
|
||||
peer_gid: int
|
||||
peer_executable: str | None
|
||||
timestamp: datetime
|
||||
operation: str
|
||||
branch: str
|
||||
result: str
|
||||
|
||||
|
||||
class InMemoryAuditSink:
|
||||
"""Simple in-memory audit log used by tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[AuditRecord] = []
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AuthorizationState:
|
||||
"""Tracks one-shot operation consumption."""
|
||||
|
||||
create_done: set[str] = field(default_factory=set)
|
||||
append_done: set[str] = field(default_factory=set)
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
class GovernedLocalWriterService:
|
||||
"""Policy-aware AF_UNIX service that calls an injected handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: str | os.PathLike[str],
|
||||
authorization: GovernedAuthorization,
|
||||
handler: GovernedWriterHandler,
|
||||
*,
|
||||
audit_sink: AuditSink | None = None,
|
||||
peer_identity_fn: Callable[[socket.socket], PeerIdentity] | None = None,
|
||||
state_path: str | os.PathLike[str] | None = None,
|
||||
) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.authorization = authorization
|
||||
self.handler = handler
|
||||
self.peer_identity_fn = peer_identity_fn or extract_peer_identity
|
||||
self.state = _AuthorizationState()
|
||||
self.audit_sink = audit_sink or InMemoryAuditSink()
|
||||
self.state_path = Path(state_path) if state_path is not None else None
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Restore one-shot state without restoring request or secret material."""
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("state must be an object")
|
||||
if raw.get("authorization_sha256") != self.authorization.authorization_sha256:
|
||||
raise ValueError("state authorization digest mismatch")
|
||||
self.state.create_done = {str(v) for v in raw.get("create_done", [])}
|
||||
self.state.append_done = {str(v) for v in raw.get("append_done", [])}
|
||||
self.state.consumed = bool(raw.get("consumed", False))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise AuthorizationError("authorization state is invalid") from exc
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": self.state.consumed,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> None:
|
||||
self.audit_sink.append(
|
||||
AuditRecord(
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
peer_pid=peer.pid,
|
||||
peer_uid=peer.uid,
|
||||
peer_gid=peer.gid,
|
||||
peer_executable=peer.executable,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
operation=operation,
|
||||
branch=branch,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
def _require_request_field(self, request: Mapping[str, Any], field_name: str) -> str:
|
||||
value = request.get(field_name)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AuthorizationError(f"{field_name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
def _branch_policy(self, branch: str) -> BranchPolicy:
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is None:
|
||||
raise AuthorizationError("branch is not authorized")
|
||||
return policy
|
||||
|
||||
def _ensure_not_consumed(self) -> None:
|
||||
if self.state.consumed:
|
||||
raise AuthorizationError("authorization consumed")
|
||||
if datetime.now(tz=timezone.utc) >= self.authorization.expires_at:
|
||||
raise AuthorizationError("authorization expired")
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op == "append_commit":
|
||||
self.state.append_done.add(branch)
|
||||
|
||||
if self.state.create_done.issuperset(self.authorization.create_branches) and self.state.append_done.issuperset(
|
||||
self.authorization.append_branches
|
||||
):
|
||||
self.state.consumed = True
|
||||
self._persist_state()
|
||||
|
||||
def _validate_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = self._require_request_field(request, "task_id")
|
||||
if task_id != TASK_ID:
|
||||
raise AuthorizationError("invalid task_id")
|
||||
|
||||
principal = self._require_request_field(request, "principal")
|
||||
if principal != PRINCIPAL:
|
||||
raise AuthorizationError("invalid principal")
|
||||
|
||||
operation = self._require_request_field(request, "op")
|
||||
if operation not in {"create_branch", "append_commit"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
if operation in self.authorization.denied_operations:
|
||||
raise AuthorizationError("operation denied by policy")
|
||||
|
||||
branch = self._require_request_field(request, "branch")
|
||||
self._ensure_not_consumed()
|
||||
policy = self._branch_policy(branch)
|
||||
|
||||
if operation == "create_branch":
|
||||
if policy.create_branch is None:
|
||||
raise AuthorizationError("branch does not allow create_branch")
|
||||
if branch in self.state.create_done:
|
||||
raise AuthorizationError("create_branch already used for this branch")
|
||||
|
||||
base_oid = self._require_request_field(request, "base_oid")
|
||||
raw_head_oid = request.get("head_oid")
|
||||
if raw_head_oid is not None and (not isinstance(raw_head_oid, str) or not raw_head_oid):
|
||||
raise AuthorizationError("head_oid must be a non-empty string when supplied")
|
||||
head_oid = str(raw_head_oid) if raw_head_oid is not None else None
|
||||
if base_oid != policy.create_branch.base_oid:
|
||||
raise AuthorizationError("base_oid mismatch")
|
||||
if policy.create_branch.head_oid is not None and head_oid != policy.create_branch.head_oid:
|
||||
raise AuthorizationError("head_oid mismatch")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"base_oid": base_oid,
|
||||
"head_oid": head_oid,
|
||||
}
|
||||
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow append_commit")
|
||||
if policy.create_branch is not None and branch not in self.state.create_done:
|
||||
raise AuthorizationError("append_commit requires prior successful create_branch")
|
||||
if branch in self.state.append_done:
|
||||
raise AuthorizationError("append_commit already used for this branch")
|
||||
|
||||
expected_head = policy.append_commit.expected_head_oid
|
||||
if "expected_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "expected_head_oid")
|
||||
elif "current_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "current_head_oid")
|
||||
else:
|
||||
raise AuthorizationError("expected_head_oid or current_head_oid required")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", supplied_head):
|
||||
raise AuthorizationError("head check must be a full commit OID")
|
||||
if expected_head is not None and supplied_head != expected_head:
|
||||
raise AuthorizationError("head check failed")
|
||||
|
||||
if request.get("force") is True or request.get("non_fast_forward") is True:
|
||||
raise AuthorizationError("non-fast-forward operations are not allowed")
|
||||
if request.get("history_rewrite") is True:
|
||||
raise AuthorizationError("history rewrite is not allowed")
|
||||
parent_oids = request.get("parent_oids")
|
||||
if parent_oids is not None:
|
||||
if not isinstance(parent_oids, list) or parent_oids != [supplied_head]:
|
||||
raise AuthorizationError("parent_oids must contain exactly expected_head_oid")
|
||||
|
||||
changes = request.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
raise AuthorizationError("changes must be a list")
|
||||
validated_changes = validate_file_changes(changes, policy.append_commit.allowed_paths)
|
||||
commit_message = request.get("commit_message", "chore(governance): append governed commit")
|
||||
if not isinstance(commit_message, str) or not commit_message.strip() or len(commit_message) > 200:
|
||||
raise AuthorizationError("commit_message must be a short non-empty string")
|
||||
if "\n" in commit_message or "\r" in commit_message or contains_secret_like_content(commit_message):
|
||||
raise AuthorizationError("commit_message failed policy")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"expected_head_oid": supplied_head,
|
||||
"changes": validated_changes,
|
||||
"commit_message": commit_message.strip(),
|
||||
}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
"""Run one request through policy checks and handler adapter."""
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_request(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details["branch"])
|
||||
|
||||
if operation == "create_branch":
|
||||
self.handler.create_branch(
|
||||
CreateBranchRequest(
|
||||
branch=branch,
|
||||
base_oid=str(details["base_oid"]),
|
||||
head_oid=(
|
||||
str(details["head_oid"])
|
||||
if details.get("head_oid") is not None
|
||||
else None
|
||||
),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
)
|
||||
)
|
||||
self._mark_success("create_branch", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
|
||||
self.handler.append_commit(
|
||||
AppendCommitRequest(
|
||||
branch=branch,
|
||||
expected_head_oid=str(details["expected_head_oid"]),
|
||||
changes=tuple(details["changes"]),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
commit_message=str(details["commit_message"]),
|
||||
)
|
||||
)
|
||||
self._mark_success("append_commit", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
except Exception as error:
|
||||
self._new_audit(peer, operation or "unknown", branch, f"error:{error.__class__.__name__}")
|
||||
if isinstance(error, PermissionError):
|
||||
code = "permission_denied"
|
||||
elif isinstance(error, AuthorizationError):
|
||||
code = "request_denied"
|
||||
else:
|
||||
code = "handler_failed"
|
||||
if code == "handler_failed" and str(error).startswith("handler_phase="):
|
||||
code = str(error)
|
||||
return {
|
||||
"status": "error",
|
||||
"operation": operation or "unknown",
|
||||
"branch": branch,
|
||||
"error": code,
|
||||
}
|
||||
|
||||
def _write_json(self, connection: socket.socket, payload: Mapping[str, Any]) -> None:
|
||||
connection.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
|
||||
def _handle_client(self, connection: socket.socket) -> None:
|
||||
with connection:
|
||||
try:
|
||||
peer = self.peer_identity_fn(connection)
|
||||
except Exception:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "connection",
|
||||
"branch": "",
|
||||
"error": "peer identity validation failed",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
with connection.makefile("r", encoding="utf-8") as stream:
|
||||
for raw_line in stream:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "invalid json",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not isinstance(request, Mapping):
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "request must be a JSON object",
|
||||
},
|
||||
)
|
||||
continue
|
||||
self._write_json(connection, self.process_request(request, peer))
|
||||
|
||||
def _bind_socket(self) -> socket.socket:
|
||||
if self.socket_path.exists():
|
||||
raise RuntimeError("governed writer socket already exists")
|
||||
parent = self.socket_path.parent
|
||||
if not parent.is_dir():
|
||||
raise RuntimeError("governed writer socket parent is missing")
|
||||
if parent.is_symlink():
|
||||
raise RuntimeError("governed writer socket parent must not be a symlink")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(self.socket_path))
|
||||
os.chmod(self.socket_path, 0o600)
|
||||
server.listen(16)
|
||||
return server
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Serve newline-delimited JSON over a filesystem AF_UNIX socket."""
|
||||
server = self._bind_socket()
|
||||
try:
|
||||
while True:
|
||||
connection, _ = server.accept()
|
||||
self._handle_client(connection)
|
||||
finally:
|
||||
server.close()
|
||||
if self.socket_path.exists():
|
||||
self.socket_path.unlink()
|
||||
|
||||
|
||||
class JsonAuditSink:
|
||||
"""Append-only root-owned audit sink containing no request payloads."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
payload = {
|
||||
"authorization_sha256": record.authorization_digest,
|
||||
"peer_pid": record.peer_pid,
|
||||
"peer_uid": record.peer_uid,
|
||||
"peer_gid": record.peer_gid,
|
||||
"peer_executable": record.peer_executable,
|
||||
"timestamp": record.timestamp.isoformat(),
|
||||
"operation": record.operation,
|
||||
"branch": record.branch,
|
||||
"result": record.result,
|
||||
}
|
||||
line = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
os.write(fd, line)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
|
||||
# V2R1C6 durable receipt overlay. This subclasses the original root-only
|
||||
# writer in-place so the existing AF_UNIX/SO_PEERCRED and handler policy remain
|
||||
# the only mutation path.
|
||||
_LegacyGovernedLocalWriterService = GovernedLocalWriterService
|
||||
|
||||
class GovernedLocalWriterService(_LegacyGovernedLocalWriterService):
|
||||
"""Existing writer with durable request receipts and safe recovery."""
|
||||
|
||||
def _load_state(self) -> None:
|
||||
super()._load_state()
|
||||
self.state.receipts = {}
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise AuthorizationError("authorization state is invalid")
|
||||
receipts = raw.get("receipts", {})
|
||||
if not isinstance(receipts, dict):
|
||||
raise AuthorizationError("authorization receipts are invalid")
|
||||
self.state.receipts = {
|
||||
str(key): dict(value)
|
||||
for key, value in receipts.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": bool(self.state.consumed),
|
||||
"receipts": self.state.receipts,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _request_identity(self, request: Mapping[str, Any]) -> dict[str, str]:
|
||||
canonical = json.dumps(dict(request), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
fallback = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
request_id = request.get("request_id")
|
||||
idempotency_key = request.get("idempotency_key")
|
||||
request_id = request_id.strip() if isinstance(request_id, str) and request_id.strip() else "legacy-" + fallback
|
||||
idempotency_key = idempotency_key.strip() if isinstance(idempotency_key, str) and idempotency_key.strip() else "legacy-" + fallback
|
||||
for field_name, value in (("request_id", request_id), ("idempotency_key", idempotency_key)):
|
||||
if len(value) > 200 or "\n" in value or "\r" in value:
|
||||
raise AuthorizationError(field_name + " is invalid")
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_fingerprint": fallback,
|
||||
}
|
||||
|
||||
def _find_receipt(self, details: Mapping[str, Any], compare_fingerprint: bool = True):
|
||||
request_id = str(details["request_id"])
|
||||
idempotency_key = str(details["idempotency_key"])
|
||||
fingerprint = str(details.get("request_fingerprint", ""))
|
||||
for receipt in self.state.receipts.values():
|
||||
if receipt.get("request_id") == request_id or receipt.get("idempotency_key") == idempotency_key:
|
||||
if compare_fingerprint and receipt.get("request_fingerprint") != fingerprint:
|
||||
raise AuthorizationError("idempotency key reused with different request")
|
||||
return receipt
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _receipt_response(receipt: Mapping[str, object], status: str = "receipt") -> dict[str, object]:
|
||||
response = {
|
||||
"status": status,
|
||||
"request_id": receipt.get("request_id"),
|
||||
"idempotency_key": receipt.get("idempotency_key"),
|
||||
"operation": receipt.get("operation"),
|
||||
"branch": receipt.get("branch", ""),
|
||||
"receipt_status": receipt.get("status"),
|
||||
}
|
||||
for field_name in ("commit_oid", "final_head", "parent_oid", "audit_sha256", "error_code", "safe_to_retry"):
|
||||
if field_name in receipt:
|
||||
response[field_name] = receipt[field_name]
|
||||
return response
|
||||
|
||||
def _new_receipt(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
return {
|
||||
"request_id": str(details["request_id"]),
|
||||
"idempotency_key": str(details["idempotency_key"]),
|
||||
"request_fingerprint": str(details["request_fingerprint"]),
|
||||
"operation": str(details["operation"]),
|
||||
"branch": str(details.get("branch", "")),
|
||||
"expected_head_oid": str(details.get("expected_head_oid", "")),
|
||||
"status": "accepted",
|
||||
"created_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"safe_to_retry": False,
|
||||
}
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> str:
|
||||
super()._new_audit(peer, operation, branch, result)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"peer_pid": peer.pid,
|
||||
"peer_uid": peer.uid,
|
||||
"peer_gid": peer.gid,
|
||||
"peer_executable": peer.executable,
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": result,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.append_done.add(branch)
|
||||
# The provisioning grant is one-shot; the task writer lease itself must
|
||||
# support multiple append receipts for the exact authorized branches.
|
||||
self.state.consumed = False
|
||||
self._persist_state()
|
||||
|
||||
def _validate_durable(self, raw_request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = self._require_request_field(raw_request, "op")
|
||||
identity = self._request_identity(raw_request)
|
||||
if operation == "query_receipt":
|
||||
return {"operation": operation, **identity}
|
||||
if operation == "recover_request":
|
||||
branch = self._require_request_field(raw_request, "branch")
|
||||
policy = self._branch_policy(branch)
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow recovery")
|
||||
expected = raw_request.get("expected_head_oid") or policy.append_commit.expected_head_oid
|
||||
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", expected):
|
||||
raise AuthorizationError("recovery requires expected_head_oid")
|
||||
return {"operation": operation, "branch": branch, "expected_head_oid": expected.lower(), **identity}
|
||||
if operation not in {"create_branch", "append_commit", "append_files", "append_batch"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
request = dict(raw_request)
|
||||
if operation in {"append_files", "append_batch"}:
|
||||
request["op"] = "append_commit"
|
||||
if operation in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.consumed = False
|
||||
branch = request.get("branch")
|
||||
removed = False
|
||||
dynamic_expected = False
|
||||
if isinstance(branch, str) and branch in self.state.append_done:
|
||||
self.state.append_done.remove(branch)
|
||||
removed = True
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is not None and policy.append_commit is not None and policy.append_commit.expected_head_oid is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[branch] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=branch, expected_head_oid=None, allowed_paths=policy.append_commit.allowed_paths))
|
||||
dynamic_expected = True
|
||||
try:
|
||||
details = super()._validate_request(request)
|
||||
finally:
|
||||
if dynamic_expected:
|
||||
policy = self.authorization.branch_operations.get(str(branch))
|
||||
if policy is not None and policy.append_commit is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[str(branch)] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=str(branch), expected_head_oid=str(raw_request.get("expected_head_oid") or ""), allowed_paths=policy.append_commit.allowed_paths))
|
||||
if removed:
|
||||
self.state.append_done.add(str(branch))
|
||||
details.update(identity)
|
||||
details["operation"] = operation
|
||||
return details
|
||||
|
||||
def _recover_backend(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
if not hasattr(self.handler, "_workspace") or not hasattr(self.handler, "_git"):
|
||||
raise RuntimeError("recovery backend unavailable")
|
||||
workspace = self.handler._workspace(str(details["branch"]))
|
||||
expected = str(details["expected_head_oid"])
|
||||
current = self.handler._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current == expected:
|
||||
return {"status": "not_started", "final_head": current}
|
||||
parent = self.handler._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if parent != expected:
|
||||
raise RuntimeError("external branch advance during recovery")
|
||||
return {"status": "applied", "commit_oid": current, "final_head": current, "parent_oid": parent}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_durable(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details.get("branch", ""))
|
||||
if operation == "query_receipt":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
return self._receipt_response(receipt) if receipt is not None else {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"]}
|
||||
if operation == "recover_request":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
if receipt is None:
|
||||
return {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"], "safe_to_retry": True}
|
||||
if receipt.get("status") == "applied":
|
||||
return self._receipt_response(receipt)
|
||||
recovered = self._recover_backend(details)
|
||||
if recovered.get("status") == "applied":
|
||||
receipt.update({"status": "applied", "commit_oid": recovered.get("commit_oid"), "final_head": recovered.get("final_head"), "parent_oid": recovered.get("parent_oid"), "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt)
|
||||
receipt["safe_to_retry"] = True
|
||||
self._persist_state()
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._find_receipt(details)
|
||||
if receipt is not None:
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._new_receipt(details)
|
||||
self.state.receipts[str(details["request_id"])] = receipt
|
||||
self._persist_state()
|
||||
if operation == "create_branch":
|
||||
result = self.handler.create_branch(CreateBranchRequest(branch=branch, base_oid=str(details["base_oid"]), head_oid=str(details["head_oid"]) if details.get("head_oid") is not None else None, authorization_digest=self.authorization.authorization_sha256))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("create_branch", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
result = self.handler.append_commit(AppendCommitRequest(branch=branch, expected_head_oid=str(details["expected_head_oid"]), changes=tuple(details["changes"]), authorization_digest=self.authorization.authorization_sha256, commit_message=str(details["commit_message"])))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
except Exception as error:
|
||||
try:
|
||||
identity = self._request_identity(raw_request)
|
||||
receipt = self.state.receipts.get(identity["request_id"])
|
||||
if receipt is not None and receipt.get("status") == "accepted":
|
||||
receipt.update({"status": "failed", "error_code": error.__class__.__name__, "safe_to_retry": False})
|
||||
self._persist_state()
|
||||
except Exception:
|
||||
pass
|
||||
self._new_audit(peer, operation, branch, "error:" + error.__class__.__name__)
|
||||
return {"status": "error", "operation": operation, "branch": branch, "error": "request_denied" if isinstance(error, AuthorizationError) else "handler_failed"}
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="AIOS governed local writer")
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--authorization", required=True)
|
||||
parser.add_argument("--state", required=True)
|
||||
parser.add_argument("--audit", required=True)
|
||||
parser.add_argument("--repair-branch", required=True)
|
||||
parser.add_argument("--repair-workspace", required=True)
|
||||
parser.add_argument("--candidate-branch", required=True)
|
||||
parser.add_argument("--candidate-workspace", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--api-source", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print("governed local writer requires uid 0", file=sys.stderr)
|
||||
return 2
|
||||
args = _parse_args()
|
||||
authorization = load_authorization_file(args.authorization)
|
||||
handler = ExistingGitOpsHandler(
|
||||
workspace_by_branch={
|
||||
args.repair_branch: args.repair_workspace,
|
||||
args.candidate_branch: args.candidate_workspace,
|
||||
},
|
||||
bare_repo=args.bare_repo,
|
||||
api_source=args.api_source,
|
||||
)
|
||||
service = GovernedLocalWriterService(
|
||||
args.socket,
|
||||
authorization,
|
||||
handler,
|
||||
audit_sink=JsonAuditSink(args.audit),
|
||||
state_path=args.state,
|
||||
)
|
||||
service.serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - systemd entrypoint
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""HTTP adapter for audited Owner authorization and writer provisioning.
|
||||
|
||||
This module layers on top of ``aios_authorized_task_writer_api`` so existing
|
||||
routes and the legacy exact ``writer:provision`` route remain available. The
|
||||
new endpoint does not mint or modify bearer credentials. It authenticates an
|
||||
existing, non-wildcard, explicitly allow-listed Owner credential and then
|
||||
invokes the audited broker.
|
||||
|
||||
Endpoint:
|
||||
POST /api/governance/owner-authorizations
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Any, Mapping
|
||||
|
||||
import aios_authorized_task_writer_api as bridge_api
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import (
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationError,
|
||||
OwnerPrincipal,
|
||||
)
|
||||
|
||||
|
||||
api = bridge_api.api
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _configured_intake_scope() -> str:
|
||||
scopes = [
|
||||
item.strip()
|
||||
for item in os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES", ""
|
||||
).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if len(scopes) != 1:
|
||||
raise OwnerAuthorizationError(
|
||||
"EXACTLY_ONE_HTTP_INTAKE_SCOPE_REQUIRED", 503
|
||||
)
|
||||
scope = scopes[0]
|
||||
if scope == "writer:provision" or scope == "*" or scope.endswith(":*"):
|
||||
raise OwnerAuthorizationError("HTTP_INTAKE_SCOPE_REJECTED", 503)
|
||||
return scope
|
||||
|
||||
|
||||
def _scope_set(auth: Mapping[str, Any]) -> frozenset[str]:
|
||||
raw = auth.get("scopes")
|
||||
if raw is None:
|
||||
raw = auth.get("scope")
|
||||
if raw is None:
|
||||
return frozenset()
|
||||
if isinstance(raw, str):
|
||||
candidates = raw.replace(",", " ").split()
|
||||
elif isinstance(raw, (list, tuple, set, frozenset)):
|
||||
candidates = [str(item) for item in raw]
|
||||
else:
|
||||
raise OwnerAuthorizationError("OWNER_SCOPE_EVIDENCE_INVALID", 403)
|
||||
return frozenset(item.strip() for item in candidates if item.strip())
|
||||
|
||||
|
||||
def _credential_id(auth: Mapping[str, Any]) -> str:
|
||||
for name in (
|
||||
"credential_id",
|
||||
"token_id",
|
||||
"key_id",
|
||||
"auth_id",
|
||||
):
|
||||
value = str(auth.get(name) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _authenticate_existing_owner(handler) -> OwnerPrincipal | None:
|
||||
scope = _configured_intake_scope()
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_ROLE_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or auth.get("identity") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
scopes = _scope_set(auth)
|
||||
if any(item == "*" or item.endswith(":*") for item in scopes):
|
||||
handler.sendj(
|
||||
{"error": "WILDCARD_OWNER_CREDENTIAL_REJECTED"}, 403
|
||||
)
|
||||
return None
|
||||
return OwnerPrincipal(
|
||||
identity=identity,
|
||||
role="owner",
|
||||
authenticated_scope=scope,
|
||||
token_scopes=scopes,
|
||||
credential_id=_credential_id(auth),
|
||||
)
|
||||
|
||||
|
||||
def _broker_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
if not _enabled():
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_DISABLED"}, 503
|
||||
)
|
||||
try:
|
||||
principal = _authenticate_existing_owner(self)
|
||||
if principal is None:
|
||||
return
|
||||
data = self.body()
|
||||
result = OwnerAuthorizationBroker().authorize_and_provision(
|
||||
data, principal
|
||||
)
|
||||
return self.sendj(result)
|
||||
except (OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Never expose registry data, bearer tokens, signer output or traceback.
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR"}, 500
|
||||
)
|
||||
|
||||
|
||||
api.H.do_POST = _broker_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback-only HTTP API for the task identity overlay.
|
||||
|
||||
Endpoints:
|
||||
GET /healthz
|
||||
POST /api/governance/owner-authorizations
|
||||
|
||||
The API authenticates only the short-lived overlay credential. It does not read
|
||||
or modify the global bearer-token registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import OwnerAuthorizationError
|
||||
from task_identity_overlay import (
|
||||
MAX_BODY_BYTES,
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayService,
|
||||
)
|
||||
|
||||
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _bearer(header: str) -> str:
|
||||
prefix = "Bearer "
|
||||
if not header.startswith(prefix):
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
token = header[len(prefix):].strip()
|
||||
if not token or len(token) > 4096:
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
return token
|
||||
|
||||
|
||||
class OverlayHandler(BaseHTTPRequestHandler):
|
||||
server_version = "aios-task-identity-overlay/1"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def overlay(self) -> TaskIdentityOverlayService:
|
||||
return self.server.overlay # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Never log Authorization or request bodies.
|
||||
return
|
||||
|
||||
def _send(self, status: int, value: dict[str, Any]) -> None:
|
||||
payload = (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise IdentityOverlayError("OVERLAY_CONTENT_LENGTH_INVALID") from None
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
raise IdentityOverlayError("OVERLAY_BODY_SIZE_REJECTED", 413)
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_JSON_INVALID") from None
|
||||
if not isinstance(value, dict):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/healthz":
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"service": "aios-task-identity-overlay",
|
||||
"status": "active" if _enabled() else "disabled",
|
||||
"global_token_registry_used": False,
|
||||
"loopback_only": True,
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
if not _enabled():
|
||||
self._send(503, {"error": "IDENTITY_OVERLAY_DISABLED"})
|
||||
return
|
||||
try:
|
||||
token = _bearer(self.headers.get("Authorization", ""))
|
||||
result = self.overlay.provision(self._body(), token)
|
||||
self._send(200, result)
|
||||
except (IdentityOverlayError, OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body: dict[str, Any] = {"error": exc.code}
|
||||
if getattr(exc, "details", None):
|
||||
body["details"] = exc.details
|
||||
self._send(exc.status, body)
|
||||
except Exception:
|
||||
self._send(500, {"error": "IDENTITY_OVERLAY_API_INTERNAL_ERROR"})
|
||||
|
||||
|
||||
class OverlayServer(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
overlay: TaskIdentityOverlayService,
|
||||
) -> None:
|
||||
super().__init__(address, OverlayHandler)
|
||||
self.overlay = overlay
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_HOST", "127.0.0.1")
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise SystemExit("identity overlay must listen on loopback")
|
||||
try:
|
||||
port = int(os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_PORT", "19864"))
|
||||
except ValueError:
|
||||
raise SystemExit("invalid overlay port") from None
|
||||
if not 1024 <= port <= 65535:
|
||||
raise SystemExit("invalid overlay port")
|
||||
config = IdentityOverlayConfig.from_environment()
|
||||
server = OverlayServer(
|
||||
(host, port),
|
||||
TaskIdentityOverlayService(config),
|
||||
)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-governed CLI for issuing and revoking task identity overlays.
|
||||
|
||||
The CLI never prints the bearer token. It returns only the root-owned secret
|
||||
reference and non-secret metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from task_identity_overlay import (
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayStore,
|
||||
)
|
||||
|
||||
|
||||
def _write_result(path: str, value: dict) -> None:
|
||||
data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
if path == "-":
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(data, encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(
|
||||
description="Issue/status/revoke a task-scoped identity overlay"
|
||||
)
|
||||
sub = value.add_subparsers(dest="command", required=True)
|
||||
|
||||
issue = sub.add_parser("issue")
|
||||
issue.add_argument("--ttl-seconds", type=int, default=900)
|
||||
issue.add_argument("--authorization-assertion-ref", required=True)
|
||||
issue.add_argument(
|
||||
"--operator-identity",
|
||||
default=os.getenv("SUDO_USER") or os.getenv("USER") or "root",
|
||||
)
|
||||
issue.add_argument("--result-json", default="-")
|
||||
|
||||
status = sub.add_parser("status")
|
||||
status.add_argument("--overlay-id", required=True)
|
||||
status.add_argument("--result-json", default="-")
|
||||
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("--overlay-id", required=True)
|
||||
revoke.add_argument("--reason", required=True)
|
||||
revoke.add_argument("--result-json", default="-")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
store = TaskIdentityOverlayStore(
|
||||
IdentityOverlayConfig.from_environment()
|
||||
)
|
||||
if args.command == "issue":
|
||||
result = store.issue(
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
authorization_assertion_ref=args.authorization_assertion_ref,
|
||||
operator_identity=args.operator_identity,
|
||||
)
|
||||
elif args.command == "status":
|
||||
result = store.status(args.overlay_id)
|
||||
else:
|
||||
result = store.revoke(args.overlay_id, reason=args.reason)
|
||||
_write_result(args.result_json, result)
|
||||
return 0
|
||||
except IdentityOverlayError as exc:
|
||||
body = {"status": "BLOCKED", "error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
_write_result(getattr(args, "result_json", "-"), body)
|
||||
return 2
|
||||
except Exception:
|
||||
_write_result(
|
||||
getattr(args, "result_json", "-"),
|
||||
{"status": "FAILED", "error": "IDENTITY_OVERLAY_CLI_INTERNAL_ERROR"},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,953 @@
|
||||
"""Owner-authorized, fail-closed task writer provisioning bridge.
|
||||
|
||||
This module converts an authenticated Owner authorization into a canonical,
|
||||
short-lived, one-time grant request. It delegates signing, validation and
|
||||
worktree/writer provisioning to existing root-governed executables.
|
||||
|
||||
It never writes Git refs directly and never authorizes main, tag, baseline or
|
||||
production mutations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from signer_contract_compatibility import (
|
||||
deployed_signer_help,
|
||||
run_compatible_signer,
|
||||
run_compatible_validator,
|
||||
)
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
TASK_RE = re.compile(r"^[A-Z0-9][A-Z0-9._:-]{7,127}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
OWNER_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{3,255}$")
|
||||
|
||||
REQUIRED_REQUEST_FIELDS = frozenset(
|
||||
{
|
||||
"task_id",
|
||||
"repository",
|
||||
"base_ref",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"owner_authorization_reference",
|
||||
"one_time_use",
|
||||
"expires_in_seconds",
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
"allowed_operations",
|
||||
"denied_operations",
|
||||
}
|
||||
)
|
||||
REQUIRED_ALLOWED_OPERATIONS = frozenset(
|
||||
{
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch",
|
||||
}
|
||||
)
|
||||
REQUIRED_DENIED_OPERATIONS = frozenset(
|
||||
{
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite",
|
||||
}
|
||||
)
|
||||
REQUIRED_NEGATIVE_TESTS = frozenset(
|
||||
{
|
||||
"main_write_denied",
|
||||
"other_branch_write_denied",
|
||||
"tag_create_denied",
|
||||
"tag_update_denied",
|
||||
"baseline_write_denied",
|
||||
"force_push_denied",
|
||||
"branch_delete_denied",
|
||||
}
|
||||
)
|
||||
SENSITIVE_KEY_MARKERS = (
|
||||
"authorization",
|
||||
"credential",
|
||||
"private_key",
|
||||
"secret",
|
||||
"signature",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
"""Stable fail-closed error returned to the API adapter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
status: int = 400,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerContext:
|
||||
identity: str
|
||||
role: str
|
||||
authorization_reference: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvisioningConfig:
|
||||
repository_identity: str
|
||||
authority_git_dir: Path
|
||||
state_root: Path
|
||||
signer: Path
|
||||
validator: Path
|
||||
provisioner: Path
|
||||
governed_use_sudo: bool = False
|
||||
sudo_path: Path = Path("/usr/bin/sudo")
|
||||
max_expiry_seconds: int = 1800
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "ProvisioningConfig":
|
||||
return cls(
|
||||
repository_identity=os.getenv(
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY", "aios/aios"
|
||||
).strip(),
|
||||
authority_git_dir=Path(
|
||||
os.getenv("AIOS_GITOPS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git")
|
||||
),
|
||||
state_root=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT",
|
||||
"/var/lib/aios-gitops/authorized-writer-bridge",
|
||||
)
|
||||
),
|
||||
signer=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_GRANT_SIGNER_ADAPTER",
|
||||
"/usr/local/libexec/aios-grant-signer-adapter",
|
||||
)
|
||||
),
|
||||
validator=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_GRANT_VALIDATOR",
|
||||
"/usr/local/libexec/aios-grant-validator",
|
||||
)
|
||||
),
|
||||
provisioner=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_TASK_WRITER_PROVISIONER",
|
||||
"/usr/local/libexec/aios-task-writer-provisioner",
|
||||
)
|
||||
),
|
||||
governed_use_sudo=os.getenv(
|
||||
"AIOS_GITOPS_GOVERNED_USE_SUDO", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"},
|
||||
sudo_path=Path(
|
||||
os.getenv("AIOS_GITOPS_SUDO_PATH", "/usr/bin/sudo")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso_utc(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, value: Any, mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise ProvisioningError("STATE_PATH_SYMLINK_REJECTED", 500)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(canonical_json_bytes(value))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError("RESULT_FILE_INVALID", 500)
|
||||
if path.stat().st_size <= 0 or path.stat().st_size > 1_048_576:
|
||||
raise ProvisioningError("RESULT_FILE_INVALID", 500)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise ProvisioningError("RESULT_JSON_INVALID", 500) from None
|
||||
if not isinstance(value, dict):
|
||||
raise ProvisioningError("RESULT_OBJECT_REQUIRED", 500)
|
||||
return value
|
||||
|
||||
|
||||
def _safe_receipt_id(task_id: str, repository: str, branch: str) -> str:
|
||||
return hashlib.sha256(f"{task_id}\0{repository}\0{branch}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _redacted(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
cleaned = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in SENSITIVE_KEY_MARKERS):
|
||||
if lowered in {
|
||||
"owner_authorization_reference",
|
||||
"signing_key_identity",
|
||||
"signer_identity",
|
||||
}:
|
||||
cleaned[key] = inner
|
||||
else:
|
||||
cleaned[key] = "[REDACTED]"
|
||||
else:
|
||||
cleaned[key] = _redacted(inner)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [_redacted(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _run(
|
||||
argv: list[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
timeout: int = 60,
|
||||
allowed_returncodes: tuple[int, ...] = (0,),
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env=dict(env) if env is not None else None,
|
||||
)
|
||||
if result.returncode not in allowed_returncodes:
|
||||
raise ProvisioningError(
|
||||
"GOVERNED_TOOL_FAILED",
|
||||
503,
|
||||
details={
|
||||
"tool": Path(argv[0]).name,
|
||||
"returncode": result.returncode,
|
||||
"stderr": result.stderr[-1000:],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _git(git_dir: Path, *args: str, allowed_returncodes: tuple[int, ...] = (0,)) -> str:
|
||||
result = _run(
|
||||
["git", "--git-dir", str(git_dir), *args],
|
||||
timeout=30,
|
||||
allowed_returncodes=allowed_returncodes,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _verify_executable(path: Path, name: str) -> None:
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise ProvisioningError(f"{name}_EXECUTABLE_INVALID", 503)
|
||||
info = path.stat(follow_symlinks=False)
|
||||
if not stat.S_ISREG(info.st_mode) or not os.access(path, os.X_OK):
|
||||
raise ProvisioningError(f"{name}_EXECUTABLE_INVALID", 503)
|
||||
if info.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||
raise ProvisioningError(f"{name}_EXECUTABLE_PERMISSIONS_REJECTED", 503)
|
||||
|
||||
|
||||
def _tool_argv(
|
||||
config: ProvisioningConfig,
|
||||
path: Path,
|
||||
*args: str,
|
||||
) -> list[str]:
|
||||
if config.governed_use_sudo:
|
||||
return [str(config.sudo_path), "-n", "--", str(path), *args]
|
||||
return [str(path), *args]
|
||||
|
||||
|
||||
def _verify_tool_contract(
|
||||
path: Path,
|
||||
name: str,
|
||||
config: ProvisioningConfig,
|
||||
) -> None:
|
||||
_verify_executable(path, name)
|
||||
if config.governed_use_sudo:
|
||||
_verify_executable(config.sudo_path, "SUDO")
|
||||
help_result = _run(_tool_argv(config, path, "--help"), timeout=15)
|
||||
if name == "SIGNER":
|
||||
sign_help = _run(
|
||||
_tool_argv(config, path, "sign", "--help"),
|
||||
timeout=15,
|
||||
)
|
||||
if deployed_signer_help(sign_help.stdout, sign_help.stderr):
|
||||
_run(_tool_argv(config, path, "self-test"), timeout=30)
|
||||
return
|
||||
for flag in ("--grant-dir", "--result-json"):
|
||||
if flag not in help_result.stdout and flag not in help_result.stderr:
|
||||
raise ProvisioningError(f"{name}_CONTRACT_UNSUPPORTED", 503)
|
||||
_run(_tool_argv(config, path, "--self-test"), timeout=30)
|
||||
|
||||
|
||||
def _validate_request(
|
||||
raw: Mapping[str, Any],
|
||||
*,
|
||||
owner: OwnerContext,
|
||||
config: ProvisioningConfig,
|
||||
) -> dict[str, Any]:
|
||||
if owner.role != "owner" or not owner.identity.strip():
|
||||
raise ProvisioningError("OWNER_AUTHORIZATION_REQUIRED", 403)
|
||||
if set(raw) != REQUIRED_REQUEST_FIELDS:
|
||||
raise ProvisioningError("REQUEST_FIELDS_REJECTED")
|
||||
if raw["repository"] != config.repository_identity:
|
||||
raise ProvisioningError("REPOSITORY_IDENTITY_MISMATCH", 409)
|
||||
if raw["base_ref"] != "refs/heads/main":
|
||||
raise ProvisioningError("BASE_REF_REJECTED", 409)
|
||||
if not isinstance(raw["base_oid"], str) or not OID_RE.fullmatch(raw["base_oid"]):
|
||||
raise ProvisioningError("BASE_OID_REJECTED")
|
||||
if (
|
||||
not isinstance(raw["target_branch"], str)
|
||||
or not BRANCH_RE.fullmatch(raw["target_branch"])
|
||||
or raw["target_branch"] in {"main", "master"}
|
||||
):
|
||||
raise ProvisioningError("TARGET_BRANCH_REJECTED")
|
||||
if not isinstance(raw["task_id"], str) or not TASK_RE.fullmatch(raw["task_id"]):
|
||||
raise ProvisioningError("TASK_ID_REJECTED")
|
||||
if (
|
||||
not isinstance(raw["owner_authorization_reference"], str)
|
||||
or not OWNER_REF_RE.fullmatch(raw["owner_authorization_reference"])
|
||||
or raw["owner_authorization_reference"] != owner.authorization_reference
|
||||
):
|
||||
raise ProvisioningError("OWNER_AUTHORIZATION_REFERENCE_MISMATCH", 403)
|
||||
if raw["one_time_use"] is not True:
|
||||
raise ProvisioningError("ONE_TIME_USE_REQUIRED")
|
||||
expiry = raw["expires_in_seconds"]
|
||||
if (
|
||||
not isinstance(expiry, int)
|
||||
or isinstance(expiry, bool)
|
||||
or not 60 <= expiry <= config.max_expiry_seconds
|
||||
):
|
||||
raise ProvisioningError("GRANT_LIFETIME_REJECTED")
|
||||
for name in (
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
):
|
||||
if raw[name] is not False:
|
||||
raise ProvisioningError("PRODUCTION_CAPABILITY_REJECTED", 403)
|
||||
allowed = raw["allowed_operations"]
|
||||
denied = raw["denied_operations"]
|
||||
if not isinstance(allowed, list) or frozenset(allowed) != REQUIRED_ALLOWED_OPERATIONS:
|
||||
raise ProvisioningError("ALLOWED_OPERATIONS_MISMATCH", 403)
|
||||
if len(allowed) != len(set(allowed)):
|
||||
raise ProvisioningError("ALLOWED_OPERATIONS_MISMATCH", 403)
|
||||
if not isinstance(denied, list) or frozenset(denied) != REQUIRED_DENIED_OPERATIONS:
|
||||
raise ProvisioningError("DENIED_OPERATIONS_MISMATCH", 403)
|
||||
if len(denied) != len(set(denied)):
|
||||
raise ProvisioningError("DENIED_OPERATIONS_MISMATCH", 403)
|
||||
return dict(raw)
|
||||
|
||||
|
||||
def _prepare_state_root(path: Path) -> None:
|
||||
if not path.is_absolute() or path.is_symlink():
|
||||
raise ProvisioningError("STATE_ROOT_INVALID", 500)
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(path, 0o700)
|
||||
if path.stat().st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||
raise ProvisioningError("STATE_ROOT_PERMISSIONS_REJECTED", 500)
|
||||
(path / "receipts").mkdir(mode=0o700, exist_ok=True)
|
||||
(path / "locks").mkdir(mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def _prestart_git_check(
|
||||
request: Mapping[str, Any],
|
||||
config: ProvisioningConfig,
|
||||
*,
|
||||
receipt_exists: bool,
|
||||
) -> dict[str, Any]:
|
||||
git_dir = config.authority_git_dir
|
||||
if not git_dir.is_absolute() or git_dir.is_symlink() or not git_dir.is_dir():
|
||||
raise ProvisioningError("AUTHORITY_GIT_DIR_INVALID", 503)
|
||||
actual_main = _git(git_dir, "rev-parse", request["base_ref"])
|
||||
if actual_main != request["base_oid"]:
|
||||
raise ProvisioningError(
|
||||
"AUTHORIZED_BASE_OID_DRIFT",
|
||||
409,
|
||||
details={"expected": request["base_oid"], "actual": actual_main},
|
||||
)
|
||||
ref = f"refs/heads/{request['target_branch']}"
|
||||
existing = _git(
|
||||
git_dir,
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--hash",
|
||||
ref,
|
||||
allowed_returncodes=(0, 1, 128),
|
||||
)
|
||||
if existing and not receipt_exists:
|
||||
raise ProvisioningError(
|
||||
"EXISTING_BRANCH_OID_CONFLICT",
|
||||
409,
|
||||
details={"branch_oid": existing},
|
||||
)
|
||||
if existing and receipt_exists:
|
||||
ancestor = _run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(git_dir),
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
request["base_oid"],
|
||||
ref,
|
||||
],
|
||||
allowed_returncodes=(0, 1),
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise ProvisioningError("EXISTING_BRANCH_OID_CONFLICT", 409)
|
||||
return {
|
||||
"authoritative_main_oid": actual_main,
|
||||
"target_branch_preexisting": bool(existing),
|
||||
"target_branch_oid": existing or None,
|
||||
}
|
||||
|
||||
|
||||
def _build_plan(
|
||||
request: Mapping[str, Any],
|
||||
owner: OwnerContext,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
expires_at = now + timedelta(seconds=int(request["expires_in_seconds"]))
|
||||
grant_request = {
|
||||
"schema": "aios.task-scoped-writer-grant-request.v1",
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"base": {"ref": request["base_ref"], "oid": request["base_oid"]},
|
||||
"target": {"branch": request["target_branch"]},
|
||||
"execution_contract": {
|
||||
"branch_creation": "governed_one_shot_provisioner",
|
||||
"writer_route": "POST /api/commit-file",
|
||||
"createBranch_route_required": False,
|
||||
},
|
||||
"owner_authorization": {
|
||||
"identity": owner.identity,
|
||||
"reference": owner.authorization_reference,
|
||||
"authenticated_role": owner.role,
|
||||
},
|
||||
"allowed_operations": sorted(request["allowed_operations"]),
|
||||
"denied_operations": sorted(request["denied_operations"]),
|
||||
"one_time_use": True,
|
||||
"authorization": {
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
},
|
||||
"issued_at": iso_utc(now),
|
||||
"not_before": iso_utc(now - timedelta(seconds=2)),
|
||||
"expires_at": iso_utc(expires_at),
|
||||
"nonce": secrets.token_hex(32),
|
||||
}
|
||||
binding_digest = sha256_json(grant_request)
|
||||
plan = {
|
||||
"schema": "aios.task-scoped-writer-approval-plan.v1",
|
||||
"approval_plan_id": f"sha256:{binding_digest}",
|
||||
"grant_request_sha256": binding_digest,
|
||||
"owner_authorization_reference": owner.authorization_reference,
|
||||
"owner_identity": owner.identity,
|
||||
"created_at": iso_utc(now),
|
||||
"expires_at": iso_utc(expires_at),
|
||||
"execute": True,
|
||||
"production_change_authorized": False,
|
||||
}
|
||||
return plan, grant_request
|
||||
|
||||
|
||||
def _expect_text(result: Mapping[str, Any], name: str) -> str:
|
||||
value = result.get(name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ProvisioningError(f"{name.upper()}_MISSING", 503)
|
||||
return value
|
||||
|
||||
|
||||
def _verify_signer_result(
|
||||
result: Mapping[str, Any],
|
||||
*,
|
||||
approval_plan_id: str,
|
||||
) -> dict[str, str]:
|
||||
if result.get("status") not in {"SIGNED", "PASS", "COMPLETED"}:
|
||||
raise ProvisioningError("GRANT_SIGNING_FAILED", 503)
|
||||
if result.get("approval_plan_id") != approval_plan_id:
|
||||
raise ProvisioningError("APPROVAL_PLAN_ID_MISMATCH", 503)
|
||||
digest = _expect_text(result, "grant_digest_sha256")
|
||||
if not SHA256_RE.fullmatch(digest):
|
||||
raise ProvisioningError("GRANT_DIGEST_INVALID", 503)
|
||||
signer = result.get("signing_key_identity") or result.get("signer_identity")
|
||||
if not isinstance(signer, str) or not signer.strip():
|
||||
raise ProvisioningError("SIGNING_KEY_IDENTITY_MISSING", 503)
|
||||
signature_present = bool(result.get("signature_present")) or bool(
|
||||
result.get("owner_signature")
|
||||
)
|
||||
if not signature_present:
|
||||
raise ProvisioningError("OWNER_SIGNATURE_MISSING", 503)
|
||||
return {
|
||||
"grant_id": _expect_text(result, "grant_id"),
|
||||
"grant_digest_sha256": digest,
|
||||
"signing_key_identity": signer,
|
||||
}
|
||||
|
||||
|
||||
def _verify_validator_result(
|
||||
result: Mapping[str, Any],
|
||||
*,
|
||||
approval_plan_id: str,
|
||||
grant_digest: str,
|
||||
) -> None:
|
||||
if result.get("status") not in {"PASS", "VALID", "COMPLETED"}:
|
||||
raise ProvisioningError("GOVERNED_GRANT_VALIDATION_FAILED", 503)
|
||||
if result.get("approval_plan_id") != approval_plan_id:
|
||||
raise ProvisioningError("VALIDATED_PLAN_ID_MISMATCH", 503)
|
||||
if result.get("grant_digest_sha256") != grant_digest:
|
||||
raise ProvisioningError("VALIDATED_GRANT_DIGEST_MISMATCH", 503)
|
||||
checks = result.get("checks")
|
||||
required = {
|
||||
"schema_valid",
|
||||
"signature_valid",
|
||||
"signer_trusted",
|
||||
"approval_plan_active",
|
||||
"repository_match",
|
||||
"base_ref_match",
|
||||
"base_oid_match",
|
||||
"target_branch_match",
|
||||
"one_time_use",
|
||||
"allowed_operations_match",
|
||||
"denied_operations_match",
|
||||
}
|
||||
if not isinstance(checks, dict) or any(checks.get(name) is not True for name in required):
|
||||
raise ProvisioningError("GOVERNED_GRANT_VALIDATION_FAILED", 503)
|
||||
|
||||
|
||||
def _verify_provisioner_result(
|
||||
result: Mapping[str, Any],
|
||||
request: Mapping[str, Any],
|
||||
*,
|
||||
grant_id: str,
|
||||
validator_config_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
if result.get("status") not in {"READY_FOR_CODE_AUTHORING", "COMPLETED"}:
|
||||
raise ProvisioningError("TASK_WRITER_PROVISIONING_FAILED", 503)
|
||||
|
||||
# The legacy bridge created the target ref itself. Keep accepting it for
|
||||
# controlled test doubles, but prefer the deployed direct one-shot writer.
|
||||
if result.get("status") == "COMPLETED" and result.get("writer_mode") == "direct_one_shot_detached":
|
||||
if result.get("target_branch") != request["target_branch"]:
|
||||
raise ProvisioningError("PROVISIONED_BRANCH_MISMATCH", 503)
|
||||
if result.get("base_oid") != request["base_oid"]:
|
||||
raise ProvisioningError("PROVISIONED_BASE_MISMATCH", 503)
|
||||
if result.get("writer_created") is not True:
|
||||
raise ProvisioningError("TASK_WRITER_PROVISIONING_FAILED", 503)
|
||||
writer_config_name = result.get("writer_config")
|
||||
if not isinstance(writer_config_name, str) or not Path(writer_config_name).is_absolute():
|
||||
raise ProvisioningError("WRITER_CONFIG_INVALID", 503)
|
||||
writer_config_path = Path(writer_config_name)
|
||||
writer_config = _read_json(writer_config_path)
|
||||
if writer_config.get("status") != "READY":
|
||||
raise ProvisioningError("WRITER_CONFIG_NOT_READY", 503)
|
||||
if writer_config.get("target_branch") != request["target_branch"]:
|
||||
raise ProvisioningError("WRITER_CONFIG_BRANCH_MISMATCH", 503)
|
||||
if writer_config.get("base_oid") != request["base_oid"]:
|
||||
raise ProvisioningError("WRITER_CONFIG_BASE_MISMATCH", 503)
|
||||
if writer_config.get("worktree_mode") != "detached":
|
||||
raise ProvisioningError("WRITER_MODE_UNSUPPORTED", 503)
|
||||
workspace = writer_config.get("worktree")
|
||||
if not isinstance(workspace, str) or not Path(workspace).is_absolute():
|
||||
raise ProvisioningError("PROVISIONED_WORKSPACE_INVALID", 503)
|
||||
credential_name = writer_config.get("runtime_credential_file")
|
||||
if not isinstance(credential_name, str) or not Path(credential_name).is_absolute():
|
||||
raise ProvisioningError("WRITER_CREDENTIAL_REF_INVALID", 503)
|
||||
credential = Path(credential_name)
|
||||
if not credential.is_file() or credential.is_symlink():
|
||||
raise ProvisioningError("WRITER_CREDENTIAL_MISSING", 503)
|
||||
credential_stat = credential.stat()
|
||||
if credential_stat.st_uid != 0 or stat.S_IMODE(credential_stat.st_mode) != 0o400:
|
||||
raise ProvisioningError("WRITER_CREDENTIAL_PERMISSIONS_INVALID", 503)
|
||||
|
||||
dynamic_config = _read_json(validator_config_path)
|
||||
ledger_name = dynamic_config.get("consumed_grants_ledger")
|
||||
if not isinstance(ledger_name, str) or not Path(ledger_name).is_absolute():
|
||||
raise ProvisioningError("GRANT_LEDGER_REF_INVALID", 503)
|
||||
ledger = Path(ledger_name)
|
||||
consumed = False
|
||||
if ledger.is_file() and not ledger.is_symlink():
|
||||
for line in ledger.read_text(encoding="utf-8").splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if (
|
||||
isinstance(row, dict)
|
||||
and row.get("grant_id") == grant_id
|
||||
and row.get("target_branch") == request["target_branch"]
|
||||
and row.get("base_oid") == request["base_oid"]
|
||||
):
|
||||
consumed = True
|
||||
break
|
||||
if not consumed:
|
||||
raise ProvisioningError("ONE_TIME_GRANT_NOT_CONSUMED", 503)
|
||||
|
||||
scope = {
|
||||
"workspace_role": "task-scoped-isolated-writer",
|
||||
"writes_enabled": True,
|
||||
"allowed_branch": request["target_branch"],
|
||||
"allowed_base_oid": request["base_oid"],
|
||||
"allow_main_write": False,
|
||||
"allow_tag_write": False,
|
||||
"allow_baseline_write": False,
|
||||
"allow_force_push": False,
|
||||
"allowed_routes": ["POST /api/commit-file"],
|
||||
"writer_mode": "direct_one_shot_detached",
|
||||
}
|
||||
negatives = {name: True for name in sorted(REQUIRED_NEGATIVE_TESTS)}
|
||||
return {
|
||||
"branch": request["target_branch"],
|
||||
"branch_oid": request["base_oid"],
|
||||
"base_oid": request["base_oid"],
|
||||
"workspace_path": workspace,
|
||||
"writer_scope": scope,
|
||||
"negative_tests": negatives,
|
||||
"grant_consumed": True,
|
||||
"remote_ref_status": "target_branch_absent_until_writer_push",
|
||||
"remote_oid": None,
|
||||
"detached_writer": True,
|
||||
"writer_config_path": str(writer_config_path),
|
||||
}
|
||||
|
||||
if result.get("branch") != request["target_branch"]:
|
||||
raise ProvisioningError("PROVISIONED_BRANCH_MISMATCH", 503)
|
||||
if result.get("base_oid") != request["base_oid"]:
|
||||
raise ProvisioningError("PROVISIONED_BASE_MISMATCH", 503)
|
||||
if result.get("branch_oid") != request["base_oid"]:
|
||||
raise ProvisioningError("PROVISIONED_BRANCH_OID_MISMATCH", 503)
|
||||
if result.get("grant_consumed") is not True:
|
||||
raise ProvisioningError("ONE_TIME_GRANT_NOT_CONSUMED", 503)
|
||||
workspace = result.get("workspace_path")
|
||||
if not isinstance(workspace, str) or not Path(workspace).is_absolute():
|
||||
raise ProvisioningError("PROVISIONED_WORKSPACE_INVALID", 503)
|
||||
scope = result.get("writer_scope")
|
||||
if not isinstance(scope, dict):
|
||||
raise ProvisioningError("WRITER_SCOPE_ATTESTATION_MISSING", 503)
|
||||
required_scope = {
|
||||
"workspace_role": "task-scoped-isolated-writer",
|
||||
"writes_enabled": True,
|
||||
"allowed_branch": request["target_branch"],
|
||||
"allowed_base_oid": request["base_oid"],
|
||||
"allow_main_write": False,
|
||||
"allow_tag_write": False,
|
||||
"allow_baseline_write": False,
|
||||
"allow_force_push": False,
|
||||
}
|
||||
for key, expected in required_scope.items():
|
||||
if scope.get(key) != expected:
|
||||
raise ProvisioningError("WRITER_SCOPE_ATTESTATION_MISMATCH", 503)
|
||||
routes = scope.get("allowed_routes")
|
||||
if routes != ["POST /api/commit-file"]:
|
||||
raise ProvisioningError("WRITER_ROUTE_ATTESTATION_MISMATCH", 503)
|
||||
negatives = result.get("negative_tests")
|
||||
if not isinstance(negatives, dict) or any(
|
||||
negatives.get(name) is not True for name in REQUIRED_NEGATIVE_TESTS
|
||||
):
|
||||
raise ProvisioningError("NEGATIVE_POLICY_TESTS_FAILED", 503)
|
||||
return {
|
||||
"branch": result["branch"],
|
||||
"branch_oid": result["branch_oid"],
|
||||
"base_oid": result["base_oid"],
|
||||
"workspace_path": workspace,
|
||||
"writer_scope": scope,
|
||||
"negative_tests": {
|
||||
name: True for name in sorted(REQUIRED_NEGATIVE_TESTS)
|
||||
},
|
||||
"grant_consumed": True,
|
||||
"remote_ref_status": result.get("remote_ref_status"),
|
||||
"remote_oid": result.get("remote_oid"),
|
||||
"detached_writer": False,
|
||||
"writer_config_path": None,
|
||||
}
|
||||
|
||||
|
||||
class AuthorizedTaskWriterProvisioner:
|
||||
def __init__(
|
||||
self,
|
||||
config: ProvisioningConfig | None = None,
|
||||
*,
|
||||
clock=utc_now,
|
||||
) -> None:
|
||||
self.config = config or ProvisioningConfig.from_environment()
|
||||
self.clock = clock
|
||||
|
||||
def provision(
|
||||
self,
|
||||
raw_request: Mapping[str, Any],
|
||||
owner: OwnerContext,
|
||||
) -> dict[str, Any]:
|
||||
request = _validate_request(raw_request, owner=owner, config=self.config)
|
||||
_prepare_state_root(self.config.state_root)
|
||||
receipt_id = _safe_receipt_id(
|
||||
request["task_id"], request["repository"], request["target_branch"]
|
||||
)
|
||||
receipt_path = self.config.state_root / "receipts" / f"{receipt_id}.json"
|
||||
lock_path = self.config.state_root / "locks" / f"{receipt_id}.lock"
|
||||
with open(lock_path, "a+b") as lock:
|
||||
os.chmod(lock_path, 0o600)
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
return self._provision_locked(request, owner, receipt_path)
|
||||
|
||||
def _provision_locked(
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
owner: OwnerContext,
|
||||
receipt_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
receipt_exists = receipt_path.exists()
|
||||
prestart = _prestart_git_check(
|
||||
request, self.config, receipt_exists=receipt_exists
|
||||
)
|
||||
if receipt_exists:
|
||||
receipt = _read_json(receipt_path)
|
||||
expected_digest = sha256_json(
|
||||
{
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"base_ref": request["base_ref"],
|
||||
"base_oid": request["base_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"owner_authorization_reference": request[
|
||||
"owner_authorization_reference"
|
||||
],
|
||||
}
|
||||
)
|
||||
if receipt.get("request_identity_sha256") != expected_digest:
|
||||
raise ProvisioningError("IDEMPOTENCY_SCOPE_CONFLICT", 409)
|
||||
return {
|
||||
**receipt["public_result"],
|
||||
"idempotent": True,
|
||||
"prestart": prestart,
|
||||
}
|
||||
|
||||
for path, name in (
|
||||
(self.config.signer, "SIGNER"),
|
||||
(self.config.validator, "VALIDATOR"),
|
||||
(self.config.provisioner, "PROVISIONER"),
|
||||
):
|
||||
_verify_tool_contract(path, name, self.config)
|
||||
|
||||
now = self.clock()
|
||||
plan, grant_request = _build_plan(request, owner, now=now)
|
||||
temporary_root = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix="grant-",
|
||||
dir=self.config.state_root,
|
||||
)
|
||||
)
|
||||
os.chmod(temporary_root, 0o700)
|
||||
try:
|
||||
grant_dir = temporary_root / "grant"
|
||||
grant_dir.mkdir(mode=0o700)
|
||||
_atomic_write_json(grant_dir / "approval-plan.json", plan)
|
||||
_atomic_write_json(grant_dir / "grant-request.json", grant_request)
|
||||
|
||||
signer_result_path = temporary_root / "signer-result.json"
|
||||
validator_result_path = temporary_root / "validator-result.json"
|
||||
provisioner_result_path = temporary_root / "provisioner-result.json"
|
||||
|
||||
try:
|
||||
run_compatible_signer(
|
||||
_tool_argv(self.config, self.config.signer),
|
||||
grant_dir,
|
||||
signer_result_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ProvisioningError(
|
||||
"SIGNER_COMPATIBILITY_FAILED",
|
||||
503,
|
||||
details={"error": type(exc).__name__},
|
||||
) from exc
|
||||
signer_result = _read_json(signer_result_path)
|
||||
signer = _verify_signer_result(
|
||||
signer_result,
|
||||
approval_plan_id=plan["approval_plan_id"],
|
||||
)
|
||||
|
||||
try:
|
||||
validator_config_path = run_compatible_validator(
|
||||
_tool_argv(self.config, self.config.validator),
|
||||
grant_dir,
|
||||
validator_result_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ProvisioningError(
|
||||
"VALIDATOR_COMPATIBILITY_FAILED",
|
||||
503,
|
||||
details={"error": type(exc).__name__},
|
||||
) from exc
|
||||
validator_result = _read_json(validator_result_path)
|
||||
_verify_validator_result(
|
||||
validator_result,
|
||||
approval_plan_id=plan["approval_plan_id"],
|
||||
grant_digest=signer["grant_digest_sha256"],
|
||||
)
|
||||
|
||||
provisioner_env = dict(os.environ)
|
||||
provisioner_env["AIOS_GRANT_ROOT_CONFIG"] = str(
|
||||
validator_config_path
|
||||
)
|
||||
_run(
|
||||
_tool_argv(
|
||||
self.config,
|
||||
self.config.provisioner,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(provisioner_result_path),
|
||||
),
|
||||
env=provisioner_env,
|
||||
timeout=180,
|
||||
)
|
||||
provisioner_result = _read_json(provisioner_result_path)
|
||||
provisioned = _verify_provisioner_result(
|
||||
provisioner_result,
|
||||
request,
|
||||
grant_id=signer["grant_id"],
|
||||
validator_config_path=validator_config_path,
|
||||
)
|
||||
|
||||
post_branch_oid = _git(
|
||||
self.config.authority_git_dir,
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--hash",
|
||||
f"refs/heads/{request['target_branch']}",
|
||||
allowed_returncodes=(0, 1, 128),
|
||||
)
|
||||
if provisioned["detached_writer"]:
|
||||
if post_branch_oid:
|
||||
raise ProvisioningError("POST_PROVISION_TARGET_BRANCH_EXISTS", 503)
|
||||
post_branch_oid = request["base_oid"]
|
||||
elif post_branch_oid != request["base_oid"]:
|
||||
raise ProvisioningError("POST_PROVISION_BRANCH_OID_MISMATCH", 503)
|
||||
post_main_oid = _git(
|
||||
self.config.authority_git_dir,
|
||||
"rev-parse",
|
||||
request["base_ref"],
|
||||
)
|
||||
if post_main_oid != request["base_oid"]:
|
||||
raise ProvisioningError("POST_PROVISION_MAIN_DRIFT", 409)
|
||||
|
||||
public_result = {
|
||||
"status": "READY_FOR_CODE_AUTHORING",
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"approval_plan_id": plan["approval_plan_id"],
|
||||
"grant_id": signer["grant_id"],
|
||||
"grant_digest_sha256": signer["grant_digest_sha256"],
|
||||
"signing_key_identity": signer["signing_key_identity"],
|
||||
"branch": provisioned["branch"],
|
||||
"branch_oid": provisioned["branch_oid"],
|
||||
"base_oid": request["base_oid"],
|
||||
"workspace_path": provisioned["workspace_path"],
|
||||
"writer_scope": provisioned["writer_scope"],
|
||||
"negative_tests": provisioned["negative_tests"],
|
||||
"grant_consumed": True,
|
||||
"main_unchanged": True,
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"idempotent": False,
|
||||
}
|
||||
request_identity = sha256_json(
|
||||
{
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"base_ref": request["base_ref"],
|
||||
"base_oid": request["base_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"owner_authorization_reference": request[
|
||||
"owner_authorization_reference"
|
||||
],
|
||||
}
|
||||
)
|
||||
receipt = {
|
||||
"schema": "aios.task-scoped-writer-provisioning-receipt.v1",
|
||||
"request_identity_sha256": request_identity,
|
||||
"created_at": iso_utc(self.clock()),
|
||||
"public_result": _redacted(public_result),
|
||||
"prestart": prestart,
|
||||
"tool_evidence": {
|
||||
"signer": {
|
||||
"status": signer_result.get("status"),
|
||||
"grant_digest_sha256": signer["grant_digest_sha256"],
|
||||
"signing_key_identity": signer["signing_key_identity"],
|
||||
},
|
||||
"validator": {
|
||||
"status": validator_result.get("status"),
|
||||
"checks": validator_result.get("checks"),
|
||||
},
|
||||
"provisioner": {
|
||||
"status": provisioner_result.get("status"),
|
||||
"grant_consumed": provisioner_result.get("grant_consumed"),
|
||||
},
|
||||
},
|
||||
}
|
||||
_atomic_write_json(receipt_path, receipt)
|
||||
return {**public_result, "prestart": prestart}
|
||||
finally:
|
||||
shutil.rmtree(temporary_root, ignore_errors=True)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Durable Owner/GPT notification outbox with retry and acknowledgement."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboxEvent:
|
||||
event_id: str
|
||||
recipient: str
|
||||
kind: str
|
||||
payload: Mapping[str, Any]
|
||||
attempts: int
|
||||
available_at: float
|
||||
acknowledged: bool
|
||||
|
||||
|
||||
class DurableOutbox:
|
||||
RECIPIENTS = ("owner", "gpt")
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = str(path)
|
||||
self.db = sqlite3.connect(self.path)
|
||||
self.db.execute("PRAGMA journal_mode=WAL")
|
||||
self.db.execute("CREATE TABLE IF NOT EXISTS outbox (event_id TEXT PRIMARY KEY, recipient TEXT NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, available_at REAL NOT NULL, delivered_at REAL, acknowledged INTEGER NOT NULL DEFAULT 0, last_error TEXT)")
|
||||
self.db.commit()
|
||||
|
||||
def enqueue(self, kind: str, payload: Mapping[str, Any], *, event_id: str | None = None, recipients: tuple[str, ...] = RECIPIENTS) -> list[str]:
|
||||
ids: list[str] = []
|
||||
for recipient in recipients:
|
||||
if recipient not in self.RECIPIENTS:
|
||||
raise ValueError("recipient is outside the governed notification plane")
|
||||
eid = event_id or str(uuid.uuid4())
|
||||
if event_id is not None and len(recipients) > 1:
|
||||
eid = f"{event_id}:{recipient}"
|
||||
self.db.execute("INSERT OR IGNORE INTO outbox(event_id,recipient,kind,payload,available_at) VALUES(?,?,?,?,?)", (eid, recipient, kind, json.dumps(dict(payload), sort_keys=True), time.time()))
|
||||
ids.append(eid)
|
||||
self.db.commit()
|
||||
return ids
|
||||
|
||||
def due(self, *, now: float | None = None, limit: int = 20) -> list[OutboxEvent]:
|
||||
now = time.time() if now is None else now
|
||||
rows = self.db.execute("SELECT event_id,recipient,kind,payload,attempts,available_at,acknowledged FROM outbox WHERE delivered_at IS NULL AND available_at<=? ORDER BY available_at,event_id LIMIT ?", (now, limit)).fetchall()
|
||||
return [OutboxEvent(r[0], r[1], r[2], json.loads(r[3]), r[4], r[5], bool(r[6])) for r in rows]
|
||||
|
||||
def delivered(self, event_id: str, *, now: float | None = None) -> None:
|
||||
self.db.execute("UPDATE outbox SET delivered_at=? WHERE event_id=?", (time.time() if now is None else now, event_id))
|
||||
self.db.commit()
|
||||
|
||||
def failed(self, event_id: str, error: str, *, now: float | None = None, max_backoff: float = 300.0) -> None:
|
||||
now = time.time() if now is None else now
|
||||
row = self.db.execute("SELECT attempts FROM outbox WHERE event_id=?", (event_id,)).fetchone()
|
||||
attempts = int(row[0]) + 1 if row else 1
|
||||
delay = min(max_backoff, 2 ** min(attempts, 8))
|
||||
self.db.execute("UPDATE outbox SET attempts=?,available_at=?,last_error=? WHERE event_id=?", (attempts, now + delay, error[:200], event_id))
|
||||
self.db.commit()
|
||||
|
||||
def acknowledge(self, event_id: str) -> None:
|
||||
self.db.execute("UPDATE outbox SET acknowledged=1 WHERE event_id=?", (event_id,))
|
||||
self.db.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
|
||||
class NotificationPlane:
|
||||
def __init__(self, outbox: DurableOutbox) -> None:
|
||||
self.outbox = outbox
|
||||
|
||||
def emit(self, kind: str, payload: Mapping[str, Any], *, event_id: str | None = None) -> list[str]:
|
||||
return self.outbox.enqueue(kind, payload, event_id=event_id)
|
||||
|
||||
def deliver_once(self, send: Callable[[OutboxEvent], None], *, now: float | None = None) -> list[str]:
|
||||
delivered: list[str] = []
|
||||
for event in self.outbox.due(now=now):
|
||||
try:
|
||||
send(event)
|
||||
except Exception as exc:
|
||||
self.outbox.failed(event.event_id, type(exc).__name__, now=now)
|
||||
else:
|
||||
self.outbox.delivered(event.event_id, now=now)
|
||||
delivered.append(event.event_id)
|
||||
return delivered
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Fail-closed, branch-bound repair leases for governed AIOS GitOps work."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
TASK_ID = "V2R1C6_GOVERNED_LOCAL_BOOTSTRAP"
|
||||
MAX_LEASE_SECONDS = 86_400
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
DENIED_BRANCHES = {"main", "master", "HEAD"}
|
||||
PROTECTED_PREFIXES = (
|
||||
".git",
|
||||
"VERSION",
|
||||
"versions/V2R1C5.json",
|
||||
"artifacts/V2R1C5",
|
||||
"baseline",
|
||||
"config/gitops.tokens",
|
||||
"gitops.tokens",
|
||||
"secrets",
|
||||
"credentials",
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
re.compile(r"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*\S+"),
|
||||
re.compile(r"\b(?:sk|HPUA|SDC)-[A-Za-z0-9_-]{16,}\b"),
|
||||
)
|
||||
|
||||
|
||||
class LeaseError(Exception):
|
||||
"""Raised for any invalid, stale, expired, or prohibited lease action."""
|
||||
|
||||
|
||||
class GitProbeProtocol(Protocol):
|
||||
"""Read-only Git seam used by the lease manager."""
|
||||
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
|
||||
def parent(self, commit: str) -> str: ...
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
|
||||
def changed_paths(self, commit: str) -> tuple[str, ...]: ...
|
||||
|
||||
def commits_between(self, older: str, newer: str) -> tuple[str, ...]: ...
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _ts(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_ts(value: str) -> datetime:
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(value)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _oid(value: str, field: str = "oid") -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise LeaseError(f"{field} must be a full commit OID")
|
||||
return value.lower()
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""Normalize a repository-relative path without resolving through the FS."""
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise LeaseError("path must be a non-empty string")
|
||||
value = path.replace("\\", "/").strip()
|
||||
if value.startswith("/") or "\x00" in value:
|
||||
raise LeaseError("path must be relative")
|
||||
parts = value.split("/")
|
||||
if any(part in {"", ".", ".."} for part in parts):
|
||||
raise LeaseError("path contains a prohibited component")
|
||||
normalized = posixpath.normpath(value)
|
||||
if normalized == "." or normalized.startswith("../"):
|
||||
raise LeaseError("path is not normalized")
|
||||
return normalized
|
||||
|
||||
|
||||
def path_is_protected(path: str) -> bool:
|
||||
normalized = normalize_path(path)
|
||||
lower = normalized.lower()
|
||||
return any(
|
||||
lower == prefix.lower() or lower.startswith(prefix.lower().rstrip("/") + "/")
|
||||
for prefix in PROTECTED_PREFIXES
|
||||
) or lower.startswith("refs/")
|
||||
|
||||
|
||||
def contains_secret_like_content(content: str) -> bool:
|
||||
return any(pattern.search(content or "") for pattern in SECRET_PATTERNS)
|
||||
|
||||
|
||||
def validate_paths(paths: Iterable[str], allowed_paths: Sequence[str]) -> tuple[str, ...]:
|
||||
allowed = tuple(normalize_path(path) for path in allowed_paths)
|
||||
if not allowed:
|
||||
raise LeaseError("allowed_paths must not be empty")
|
||||
result: list[str] = []
|
||||
for raw in paths:
|
||||
path = normalize_path(raw)
|
||||
if path_is_protected(path):
|
||||
raise LeaseError("protected path is not lease-writable")
|
||||
if not any(path == prefix or path.startswith(prefix + "/") for prefix in allowed):
|
||||
raise LeaseError("path is outside the lease allowlist")
|
||||
if path not in result:
|
||||
result.append(path)
|
||||
if not result:
|
||||
raise LeaseError("at least one changed path is required")
|
||||
return tuple(result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommitEntry:
|
||||
commit_oid: str
|
||||
parent_oid: str
|
||||
paths: tuple[str, ...]
|
||||
recorded_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairLease:
|
||||
lease_id: str
|
||||
task_id: str
|
||||
owner_identity: str
|
||||
branch: str
|
||||
description: str
|
||||
allowed_paths: tuple[str, ...]
|
||||
initial_head_oid: str
|
||||
current_head_oid: str
|
||||
created_at: str
|
||||
expires_at: str
|
||||
max_expires_at: str
|
||||
state: str = "ACTIVE"
|
||||
revision: int = 0
|
||||
commits: list[CommitEntry] = field(default_factory=list)
|
||||
attached: bool = False
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
payload = asdict(self)
|
||||
payload["allowed_paths"] = list(self.allowed_paths)
|
||||
payload["commits"] = [asdict(entry) for entry in self.commits]
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw: dict[str, Any]) -> "RepairLease":
|
||||
commits = tuple(
|
||||
CommitEntry(
|
||||
commit_oid=_oid(item["commit_oid"], "commit_oid"),
|
||||
parent_oid=_oid(item["parent_oid"], "parent_oid"),
|
||||
paths=tuple(normalize_path(path) for path in item["paths"]),
|
||||
recorded_at=str(item["recorded_at"]),
|
||||
)
|
||||
for item in raw.get("commits", [])
|
||||
)
|
||||
return cls(
|
||||
lease_id=str(raw["lease_id"]),
|
||||
task_id=str(raw["task_id"]),
|
||||
owner_identity=str(raw["owner_identity"]),
|
||||
branch=str(raw["branch"]),
|
||||
description=str(raw["description"]),
|
||||
allowed_paths=tuple(normalize_path(path) for path in raw["allowed_paths"]),
|
||||
initial_head_oid=_oid(raw["initial_head_oid"], "initial_head_oid"),
|
||||
current_head_oid=_oid(raw["current_head_oid"], "current_head_oid"),
|
||||
created_at=str(raw["created_at"]),
|
||||
expires_at=str(raw["expires_at"]),
|
||||
max_expires_at=str(raw["max_expires_at"]),
|
||||
state=str(raw.get("state", "ACTIVE")),
|
||||
revision=int(raw.get("revision", 0)),
|
||||
commits=list(commits),
|
||||
attached=bool(raw.get("attached", False)),
|
||||
)
|
||||
|
||||
|
||||
class LeaseStore:
|
||||
"""Atomic JSON store for restart-recoverable lease state."""
|
||||
|
||||
def __init__(self, directory: str | os.PathLike[str]) -> None:
|
||||
self.directory = Path(directory)
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
if self.directory.is_symlink():
|
||||
raise LeaseError("lease store must not be a symlink")
|
||||
os.chmod(self.directory, 0o700)
|
||||
|
||||
def _path(self, lease_id: str) -> Path:
|
||||
if not re.fullmatch(r"lease-[0-9a-f]{32}", lease_id):
|
||||
raise LeaseError("invalid lease id")
|
||||
return self.directory / f"{lease_id}.json"
|
||||
|
||||
def create(self, lease: RepairLease) -> None:
|
||||
path = self._path(lease.lease_id)
|
||||
if path.exists() or path.is_symlink():
|
||||
raise LeaseError("lease already exists")
|
||||
self._write(path, lease.to_json(), exclusive=True)
|
||||
|
||||
def load(self, lease_id: str) -> RepairLease:
|
||||
path = self._path(lease_id)
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, TypeError) as exc:
|
||||
raise LeaseError("lease state is unavailable or invalid") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise LeaseError("lease state must be an object")
|
||||
return RepairLease.from_json(raw)
|
||||
|
||||
def save(self, lease: RepairLease) -> None:
|
||||
self._write(self._path(lease.lease_id), lease.to_json())
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, payload: dict[str, Any], *, exclusive: bool = False) -> None:
|
||||
flags = os.O_WRONLY | os.O_CREAT | (os.O_EXCL if exclusive else 0)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".lease-", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
if exclusive:
|
||||
os.link(temp_name, path)
|
||||
os.unlink(temp_name)
|
||||
else:
|
||||
os.replace(temp_name, path)
|
||||
os.chmod(path, 0o600)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(temp_name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
class GitProbe:
|
||||
"""Read-only Git implementation; it never changes a ref or working tree."""
|
||||
|
||||
def __init__(self, bare_repo: str | os.PathLike[str]) -> None:
|
||||
self.bare_repo = Path(bare_repo)
|
||||
|
||||
def _run(self, args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise LeaseError("read-only Git probe failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
return _oid(self._run(["rev-parse", f"refs/heads/{branch}"]), "branch_head")
|
||||
|
||||
def parent(self, commit: str) -> str:
|
||||
return _oid(self._run(["rev-parse", f"{_oid(commit)}^1"]), "parent")
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
old, new = _oid(older), _oid(newer)
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo), "merge-base", "--is-ancestor", old, new],
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def changed_paths(self, commit: str) -> tuple[str, ...]:
|
||||
raw = self._run(["diff-tree", "--no-commit-id", "--name-only", "-r", _oid(commit)])
|
||||
return tuple(normalize_path(line) for line in raw.splitlines() if line.strip())
|
||||
|
||||
def commits_between(self, older: str, newer: str) -> tuple[str, ...]:
|
||||
raw = self._run(["rev-list", "--reverse", f"{_oid(older)}..{_oid(newer)}"])
|
||||
return tuple(_oid(line, "commit") for line in raw.splitlines() if line.strip())
|
||||
|
||||
|
||||
class RepairLeaseManager:
|
||||
"""Issue, recover, renew, and close branch-bound repair leases."""
|
||||
|
||||
def __init__(self, store: LeaseStore, git: GitProbeProtocol, *, clock=_now) -> None:
|
||||
self.store = store
|
||||
self.git = git
|
||||
self.clock = clock
|
||||
|
||||
def _require_owner(self, lease: RepairLease, owner_identity: str) -> None:
|
||||
if owner_identity != lease.owner_identity:
|
||||
raise LeaseError("lease identity mismatch")
|
||||
|
||||
def _active(self, lease: RepairLease) -> RepairLease:
|
||||
now = self.clock()
|
||||
if lease.state != "ACTIVE":
|
||||
raise LeaseError(f"lease is {lease.state.lower()}")
|
||||
if now >= _parse_ts(lease.expires_at):
|
||||
lease.state = "EXPIRED"
|
||||
self.store.save(lease)
|
||||
raise LeaseError("lease expired")
|
||||
return lease
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
owner_identity: str,
|
||||
branch: str,
|
||||
description: str,
|
||||
allowed_paths: Sequence[str],
|
||||
ttl_seconds: int = 3600,
|
||||
) -> RepairLease:
|
||||
if task_id != TASK_ID:
|
||||
raise LeaseError("unexpected task id")
|
||||
if not owner_identity or not description or len(description) > 240:
|
||||
raise LeaseError("owner identity and description are required")
|
||||
if "\n" in description or "\r" in description:
|
||||
raise LeaseError("description must be a single line")
|
||||
if branch in DENIED_BRANCHES or branch.startswith(("main/", "refs/", "tags/")):
|
||||
raise LeaseError("protected branch cannot receive a repair lease")
|
||||
if not isinstance(ttl_seconds, int) or not 0 < ttl_seconds <= MAX_LEASE_SECONDS:
|
||||
raise LeaseError("lease TTL exceeds the maximum")
|
||||
allowed = tuple(normalize_path(path) for path in allowed_paths)
|
||||
if any(path_is_protected(path) for path in allowed):
|
||||
raise LeaseError("protected path in lease allowlist")
|
||||
head = self.git.branch_head(branch)
|
||||
created = self.clock()
|
||||
expires = created + timedelta(seconds=ttl_seconds)
|
||||
max_expires = created + timedelta(seconds=MAX_LEASE_SECONDS)
|
||||
lease_id = "lease-" + secrets.token_hex(16)
|
||||
lease = RepairLease(
|
||||
lease_id=lease_id,
|
||||
task_id=task_id,
|
||||
owner_identity=owner_identity,
|
||||
branch=branch,
|
||||
description=description,
|
||||
allowed_paths=allowed,
|
||||
initial_head_oid=head,
|
||||
current_head_oid=head,
|
||||
created_at=_ts(created),
|
||||
expires_at=_ts(expires),
|
||||
max_expires_at=_ts(max_expires),
|
||||
)
|
||||
self.store.create(lease)
|
||||
return lease
|
||||
|
||||
def attach_existing_branch(
|
||||
self, lease_id: str, *, owner_identity: str, expected_head_oid: str
|
||||
) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
current = self.git.branch_head(lease.branch)
|
||||
expected = _oid(expected_head_oid, "expected_head_oid")
|
||||
if current != expected or current != lease.current_head_oid:
|
||||
raise LeaseError("branch head does not match the lease")
|
||||
lease.attached = True
|
||||
lease.revision += 1
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
def authorize_commit(
|
||||
self,
|
||||
lease_id: str,
|
||||
*,
|
||||
owner_identity: str,
|
||||
expected_head_oid: str,
|
||||
paths: Sequence[str],
|
||||
content: Mapping[str, str] | None = None,
|
||||
) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
expected = _oid(expected_head_oid, "expected_head_oid")
|
||||
current = self.git.branch_head(lease.branch)
|
||||
if current != expected or current != lease.current_head_oid:
|
||||
raise LeaseError("stale or externally advanced branch head")
|
||||
if not lease.attached:
|
||||
raise LeaseError("lease is not attached")
|
||||
validate_paths(paths, lease.allowed_paths)
|
||||
if content and any(contains_secret_like_content(value) for value in content.values()):
|
||||
raise LeaseError("secret-like content rejected")
|
||||
return lease
|
||||
|
||||
def record_commit(
|
||||
self,
|
||||
lease_id: str,
|
||||
*,
|
||||
owner_identity: str,
|
||||
expected_head_oid: str,
|
||||
new_head_oid: str,
|
||||
paths: Sequence[str],
|
||||
) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
new_head = _oid(new_head_oid, "new_head_oid")
|
||||
expected = _oid(expected_head_oid, "expected_head_oid")
|
||||
if not lease.attached or lease.current_head_oid != expected:
|
||||
raise LeaseError("stale lease head")
|
||||
if self.git.branch_head(lease.branch) != new_head:
|
||||
raise LeaseError("branch head does not match committed head")
|
||||
validate_paths(paths, lease.allowed_paths)
|
||||
if self.git.parent(new_head) != expected or not self.git.is_ancestor(expected, new_head):
|
||||
raise LeaseError("commit is not a fast-forward child of the expected head")
|
||||
actual_paths = self.git.changed_paths(new_head)
|
||||
allowed = validate_paths(actual_paths, lease.allowed_paths)
|
||||
requested = set(validate_paths(paths, lease.allowed_paths))
|
||||
if not set(allowed).issubset(requested):
|
||||
raise LeaseError("commit changed an unrequested path")
|
||||
lease.current_head_oid = new_head
|
||||
lease.revision += 1
|
||||
lease.commits.append(
|
||||
CommitEntry(
|
||||
commit_oid=new_head,
|
||||
parent_oid=expected,
|
||||
paths=allowed,
|
||||
recorded_at=_ts(self.clock()),
|
||||
)
|
||||
)
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
def recover(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
current = self.git.branch_head(lease.branch)
|
||||
if current == lease.current_head_oid:
|
||||
return lease
|
||||
if not self.git.is_ancestor(lease.current_head_oid, current):
|
||||
raise LeaseError("branch moved non-fast-forward")
|
||||
commits = self.git.commits_between(lease.current_head_oid, current)
|
||||
for commit in commits:
|
||||
actual = self.git.changed_paths(commit)
|
||||
validate_paths(actual, lease.allowed_paths)
|
||||
lease.current_head_oid = current
|
||||
lease.revision += 1
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
def renew(self, lease_id: str, *, owner_identity: str, ttl_seconds: int) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
if not isinstance(ttl_seconds, int) or ttl_seconds <= 0:
|
||||
raise LeaseError("renewal TTL must be positive")
|
||||
candidate = self.clock() + timedelta(seconds=ttl_seconds)
|
||||
maximum = _parse_ts(lease.max_expires_at)
|
||||
if candidate > maximum:
|
||||
raise LeaseError("renewal exceeds maximum lease lifetime")
|
||||
lease.expires_at = _ts(candidate)
|
||||
lease.revision += 1
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
def complete(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
lease.state = "COMPLETED"
|
||||
lease.revision += 1
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
def revoke(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
||||
lease = self._active(self.store.load(lease_id))
|
||||
self._require_owner(lease, owner_identity)
|
||||
lease.state = "REVOKED"
|
||||
lease.revision += 1
|
||||
self.store.save(lease)
|
||||
return lease
|
||||
|
||||
|
||||
def sanitized_lease_view(lease: RepairLease) -> dict[str, Any]:
|
||||
"""Return lease metadata only; no content, token, or credential values."""
|
||||
return {
|
||||
"lease_id": lease.lease_id,
|
||||
"task_id": lease.task_id,
|
||||
"owner_identity": lease.owner_identity,
|
||||
"branch": lease.branch,
|
||||
"description": lease.description,
|
||||
"initial_head_oid": lease.initial_head_oid,
|
||||
"current_head_oid": lease.current_head_oid,
|
||||
"created_at": lease.created_at,
|
||||
"expires_at": lease.expires_at,
|
||||
"state": lease.state,
|
||||
"revision": lease.revision,
|
||||
"commit_count": len(lease.commits),
|
||||
"attached": lease.attached,
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Shared primitives for the V2R1C6 governed local writer contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
TASK_ID = "V2R1C6_GOVERNED_LOCAL_BOOTSTRAP"
|
||||
REPO = "aios/aios"
|
||||
PRINCIPAL = "governed-local-bootstrap"
|
||||
OIDSIZ = 40
|
||||
AUTHORIZATION_TTL = timedelta(hours=2)
|
||||
|
||||
|
||||
class GovernedLocalWriterError(Exception):
|
||||
"""Base error for governed-local-writer validation and runtime failures."""
|
||||
|
||||
|
||||
class AuthorizationError(GovernedLocalWriterError):
|
||||
"""Raised when authorization input is invalid, expired, or tampered."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchCreatePolicy:
|
||||
"""Exact branch creation contract."""
|
||||
|
||||
branch: str
|
||||
base_oid: str
|
||||
head_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchAppendPolicy:
|
||||
"""Exact append-commit contract for candidate branch updates."""
|
||||
|
||||
branch: str
|
||||
expected_head_oid: str | None
|
||||
allowed_paths: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchPolicy:
|
||||
"""Per-branch operation policies loaded from authorization."""
|
||||
|
||||
create_branch: BranchCreatePolicy | None
|
||||
append_commit: BranchAppendPolicy | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedAuthorization:
|
||||
"""Parsed and validated authorization object."""
|
||||
|
||||
task_id: str
|
||||
repo: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
authorization_sha256: str
|
||||
one_time_use: bool
|
||||
branch_operations: dict[str, BranchPolicy]
|
||||
denied_operations: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def create_branches(self) -> set[str]:
|
||||
"""Branches that are allowed for branch-creation operations."""
|
||||
return {
|
||||
branch
|
||||
for branch, policy in self.branch_operations.items()
|
||||
if policy.create_branch is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def append_branches(self) -> set[str]:
|
||||
"""Branches that are allowed for commit-append operations."""
|
||||
return {
|
||||
branch
|
||||
for branch, policy in self.branch_operations.items()
|
||||
if policy.append_commit is not None
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerIdentity:
|
||||
"""Verified peer identity from SO_PEERCRED."""
|
||||
|
||||
pid: int
|
||||
uid: int
|
||||
gid: int
|
||||
executable: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileChange:
|
||||
"""Single file modification payload."""
|
||||
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreateBranchRequest:
|
||||
"""Validated create-branch request payload."""
|
||||
|
||||
branch: str
|
||||
base_oid: str
|
||||
head_oid: str | None
|
||||
authorization_digest: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppendCommitRequest:
|
||||
"""Validated append-commit request payload."""
|
||||
|
||||
branch: str
|
||||
expected_head_oid: str
|
||||
changes: tuple[FileChange, ...]
|
||||
authorization_digest: str
|
||||
commit_message: str = "chore(governance): append governed commit"
|
||||
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
_DENIED_PATH_SEGMENTS = (
|
||||
"main",
|
||||
"tag",
|
||||
"baseline",
|
||||
"credential",
|
||||
"credentials",
|
||||
"secret",
|
||||
"registry",
|
||||
)
|
||||
_SECRET_PATTERNS = (
|
||||
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
re.compile(r"(?i)api[_-]?key\s*[:=]\s*\S+"),
|
||||
re.compile(r"(?i)secret\s*[:=]\s*\S+"),
|
||||
re.compile(r"(?i)password\s*[:=]\s*\S+"),
|
||||
re.compile(r"(?i)token\s*[:=]\s*\S+"),
|
||||
)
|
||||
_SAFE_SECRET_MARKERS = re.compile(
|
||||
r"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*(?:REDACTED|TEST_FIXTURE|NOT_A_SECRET)"
|
||||
)
|
||||
|
||||
|
||||
def canonicalize_authorization_payload(payload: Mapping[str, Any]) -> str:
|
||||
"""Serialize authorization payload without the signature hash, in canonical form."""
|
||||
canonical = dict(payload)
|
||||
canonical.pop("authorization_sha256", None)
|
||||
return json.dumps(
|
||||
canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
)
|
||||
|
||||
|
||||
def compute_authorization_sha256(payload: Mapping[str, Any]) -> str:
|
||||
"""Compute canonical sha256 over authorization payload."""
|
||||
digest = hashlib.sha256()
|
||||
digest.update(canonicalize_authorization_payload(payload).encode("utf-8"))
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _assert_authorization_file_permissions(path: Path) -> None:
|
||||
"""Fail-closed checks for root-only secret-like authorization files."""
|
||||
if path.is_symlink():
|
||||
raise AuthorizationError("authorization path must not be a symlink")
|
||||
|
||||
st = path.stat()
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise AuthorizationError("authorization path must be a regular file")
|
||||
|
||||
if (st.st_mode & 0o777) != 0o600:
|
||||
raise AuthorizationError("authorization file mode must be exactly 0600")
|
||||
|
||||
if hasattr(st, "st_uid") and os.name != "nt" and st.st_uid != 0:
|
||||
raise AuthorizationError("authorization file owner must be uid 0")
|
||||
|
||||
|
||||
def _parse_iso8601(value: str) -> datetime:
|
||||
"""Parse ISO 8601 timestamps used by authorization payloads."""
|
||||
try:
|
||||
if value.endswith("Z"):
|
||||
value = value[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AuthorizationError("created_at must be an ISO 8601 timestamp") from exc
|
||||
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _require_type(value: Any, expected: type, *, key: str) -> None:
|
||||
if not isinstance(value, expected):
|
||||
raise AuthorizationError(f"{key} must be {expected.__name__}")
|
||||
|
||||
|
||||
def _parse_oid(value: Any, *, field: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise AuthorizationError(f"{field} must be a string")
|
||||
if not _OID_RE.fullmatch(value):
|
||||
raise AuthorizationError(f"{field} must be a 40-char hex object id")
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _parse_allowed_paths(paths: Any) -> tuple[str, ...]:
|
||||
if not isinstance(paths, list) or not paths:
|
||||
raise AuthorizationError("allowed_paths must be a non-empty list")
|
||||
normalized: list[str] = []
|
||||
for path in paths:
|
||||
if not isinstance(path, str) or not path.strip():
|
||||
raise AuthorizationError("allowed_paths entries must be non-empty strings")
|
||||
normalized.append(path.strip())
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def parse_authorization(
|
||||
raw: Mapping[str, Any], *, now: datetime | None = None
|
||||
) -> GovernedAuthorization:
|
||||
"""Validate an authorization payload and return a typed object."""
|
||||
_require_type(raw.get("task_id"), str, key="task_id")
|
||||
if raw["task_id"] != TASK_ID:
|
||||
raise AuthorizationError("unexpected task_id")
|
||||
|
||||
_require_type(raw.get("repo"), str, key="repo")
|
||||
if raw["repo"] != REPO:
|
||||
raise AuthorizationError("unexpected repo value")
|
||||
|
||||
created_at = _parse_iso8601(raw.get("created_at"))
|
||||
check_time = now or datetime.now(tz=timezone.utc)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
if created_at - check_time > timedelta(seconds=30):
|
||||
raise AuthorizationError("authorization created_at is in the future")
|
||||
if check_time - created_at > AUTHORIZATION_TTL:
|
||||
raise AuthorizationError("authorization expired")
|
||||
raw_expires_at = raw.get("expires_at")
|
||||
expires_at = (
|
||||
_parse_iso8601(raw_expires_at)
|
||||
if raw_expires_at is not None
|
||||
else created_at + AUTHORIZATION_TTL
|
||||
)
|
||||
if expires_at <= check_time:
|
||||
raise AuthorizationError("authorization expiry is in the past")
|
||||
if expires_at > created_at + AUTHORIZATION_TTL:
|
||||
raise AuthorizationError("authorization expiry exceeds two-hour maximum")
|
||||
|
||||
_require_type(raw.get("one_time_use"), bool, key="one_time_use")
|
||||
if raw["one_time_use"] is not True:
|
||||
raise AuthorizationError("one_time_use must be true")
|
||||
|
||||
_require_type(raw.get("authorization_sha256"), str, key="authorization_sha256")
|
||||
expected_hash = compute_authorization_sha256(raw)
|
||||
provided_hash = str(raw["authorization_sha256"]).lower()
|
||||
if provided_hash != expected_hash:
|
||||
raise AuthorizationError("authorization_sha256 mismatch")
|
||||
|
||||
op_map = raw.get("allowed_branch_operations")
|
||||
if not isinstance(op_map, dict) or not op_map:
|
||||
raise AuthorizationError("allowed_branch_operations must be a non-empty object")
|
||||
|
||||
branch_operations: dict[str, BranchPolicy] = {}
|
||||
for branch, operation in op_map.items():
|
||||
if not isinstance(branch, str) or not branch.strip():
|
||||
raise AuthorizationError("branch names must be non-empty strings")
|
||||
|
||||
if not isinstance(operation, dict):
|
||||
raise AuthorizationError("operation policy must be an object")
|
||||
|
||||
create_block = operation.get("create_branch")
|
||||
append_block = operation.get("append_commit")
|
||||
|
||||
create_policy: BranchCreatePolicy | None = None
|
||||
append_policy: BranchAppendPolicy | None = None
|
||||
|
||||
if create_block is not None:
|
||||
if not isinstance(create_block, dict):
|
||||
raise AuthorizationError("create_branch block must be an object")
|
||||
raw_head_oid = create_block.get("head_oid")
|
||||
create_policy = BranchCreatePolicy(
|
||||
branch=branch,
|
||||
base_oid=_parse_oid(create_block.get("base_oid"), field="create_branch.base_oid"),
|
||||
head_oid=(
|
||||
_parse_oid(raw_head_oid, field="create_branch.head_oid")
|
||||
if raw_head_oid is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
if append_block is not None:
|
||||
if not isinstance(append_block, dict):
|
||||
raise AuthorizationError("append_commit block must be an object")
|
||||
raw_expected_head_oid = append_block.get("expected_head_oid")
|
||||
append_policy = BranchAppendPolicy(
|
||||
branch=branch,
|
||||
expected_head_oid=(
|
||||
_parse_oid(
|
||||
raw_expected_head_oid,
|
||||
field="append_commit.expected_head_oid",
|
||||
)
|
||||
if raw_expected_head_oid is not None
|
||||
else None
|
||||
),
|
||||
allowed_paths=_parse_allowed_paths(append_block.get("allowed_paths")),
|
||||
)
|
||||
|
||||
if create_policy is None and append_policy is None:
|
||||
raise AuthorizationError("branch policy must define at least one operation")
|
||||
|
||||
branch_operations[branch] = BranchPolicy(
|
||||
create_branch=create_policy, append_commit=append_policy
|
||||
)
|
||||
|
||||
denied_raw = raw.get("denied_operations", ())
|
||||
if not isinstance(denied_raw, list):
|
||||
raise AuthorizationError("denied_operations must be a list")
|
||||
denied_operations: list[str] = []
|
||||
for value in denied_raw:
|
||||
if not isinstance(value, str):
|
||||
raise AuthorizationError("denied operations must be strings")
|
||||
denied_operations.append(value.lower())
|
||||
|
||||
return GovernedAuthorization(
|
||||
task_id=raw["task_id"],
|
||||
repo=raw["repo"],
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
authorization_sha256=provided_hash,
|
||||
one_time_use=True,
|
||||
branch_operations=branch_operations,
|
||||
denied_operations=tuple(denied_operations),
|
||||
)
|
||||
|
||||
|
||||
def load_authorization_file(
|
||||
path: str | os.PathLike[str], *, now: datetime | None = None
|
||||
) -> GovernedAuthorization:
|
||||
"""Load and validate the root-only authorization file."""
|
||||
auth_path = Path(path)
|
||||
_assert_authorization_file_permissions(auth_path)
|
||||
try:
|
||||
raw_text = auth_path.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError) as exc:
|
||||
raise AuthorizationError("unable to read authorization file") from exc
|
||||
|
||||
try:
|
||||
raw_obj = json.loads(raw_text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise AuthorizationError("authorization must be JSON") from exc
|
||||
|
||||
if not isinstance(raw_obj, dict):
|
||||
raise AuthorizationError("authorization top-level must be an object")
|
||||
|
||||
return parse_authorization(raw_obj, now=now)
|
||||
|
||||
|
||||
def _resolve_peer_executable(pid: int) -> str | None:
|
||||
"""Resolve peer executable from /proc/<pid>/exe when available."""
|
||||
if pid <= 0:
|
||||
return None
|
||||
exe_path = f"/proc/{pid}/exe"
|
||||
try:
|
||||
return os.readlink(exe_path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def extract_peer_identity(
|
||||
connection: socket.socket, *, require_root_uid: bool = True
|
||||
) -> PeerIdentity:
|
||||
"""Validate SO_PEERCRED and return peer identity."""
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise AuthorizationError("SO_PEERCRED not available on this platform")
|
||||
|
||||
data = connection.getsockopt(
|
||||
socket.SOL_SOCKET,
|
||||
socket.SO_PEERCRED,
|
||||
struct.calcsize("3i"),
|
||||
)
|
||||
if len(data) != struct.calcsize("3i"):
|
||||
raise AuthorizationError("SO_PEERCRED payload size mismatch")
|
||||
|
||||
pid, uid, gid = struct.unpack("3i", data)
|
||||
if require_root_uid and uid != 0:
|
||||
raise PermissionError("peer uid must be 0")
|
||||
|
||||
return PeerIdentity(
|
||||
pid=pid,
|
||||
uid=uid,
|
||||
gid=gid,
|
||||
executable=_resolve_peer_executable(pid),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_file_path(path: str) -> str:
|
||||
normalized = posixpath.normpath(path.replace("\\", "/").strip())
|
||||
return normalized.lstrip("/")
|
||||
|
||||
|
||||
def _path_has_disallowed_segment(path: str) -> bool:
|
||||
lowered = path.lower()
|
||||
if ".." in path.split("/"):
|
||||
return True
|
||||
parts = [p for p in lowered.split("/") if p]
|
||||
for part in parts:
|
||||
for segment in _DENIED_PATH_SEGMENTS:
|
||||
if part == segment or part.startswith(f"{segment}.") or segment in part:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_allowed_path(path: str, allowed_paths: Sequence[str]) -> bool:
|
||||
normalized = _normalize_file_path(path)
|
||||
for prefix in allowed_paths:
|
||||
normalized_prefix = _normalize_file_path(prefix)
|
||||
if normalized == normalized_prefix:
|
||||
return True
|
||||
if normalized.startswith(normalized_prefix + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def contains_secret_like_content(content: str) -> bool:
|
||||
"""Heuristic detector for obvious secret-like strings."""
|
||||
content = _SAFE_SECRET_MARKERS.sub("", content or "")
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
if pattern.search(content):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_file_changes(
|
||||
changes: Any, allowed_paths: Sequence[str]
|
||||
) -> tuple[FileChange, ...]:
|
||||
"""Validate append-commit file changes against path and content policy."""
|
||||
if not isinstance(changes, list):
|
||||
raise AuthorizationError("changes must be a list")
|
||||
result: list[FileChange] = []
|
||||
|
||||
for change in changes:
|
||||
if not isinstance(change, Mapping):
|
||||
raise AuthorizationError("each file change must be an object")
|
||||
raw_path = change.get("path")
|
||||
raw_content = change.get("content")
|
||||
if not isinstance(raw_path, str) or not raw_path.strip():
|
||||
raise AuthorizationError("file change path must be a non-empty string")
|
||||
if raw_path.startswith(("/", "\\")):
|
||||
raise AuthorizationError("file change path must be relative")
|
||||
if not isinstance(raw_content, str):
|
||||
raise AuthorizationError("file change content must be a string")
|
||||
|
||||
normalized = _normalize_file_path(raw_path)
|
||||
if not normalized or normalized.startswith("../") or normalized == "..":
|
||||
raise AuthorizationError("file paths must be normalized relative paths")
|
||||
|
||||
if not _is_allowed_path(normalized, allowed_paths):
|
||||
raise AuthorizationError("file path outside allowed_paths")
|
||||
|
||||
if _path_has_disallowed_segment(normalized):
|
||||
raise AuthorizationError("file path uses denied segment")
|
||||
|
||||
if contains_secret_like_content(raw_content):
|
||||
raise AuthorizationError("file content failed secret-like scan")
|
||||
|
||||
result.append(FileChange(path=normalized, content=raw_content))
|
||||
|
||||
return tuple(result)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Supplement Gitea native protection when review identity is not expressible."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
class MergeGateDenied(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergeGateDecision:
|
||||
allowed: bool
|
||||
owner_review_id: int
|
||||
required_contexts: tuple[str, ...]
|
||||
|
||||
|
||||
def validate_protected_merge(
|
||||
pull_request: Mapping[str, Any],
|
||||
reviews: Sequence[Mapping[str, Any]],
|
||||
statuses: Mapping[str, str],
|
||||
*,
|
||||
required_contexts: Sequence[str],
|
||||
unresolved_conversations: int,
|
||||
owner_login: str = "owner",
|
||||
owner_id: int = 2,
|
||||
) -> MergeGateDecision:
|
||||
if pull_request.get("state") != "open" or pull_request.get("mergeable") is not True:
|
||||
raise MergeGateDenied("pull_request_not_mergeable")
|
||||
author = (pull_request.get("user") or {}).get("login")
|
||||
owner_review = None
|
||||
for review in reviews:
|
||||
user = review.get("user") or {}
|
||||
if review.get("state") == "APPROVED" and user.get("login") == owner_login and user.get("id") == owner_id:
|
||||
if author == owner_login or (pull_request.get("author_id") == owner_id):
|
||||
raise MergeGateDenied("pr_author_self_approval_denied")
|
||||
owner_review = review
|
||||
if owner_review is None:
|
||||
raise MergeGateDenied("owner_review_required")
|
||||
if unresolved_conversations != 0:
|
||||
raise MergeGateDenied("unresolved_conversations_block_merge")
|
||||
missing = [context for context in required_contexts if statuses.get(context) != "success"]
|
||||
if missing:
|
||||
raise MergeGateDenied("required_checks_missing:" + ",".join(missing))
|
||||
return MergeGateDecision(True, int(owner_review.get("id", 0)), tuple(required_contexts))
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
"""Audited Owner authorization broker for task-scoped writer provisioning.
|
||||
|
||||
The broker closes the bootstrap gap without minting a new general-purpose
|
||||
Owner token and without modifying the bearer-token registry. It accepts an
|
||||
already-authenticated, explicitly allow-listed Owner principal, records a
|
||||
tamper-evident authorization event, and invokes the existing
|
||||
AuthorizedTaskWriterProvisioner with a generated authorization reference.
|
||||
|
||||
The broker never writes Git refs itself. The governed signer, validator and
|
||||
one-shot provisioner remain the only components allowed to create the task
|
||||
branch/worktree/writer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from authorized_task_writer_provisioning import (
|
||||
AuthorizedTaskWriterProvisioner,
|
||||
BRANCH_RE,
|
||||
OID_RE,
|
||||
OwnerContext,
|
||||
ProvisioningError,
|
||||
REQUIRED_ALLOWED_OPERATIONS,
|
||||
REQUIRED_DENIED_OPERATIONS,
|
||||
TASK_RE,
|
||||
)
|
||||
|
||||
|
||||
ACTION = "AUTHORIZE_TASK_WRITER_PROVISIONING"
|
||||
SCHEMA = "aios.owner-authorization-event.v1"
|
||||
BROKER_REQUEST_FIELDS = frozenset(
|
||||
{
|
||||
"action",
|
||||
"task_id",
|
||||
"repository",
|
||||
"base_ref",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"one_time_use",
|
||||
"expires_in_seconds",
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
"allowed_operations",
|
||||
"denied_operations",
|
||||
}
|
||||
)
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@:+/-]{1,254}$")
|
||||
SCOPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
CREDENTIAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@+-]{1,255}$")
|
||||
WILDCARD_SCOPE_RE = re.compile(r"(^\*$)|(^.+:\*$)")
|
||||
|
||||
|
||||
class OwnerAuthorizationError(RuntimeError):
|
||||
"""Stable fail-closed broker error."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
status: int = 400,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerPrincipal:
|
||||
"""Non-secret authentication evidence returned by the existing API."""
|
||||
|
||||
identity: str
|
||||
role: str
|
||||
authenticated_scope: str
|
||||
token_scopes: frozenset[str]
|
||||
credential_id: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerAuthorizationBrokerConfig:
|
||||
repository_identity: str
|
||||
state_root: Path
|
||||
allowed_owner_identities: frozenset[str]
|
||||
allowed_intake_scopes: frozenset[str]
|
||||
allowed_credential_ids: frozenset[str] = frozenset()
|
||||
max_expiry_seconds: int = 900
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "OwnerAuthorizationBrokerConfig":
|
||||
identities = _csv_set(
|
||||
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES", "")
|
||||
)
|
||||
scopes = _csv_set(
|
||||
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES", "")
|
||||
)
|
||||
credential_ids = _csv_set(
|
||||
os.getenv("AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS", "")
|
||||
)
|
||||
if not identities:
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_IDENTITY_ALLOWLIST_REQUIRED", 503
|
||||
)
|
||||
if not scopes:
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_INTAKE_SCOPE_ALLOWLIST_REQUIRED", 503
|
||||
)
|
||||
if any(_is_wildcard_scope(item) for item in scopes):
|
||||
raise OwnerAuthorizationError(
|
||||
"WILDCARD_INTAKE_SCOPE_REJECTED", 503
|
||||
)
|
||||
if "writer:provision" in scopes:
|
||||
raise OwnerAuthorizationError(
|
||||
"WRITER_PROVISION_SCOPE_BOOTSTRAP_REJECTED", 503
|
||||
)
|
||||
try:
|
||||
maximum = int(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS", "900"
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
raise OwnerAuthorizationError(
|
||||
"BROKER_MAX_EXPIRY_INVALID", 503
|
||||
) from None
|
||||
if maximum < 60 or maximum > 1800:
|
||||
raise OwnerAuthorizationError("BROKER_MAX_EXPIRY_INVALID", 503)
|
||||
return cls(
|
||||
repository_identity=os.getenv(
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY", "aios/aios"
|
||||
).strip(),
|
||||
state_root=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT",
|
||||
"/var/lib/aios-gitops/owner-authorization-broker",
|
||||
)
|
||||
),
|
||||
allowed_owner_identities=identities,
|
||||
allowed_intake_scopes=scopes,
|
||||
allowed_credential_ids=credential_ids,
|
||||
max_expiry_seconds=maximum,
|
||||
)
|
||||
|
||||
|
||||
class TaskWriterProvisioner(Protocol):
|
||||
def provision(
|
||||
self, raw: Mapping[str, Any], owner: OwnerContext
|
||||
) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso_utc(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _csv_set(value: str) -> frozenset[str]:
|
||||
return frozenset(
|
||||
item.strip() for item in value.split(",") if item.strip()
|
||||
)
|
||||
|
||||
|
||||
def _is_wildcard_scope(scope: str) -> bool:
|
||||
return bool(WILDCARD_SCOPE_RE.fullmatch(scope.strip()))
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, value: Any, mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise OwnerAuthorizationError("BROKER_STATE_SYMLINK_REJECTED", 500)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(canonical_json_bytes(value))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
parent_fd = os.open(
|
||||
path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
)
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
||||
if path.stat().st_size <= 0 or path.stat().st_size > 1_048_576:
|
||||
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise OwnerAuthorizationError(
|
||||
"BROKER_RECEIPT_JSON_INVALID", 500
|
||||
) from None
|
||||
if not isinstance(value, dict):
|
||||
raise OwnerAuthorizationError("BROKER_RECEIPT_INVALID", 500)
|
||||
return value
|
||||
|
||||
|
||||
def _prepare_state_root(root: Path) -> None:
|
||||
if root.is_symlink():
|
||||
raise OwnerAuthorizationError("BROKER_STATE_SYMLINK_REJECTED", 500)
|
||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(root, 0o700)
|
||||
if root.stat().st_mode & 0o077:
|
||||
raise OwnerAuthorizationError(
|
||||
"BROKER_STATE_PERMISSIONS_REJECTED", 500
|
||||
)
|
||||
|
||||
|
||||
def _event_id(
|
||||
principal: OwnerPrincipal, request_digest: str
|
||||
) -> str:
|
||||
material = (
|
||||
f"{principal.identity}\0{principal.authenticated_scope}\0"
|
||||
f"{request_digest}"
|
||||
).encode("utf-8")
|
||||
return "owner-auth-" + hashlib.sha256(material).hexdigest()[:32]
|
||||
|
||||
|
||||
def _authorization_reference(event_id: str, request_digest: str) -> str:
|
||||
return f"owner-auth:{event_id}:{request_digest[:16]}"
|
||||
|
||||
|
||||
def _validate_principal(
|
||||
principal: OwnerPrincipal,
|
||||
config: OwnerAuthorizationBrokerConfig,
|
||||
) -> None:
|
||||
if principal.role != "owner":
|
||||
raise OwnerAuthorizationError("OWNER_ROLE_REQUIRED", 403)
|
||||
if (
|
||||
not principal.identity
|
||||
or not IDENTITY_RE.fullmatch(principal.identity)
|
||||
or principal.identity not in config.allowed_owner_identities
|
||||
):
|
||||
raise OwnerAuthorizationError("OWNER_IDENTITY_NOT_ALLOWED", 403)
|
||||
if (
|
||||
not principal.authenticated_scope
|
||||
or not SCOPE_RE.fullmatch(principal.authenticated_scope)
|
||||
or principal.authenticated_scope
|
||||
not in config.allowed_intake_scopes
|
||||
):
|
||||
raise OwnerAuthorizationError("OWNER_INTAKE_SCOPE_REJECTED", 403)
|
||||
if principal.authenticated_scope == "writer:provision":
|
||||
raise OwnerAuthorizationError(
|
||||
"WRITER_PROVISION_SCOPE_BOOTSTRAP_REJECTED", 403
|
||||
)
|
||||
|
||||
scopes = principal.token_scopes
|
||||
if any(_is_wildcard_scope(scope) for scope in scopes):
|
||||
raise OwnerAuthorizationError(
|
||||
"WILDCARD_OWNER_CREDENTIAL_REJECTED", 403
|
||||
)
|
||||
if scopes:
|
||||
if principal.authenticated_scope not in scopes:
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_SCOPE_EVIDENCE_MISMATCH", 403
|
||||
)
|
||||
elif (
|
||||
not principal.credential_id
|
||||
or principal.credential_id not in config.allowed_credential_ids
|
||||
):
|
||||
# handler.need(scope) proves authorization but may hide whether the
|
||||
# credential was wildcard. When scope metadata is absent, require an
|
||||
# exact non-secret credential ID allowlist as the alternative proof.
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE", 403
|
||||
)
|
||||
if principal.credential_id:
|
||||
if not CREDENTIAL_ID_RE.fullmatch(principal.credential_id):
|
||||
raise OwnerAuthorizationError("OWNER_CREDENTIAL_ID_INVALID", 403)
|
||||
if (
|
||||
config.allowed_credential_ids
|
||||
and principal.credential_id not in config.allowed_credential_ids
|
||||
):
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_CREDENTIAL_ID_NOT_ALLOWED", 403
|
||||
)
|
||||
|
||||
|
||||
def _validate_request(
|
||||
raw: Mapping[str, Any],
|
||||
config: OwnerAuthorizationBrokerConfig,
|
||||
) -> dict[str, Any]:
|
||||
if set(raw) != BROKER_REQUEST_FIELDS:
|
||||
raise OwnerAuthorizationError("BROKER_REQUEST_FIELDS_REJECTED")
|
||||
if raw["action"] != ACTION:
|
||||
raise OwnerAuthorizationError("BROKER_ACTION_REJECTED")
|
||||
if raw["repository"] != config.repository_identity:
|
||||
raise OwnerAuthorizationError(
|
||||
"REPOSITORY_IDENTITY_MISMATCH", 409
|
||||
)
|
||||
if raw["base_ref"] != "refs/heads/main":
|
||||
raise OwnerAuthorizationError("BASE_REF_REJECTED", 409)
|
||||
if (
|
||||
not isinstance(raw["base_oid"], str)
|
||||
or not OID_RE.fullmatch(raw["base_oid"])
|
||||
):
|
||||
raise OwnerAuthorizationError("BASE_OID_REJECTED")
|
||||
if (
|
||||
not isinstance(raw["target_branch"], str)
|
||||
or not BRANCH_RE.fullmatch(raw["target_branch"])
|
||||
or raw["target_branch"] in {"main", "master"}
|
||||
):
|
||||
raise OwnerAuthorizationError("TARGET_BRANCH_REJECTED")
|
||||
if (
|
||||
not isinstance(raw["task_id"], str)
|
||||
or not TASK_RE.fullmatch(raw["task_id"])
|
||||
):
|
||||
raise OwnerAuthorizationError("TASK_ID_REJECTED")
|
||||
if raw["one_time_use"] is not True:
|
||||
raise OwnerAuthorizationError("ONE_TIME_USE_REQUIRED")
|
||||
expiry = raw["expires_in_seconds"]
|
||||
if (
|
||||
not isinstance(expiry, int)
|
||||
or isinstance(expiry, bool)
|
||||
or not 60 <= expiry <= config.max_expiry_seconds
|
||||
):
|
||||
raise OwnerAuthorizationError("GRANT_LIFETIME_REJECTED")
|
||||
for field in (
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
):
|
||||
if raw[field] is not False:
|
||||
raise OwnerAuthorizationError(
|
||||
"PRODUCTION_CAPABILITY_REJECTED", 403
|
||||
)
|
||||
allowed = raw["allowed_operations"]
|
||||
denied = raw["denied_operations"]
|
||||
if (
|
||||
not isinstance(allowed, list)
|
||||
or frozenset(allowed) != REQUIRED_ALLOWED_OPERATIONS
|
||||
):
|
||||
raise OwnerAuthorizationError("ALLOWED_OPERATIONS_MISMATCH", 403)
|
||||
if (
|
||||
not isinstance(denied, list)
|
||||
or frozenset(denied) != REQUIRED_DENIED_OPERATIONS
|
||||
):
|
||||
raise OwnerAuthorizationError("DENIED_OPERATIONS_MISMATCH", 403)
|
||||
return dict(raw)
|
||||
|
||||
|
||||
def _response_is_safe(value: Any) -> bool:
|
||||
"""Reject accidental credential material before writing a receipt."""
|
||||
|
||||
sensitive_markers = (
|
||||
"access_token",
|
||||
"bearer",
|
||||
"client_secret",
|
||||
"private_key",
|
||||
"raw_token",
|
||||
"secret_value",
|
||||
)
|
||||
if isinstance(value, Mapping):
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in sensitive_markers):
|
||||
return False
|
||||
if not _response_is_safe(inner):
|
||||
return False
|
||||
elif isinstance(value, list):
|
||||
return all(_response_is_safe(item) for item in value)
|
||||
return True
|
||||
|
||||
|
||||
class OwnerAuthorizationBroker:
|
||||
"""Create an audited Owner event and invoke the governed bridge."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OwnerAuthorizationBrokerConfig | None = None,
|
||||
provisioner: TaskWriterProvisioner | None = None,
|
||||
) -> None:
|
||||
self.config = (
|
||||
config or OwnerAuthorizationBrokerConfig.from_environment()
|
||||
)
|
||||
self.provisioner = provisioner or AuthorizedTaskWriterProvisioner()
|
||||
|
||||
def authorize_and_provision(
|
||||
self,
|
||||
raw: Mapping[str, Any],
|
||||
principal: OwnerPrincipal,
|
||||
) -> dict[str, Any]:
|
||||
config = self.config
|
||||
_validate_principal(principal, config)
|
||||
request = _validate_request(raw, config)
|
||||
_prepare_state_root(config.state_root)
|
||||
|
||||
request_digest = sha256_json(request)
|
||||
event_id = _event_id(principal, request_digest)
|
||||
reference = _authorization_reference(event_id, request_digest)
|
||||
event_dir = config.state_root / "events" / event_id
|
||||
event_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(event_dir, 0o700)
|
||||
|
||||
lock_path = event_dir / "event.lock"
|
||||
lock_path.touch(mode=0o600, exist_ok=True)
|
||||
os.chmod(lock_path, 0o600)
|
||||
with lock_path.open("r+b") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
response_path = event_dir / "response.json"
|
||||
event_path = event_dir / "event.json"
|
||||
|
||||
if response_path.exists():
|
||||
response = _read_json(response_path)
|
||||
response["owner_authorization_idempotent"] = True
|
||||
return response
|
||||
|
||||
issued_at = utc_now()
|
||||
expires_at = issued_at + timedelta(
|
||||
seconds=int(request["expires_in_seconds"])
|
||||
)
|
||||
event: dict[str, Any] = {
|
||||
"schema": SCHEMA,
|
||||
"event_id": event_id,
|
||||
"authorization_reference": reference,
|
||||
"status": "AUTHORIZED",
|
||||
"owner": {
|
||||
"identity": principal.identity,
|
||||
"role": principal.role,
|
||||
"authenticated_scope": principal.authenticated_scope,
|
||||
"credential_id": principal.credential_id or None,
|
||||
"token_scope_digest_sha256": sha256_json(
|
||||
sorted(principal.token_scopes)
|
||||
),
|
||||
"wildcard_scope_used": False,
|
||||
},
|
||||
"request": {
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"base_ref": request["base_ref"],
|
||||
"base_oid": request["base_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"one_time_use": True,
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
"allowed_operations": sorted(
|
||||
REQUIRED_ALLOWED_OPERATIONS
|
||||
),
|
||||
"denied_operations": sorted(
|
||||
REQUIRED_DENIED_OPERATIONS
|
||||
),
|
||||
},
|
||||
"request_digest_sha256": request_digest,
|
||||
"issued_at": iso_utc(issued_at),
|
||||
"expires_at": iso_utc(expires_at),
|
||||
}
|
||||
event["event_digest_sha256"] = sha256_json(event)
|
||||
_atomic_write_json(event_path, event)
|
||||
|
||||
bridge_request = dict(request)
|
||||
bridge_request.pop("action")
|
||||
bridge_request["owner_authorization_reference"] = reference
|
||||
try:
|
||||
result = self.provisioner.provision(
|
||||
bridge_request,
|
||||
OwnerContext(
|
||||
identity=principal.identity,
|
||||
role=principal.role,
|
||||
authorization_reference=reference,
|
||||
),
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise OwnerAuthorizationError(
|
||||
"PROVISIONER_RESPONSE_INVALID", 502
|
||||
)
|
||||
if result.get("status") != "READY_FOR_CODE_AUTHORING":
|
||||
raise OwnerAuthorizationError(
|
||||
"PROVISIONER_NOT_READY",
|
||||
409,
|
||||
details={"status": result.get("status")},
|
||||
)
|
||||
if not _response_is_safe(result):
|
||||
raise OwnerAuthorizationError(
|
||||
"PROVISIONER_RESPONSE_CONTAINS_SENSITIVE_DATA", 500
|
||||
)
|
||||
except ProvisioningError as exc:
|
||||
event.update(
|
||||
{
|
||||
"status": "PROVISIONING_BLOCKED",
|
||||
"failed_at": iso_utc(utc_now()),
|
||||
"error": exc.code,
|
||||
"error_status": exc.status,
|
||||
}
|
||||
)
|
||||
event["event_digest_sha256"] = sha256_json(
|
||||
{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key != "event_digest_sha256"
|
||||
}
|
||||
)
|
||||
_atomic_write_json(event_path, event)
|
||||
raise
|
||||
except OwnerAuthorizationError as exc:
|
||||
event.update(
|
||||
{
|
||||
"status": "PROVISIONING_BLOCKED",
|
||||
"failed_at": iso_utc(utc_now()),
|
||||
"error": exc.code,
|
||||
"error_status": exc.status,
|
||||
}
|
||||
)
|
||||
event["event_digest_sha256"] = sha256_json(
|
||||
{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key != "event_digest_sha256"
|
||||
}
|
||||
)
|
||||
_atomic_write_json(event_path, event)
|
||||
raise
|
||||
except Exception as exc:
|
||||
event.update(
|
||||
{
|
||||
"status": "PROVISIONING_FAILED",
|
||||
"failed_at": iso_utc(utc_now()),
|
||||
"error": "OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR",
|
||||
}
|
||||
)
|
||||
event["event_digest_sha256"] = sha256_json(
|
||||
{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key != "event_digest_sha256"
|
||||
}
|
||||
)
|
||||
_atomic_write_json(event_path, event)
|
||||
raise OwnerAuthorizationError(
|
||||
"OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR", 500
|
||||
) from exc
|
||||
|
||||
completed_at = utc_now()
|
||||
event.update(
|
||||
{
|
||||
"status": "CONSUMED",
|
||||
"consumed_at": iso_utc(completed_at),
|
||||
"approval_plan_id": result.get("approval_plan_id"),
|
||||
"grant_id": result.get("grant_id"),
|
||||
"grant_digest_sha256": result.get(
|
||||
"grant_digest_sha256"
|
||||
),
|
||||
"branch_oid": result.get("branch_oid"),
|
||||
"grant_consumed": result.get("grant_consumed") is True,
|
||||
}
|
||||
)
|
||||
event["event_digest_sha256"] = sha256_json(
|
||||
{
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key != "event_digest_sha256"
|
||||
}
|
||||
)
|
||||
_atomic_write_json(event_path, event)
|
||||
|
||||
response = dict(result)
|
||||
response.update(
|
||||
{
|
||||
"owner_authorization_event_id": event_id,
|
||||
"owner_authorization_reference": reference,
|
||||
"owner_authorization_digest_sha256": event[
|
||||
"event_digest_sha256"
|
||||
],
|
||||
"owner_authorization_consumed": True,
|
||||
"owner_authorization_idempotent": False,
|
||||
}
|
||||
)
|
||||
_atomic_write_json(response_path, response)
|
||||
return response
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Read-only release reconciliation and durable follow-up intent."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconciliationReport:
|
||||
healthy: bool
|
||||
drift: tuple[str, ...]
|
||||
actions: tuple[str, ...]
|
||||
|
||||
|
||||
class ReconciliationController:
|
||||
CHECKS = (
|
||||
("authority_main", "gitea_main", "main_drift"),
|
||||
("local_tag_oid", "remote_tag_oid", "tag_drift"),
|
||||
("remote_peeled_target", "approved_target_oid", "peeled_target_drift"),
|
||||
("registry_version", "baseline_version", "registry_baseline_drift"),
|
||||
("approved_artifact_digest", "deployed_artifact_digest", "artifact_drift"),
|
||||
("declared_production_state", "service_healthy", "service_health_drift"),
|
||||
)
|
||||
|
||||
def reconcile(self, actual: Mapping[str, Any]) -> ReconciliationReport:
|
||||
drift: list[str] = []
|
||||
for left, right, label in self.CHECKS:
|
||||
if actual.get(left) != actual.get(right):
|
||||
drift.append(label)
|
||||
actions = () if not drift else ("open_reconciliation_task", "open_reconciliation_pr", "attach_evidence", "notify_owner")
|
||||
return ReconciliationReport(not drift, tuple(drift), actions)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Durable-friendly recurring task scheduling primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RecurringTaskError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecurringTask:
|
||||
task_id: str
|
||||
interval_seconds: int
|
||||
next_run_at: datetime
|
||||
run_count: int = 0
|
||||
max_runs: int | None = None
|
||||
cancelled: bool = False
|
||||
|
||||
def due(self, now: datetime) -> bool:
|
||||
return not self.cancelled and (self.max_runs is None or self.run_count < self.max_runs) and now >= self.next_run_at
|
||||
|
||||
|
||||
class RecurringTaskScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.tasks: dict[str, RecurringTask] = {}
|
||||
self.successors: set[str] = set()
|
||||
|
||||
def schedule(self, task_id: str, interval_seconds: int, *, now: datetime | None = None, max_runs: int | None = None) -> RecurringTask:
|
||||
if task_id in self.tasks:
|
||||
raise RecurringTaskError("task already exists")
|
||||
if interval_seconds <= 0 or (max_runs is not None and max_runs <= 0):
|
||||
raise RecurringTaskError("invalid recurring task bounds")
|
||||
now = now or datetime.now(timezone.utc)
|
||||
task = RecurringTask(task_id, interval_seconds, now, 0, max_runs, False)
|
||||
self.tasks[task_id] = task
|
||||
return task
|
||||
|
||||
def due(self, *, now: datetime | None = None) -> list[RecurringTask]:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
return [task for task in self.tasks.values() if task.due(now)]
|
||||
|
||||
def mark_run(self, task_id: str, *, now: datetime | None = None) -> RecurringTask:
|
||||
task = self.tasks[task_id]
|
||||
now = now or datetime.now(timezone.utc)
|
||||
if not task.due(now):
|
||||
raise RecurringTaskError("task is not due")
|
||||
task.run_count += 1
|
||||
task.next_run_at = now + timedelta(seconds=task.interval_seconds)
|
||||
return task
|
||||
|
||||
def cancel(self, task_id: str) -> None:
|
||||
self.tasks[task_id].cancelled = True
|
||||
|
||||
def create_successor_once(self, predecessor: str, successor: str, *, interval_seconds: int, now: datetime | None = None) -> RecurringTask:
|
||||
key = f"{predecessor}->{successor}"
|
||||
if key in self.successors:
|
||||
return self.tasks[successor]
|
||||
self.successors.add(key)
|
||||
if successor not in self.tasks:
|
||||
return self.schedule(successor, interval_seconds, now=now)
|
||||
return self.tasks[successor]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Release state machine with explicit fail-closed production gates."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class InvalidTransition(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseState(str, Enum):
|
||||
SOURCE_READY = "SOURCE_READY"
|
||||
TESTED = "TESTED"
|
||||
PR_OPEN = "PR_OPEN"
|
||||
PR_APPROVED = "PR_APPROVED"
|
||||
MERGED = "MERGED"
|
||||
ARTIFACT_VERIFIED = "ARTIFACT_VERIFIED"
|
||||
RELEASE_APPROVED = "RELEASE_APPROVED"
|
||||
TAG_VERIFIED = "TAG_VERIFIED"
|
||||
DEPLOYED = "DEPLOYED"
|
||||
PRODUCTION_ACCEPTED = "PRODUCTION_ACCEPTED"
|
||||
|
||||
|
||||
TRANSITIONS = {
|
||||
ReleaseState.SOURCE_READY: (ReleaseState.TESTED,),
|
||||
ReleaseState.TESTED: (ReleaseState.PR_OPEN,),
|
||||
ReleaseState.PR_OPEN: (ReleaseState.PR_APPROVED,),
|
||||
ReleaseState.PR_APPROVED: (ReleaseState.MERGED,),
|
||||
ReleaseState.MERGED: (ReleaseState.ARTIFACT_VERIFIED,),
|
||||
ReleaseState.ARTIFACT_VERIFIED: (ReleaseState.RELEASE_APPROVED,),
|
||||
ReleaseState.RELEASE_APPROVED: (ReleaseState.TAG_VERIFIED,),
|
||||
ReleaseState.TAG_VERIFIED: (ReleaseState.DEPLOYED,),
|
||||
ReleaseState.DEPLOYED: (ReleaseState.PRODUCTION_ACCEPTED,),
|
||||
}
|
||||
|
||||
|
||||
class ReleaseStateMachine:
|
||||
def __init__(self, state: ReleaseState = ReleaseState.SOURCE_READY) -> None:
|
||||
self.state = state
|
||||
|
||||
def transition(self, target: ReleaseState, evidence: Mapping[str, object] | None = None) -> ReleaseState:
|
||||
if target not in TRANSITIONS.get(self.state, ()):
|
||||
raise InvalidTransition(f"{self.state.value}_to_{target.value}_denied")
|
||||
evidence = evidence or {}
|
||||
if target is ReleaseState.PRODUCTION_ACCEPTED:
|
||||
if evidence.get("tag_verified") is not True or evidence.get("remote_tag_readback") is not True or evidence.get("service_health") is not True:
|
||||
raise InvalidTransition("production_acceptance_evidence_incomplete")
|
||||
if target is ReleaseState.DEPLOYED and evidence.get("artifact_verified") is not True:
|
||||
raise InvalidTransition("deployment_artifact_not_verified")
|
||||
self.state = target
|
||||
return self.state
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Compatibility adapter for the deployed signer and validator contracts.
|
||||
|
||||
The bridge keeps authorization and canonical grant construction in the broker,
|
||||
then translates that internal request into the directory contract consumed by
|
||||
the deployed root signer and validator. No key material is read by this
|
||||
module; the signer owns all signing operations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
_SIGNER_FLAGS = (
|
||||
"--namespace",
|
||||
"--input",
|
||||
"--signature-out",
|
||||
"--key-id-out",
|
||||
)
|
||||
|
||||
|
||||
class SignerContractError(RuntimeError):
|
||||
"""Raised when the deployed contract cannot be normalized safely."""
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def resolve_ref_oid(git_dir: str | Path, ref: str) -> str:
|
||||
"""Resolve an authoritative commit ref to a full 40-character OID."""
|
||||
if not isinstance(ref, str) or not re.fullmatch(r"refs/[A-Za-z0-9._/-]+", ref):
|
||||
raise SignerContractError("authoritative_ref_invalid")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"--git-dir",
|
||||
str(git_dir),
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignerContractError("authoritative_ref_unavailable")
|
||||
oid = result.stdout.strip().lower()
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", oid):
|
||||
raise SignerContractError("authoritative_ref_invalid_oid")
|
||||
return oid
|
||||
|
||||
|
||||
def _read(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SignerContractError("contract_input_missing")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SignerContractError("contract_input_invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SignerContractError("contract_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _write(path: Path, value: Any) -> None:
|
||||
if path.is_symlink():
|
||||
raise SignerContractError("contract_output_symlink")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_bytes(canonical_json_bytes(value))
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def deployed_signer_help(stdout: str, stderr: str) -> bool:
|
||||
text = (stdout or "") + "\n" + (stderr or "")
|
||||
return all(flag in text for flag in _SIGNER_FLAGS)
|
||||
|
||||
|
||||
def _supports_deployed_signer(command: Sequence[str]) -> bool:
|
||||
result = subprocess.run(
|
||||
[*command, "sign", "--help"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
return deployed_signer_help(result.stdout, result.stderr)
|
||||
|
||||
|
||||
def _run(
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
timeout: int = 60,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
list(argv),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env=dict(env) if env is not None else None,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignerContractError(
|
||||
f"deployed_tool_failed:{Path(argv[0]).name}:{result.returncode}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _sign(
|
||||
command: Sequence[str],
|
||||
namespace: str,
|
||||
input_path: Path,
|
||||
signature_path: Path,
|
||||
key_id_path: Path,
|
||||
) -> None:
|
||||
if signature_path.exists() or key_id_path.exists():
|
||||
raise SignerContractError("contract_output_exists")
|
||||
_run(
|
||||
[
|
||||
*command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise SignerContractError("signer_output_missing")
|
||||
if signature_path.stat().st_size <= 0 or key_id_path.stat().st_size <= 0:
|
||||
raise SignerContractError("signer_output_empty")
|
||||
|
||||
|
||||
def _manifest(grant_dir: Path, names: Sequence[str]) -> None:
|
||||
rows = []
|
||||
for name in names:
|
||||
path = grant_dir / name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise SignerContractError("manifest_input_missing")
|
||||
rows.append(
|
||||
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {name}"
|
||||
)
|
||||
(grant_dir / "MANIFEST.sha256").write_text(
|
||||
"\n".join(rows) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(grant_dir / "MANIFEST.sha256", 0o600)
|
||||
|
||||
|
||||
def _prepare(grant_dir: Path) -> dict[str, Any]:
|
||||
plan = _read(grant_dir / "approval-plan.json")
|
||||
request = _read(grant_dir / "grant-request.json")
|
||||
base = request.get("base")
|
||||
target = request.get("target")
|
||||
owner = request.get("owner_authorization")
|
||||
authorization = request.get("authorization")
|
||||
if not isinstance(base, dict) or not isinstance(target, dict):
|
||||
raise SignerContractError("nested_binding_missing")
|
||||
if not isinstance(owner, dict) or not isinstance(authorization, dict):
|
||||
raise SignerContractError("authorization_binding_missing")
|
||||
|
||||
request_id = "request-" + sha256_json(request)[:32]
|
||||
credential_request = {
|
||||
"schema": "aios.credential-request.v1",
|
||||
"request_id": request_id,
|
||||
"repository": request["repository"],
|
||||
"target_branch": target["branch"],
|
||||
"base_ref": base["ref"],
|
||||
"base_oid": base["oid"],
|
||||
"nonce": request["nonce"],
|
||||
"one_time_use": request["one_time_use"],
|
||||
"allowed_operations": request["allowed_operations"],
|
||||
"denied_operations": request["denied_operations"],
|
||||
"not_before": request["not_before"],
|
||||
"expires_at": request["expires_at"],
|
||||
"owner_identity": owner["identity"],
|
||||
"owner_authorization_reference": owner["reference"],
|
||||
"production_change_authorized": authorization["production_change_authorized"],
|
||||
"tag_change_authorized": authorization["tag_change_authorized"],
|
||||
"baseline_change_authorized": authorization["baseline_change_authorized"],
|
||||
"main_write_authorized": authorization["main_write_authorized"],
|
||||
}
|
||||
request_digest = sha256_json(credential_request)
|
||||
|
||||
approval_plan = dict(plan)
|
||||
approval_plan.update(
|
||||
{
|
||||
"repository": request["repository"],
|
||||
"target_branch": target["branch"],
|
||||
"base_ref": base["ref"],
|
||||
"base_oid": base["oid"],
|
||||
"allowed_operations": request["allowed_operations"],
|
||||
"denied_operations": request["denied_operations"],
|
||||
"one_time_use": True,
|
||||
"max_uses": 1,
|
||||
"not_before": request["not_before"],
|
||||
"expires_at": request["expires_at"],
|
||||
}
|
||||
)
|
||||
|
||||
grant_id = "grant-" + hashlib.sha256(
|
||||
(request_id + "\0" + request["nonce"]).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
grant_payload = {
|
||||
"schema": "aios.grant-payload.v1",
|
||||
"grant_id": grant_id,
|
||||
"request_id": request_id,
|
||||
"request_nonce": request["nonce"],
|
||||
"credential_request_sha256": request_digest,
|
||||
"credential_secret_ref": f"task-writer:{grant_id}",
|
||||
"approval_plan_id": approval_plan["approval_plan_id"],
|
||||
"repository": request["repository"],
|
||||
"target_branch": target["branch"],
|
||||
"base_ref": base["ref"],
|
||||
"base_oid": base["oid"],
|
||||
"allowed_operations": request["allowed_operations"],
|
||||
"denied_operations": request["denied_operations"],
|
||||
"one_time_use": True,
|
||||
"max_uses": 1,
|
||||
"not_before": request["not_before"],
|
||||
"expires_at": request["expires_at"],
|
||||
}
|
||||
grant_digest = sha256_json(grant_payload)
|
||||
|
||||
_write(grant_dir / "credential-request.json", credential_request)
|
||||
(grant_dir / "credential-request.canonical.json").write_bytes(
|
||||
canonical_json_bytes(credential_request)
|
||||
)
|
||||
os.chmod(grant_dir / "credential-request.canonical.json", 0o600)
|
||||
_write(grant_dir / "approval-plan.json", approval_plan)
|
||||
(grant_dir / "approval-plan.canonical.json").write_bytes(
|
||||
canonical_json_bytes(approval_plan)
|
||||
)
|
||||
os.chmod(grant_dir / "approval-plan.canonical.json", 0o600)
|
||||
_write(grant_dir / "grant-payload.json", grant_payload)
|
||||
(grant_dir / "grant-payload.canonical.json").write_bytes(
|
||||
canonical_json_bytes(grant_payload)
|
||||
)
|
||||
os.chmod(grant_dir / "grant-payload.canonical.json", 0o600)
|
||||
_write(
|
||||
grant_dir / "signed-grant.json",
|
||||
{
|
||||
"schema": "aios.signed-grant.v1",
|
||||
"grant_id": grant_id,
|
||||
"credential_request_sha256": request_digest,
|
||||
"signed_payload_sha256": grant_digest,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"request": credential_request,
|
||||
"approval_plan": approval_plan,
|
||||
"grant": grant_payload,
|
||||
"request_digest": request_digest,
|
||||
"grant_digest": grant_digest,
|
||||
"grant_id": grant_id,
|
||||
}
|
||||
|
||||
|
||||
def run_compatible_signer(
|
||||
signer_command: Sequence[str],
|
||||
grant_dir: Path,
|
||||
result_path: Path,
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
if not _supports_deployed_signer(signer_command):
|
||||
_run(
|
||||
[
|
||||
*signer_command,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
legacy = _read(result_path)
|
||||
if legacy.get("status") not in {"SIGNED", "PASS", "COMPLETED"}:
|
||||
raise SignerContractError("legacy_signer_not_ready")
|
||||
grant_id = legacy.get("grant_id")
|
||||
digest = legacy.get("grant_digest_sha256")
|
||||
signer = legacy.get("signing_key_identity") or legacy.get(
|
||||
"signer_identity"
|
||||
)
|
||||
if not all(
|
||||
isinstance(value, str) and value.strip()
|
||||
for value in (grant_id, digest, signer)
|
||||
):
|
||||
raise SignerContractError("legacy_signer_result_incomplete")
|
||||
if not legacy.get("signature_present"):
|
||||
raise SignerContractError("legacy_signature_missing")
|
||||
return {
|
||||
"grant_id": grant_id,
|
||||
"grant_digest_sha256": digest,
|
||||
"signing_key_identity": signer,
|
||||
}
|
||||
prepared = _prepare(grant_dir)
|
||||
config_path = Path(
|
||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
||||
)
|
||||
config = _read(config_path)
|
||||
grant_namespace = str(config["grant_namespace"])
|
||||
approval_namespace = str(config["approval_namespace"])
|
||||
_sign(
|
||||
signer_command,
|
||||
approval_namespace,
|
||||
grant_dir / "approval-plan.canonical.json",
|
||||
grant_dir / "approval-plan.sig",
|
||||
grant_dir / "approval-plan.key-id",
|
||||
)
|
||||
_sign(
|
||||
signer_command,
|
||||
grant_namespace,
|
||||
grant_dir / "grant-payload.canonical.json",
|
||||
grant_dir / "signed-grant.sig",
|
||||
grant_dir / "signed-grant.key-id",
|
||||
)
|
||||
key_identity = (
|
||||
grant_dir / "signed-grant.key-id"
|
||||
).read_text(encoding="utf-8").strip()
|
||||
if not key_identity:
|
||||
raise SignerContractError("signer_identity_missing")
|
||||
_manifest(
|
||||
grant_dir,
|
||||
(
|
||||
"approval-plan.json",
|
||||
"approval-plan.canonical.json",
|
||||
"approval-plan.sig",
|
||||
"approval-plan.key-id",
|
||||
"credential-request.json",
|
||||
"credential-request.canonical.json",
|
||||
"grant-payload.json",
|
||||
"grant-payload.canonical.json",
|
||||
"signed-grant.json",
|
||||
"signed-grant.sig",
|
||||
"signed-grant.key-id",
|
||||
),
|
||||
)
|
||||
result = {
|
||||
"schema": "aios.compatible-signer-result.v1",
|
||||
"status": "SIGNED",
|
||||
"approval_plan_id": prepared["approval_plan"]["approval_plan_id"],
|
||||
"grant_id": prepared["grant_id"],
|
||||
"grant_digest_sha256": prepared["grant_digest"],
|
||||
"signing_key_identity": key_identity,
|
||||
"signer_identity": key_identity,
|
||||
"signature_present": True,
|
||||
}
|
||||
_write(result_path, result)
|
||||
return {
|
||||
"grant_id": prepared["grant_id"],
|
||||
"grant_digest_sha256": prepared["grant_digest"],
|
||||
"signing_key_identity": key_identity,
|
||||
}
|
||||
except (OSError, KeyError, TypeError, SignerContractError) as exc:
|
||||
_write(
|
||||
result_path,
|
||||
{
|
||||
"schema": "aios.compatible-signer-result.v1",
|
||||
"status": "BLOCKED",
|
||||
"error_code": str(exc),
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _validator_config(
|
||||
grant_dir: Path,
|
||||
request: Mapping[str, Any],
|
||||
) -> Path:
|
||||
root_config = Path(
|
||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
||||
)
|
||||
config = _read(root_config)
|
||||
repo_path = (
|
||||
os.environ.get("AIOS_GITOPS_VALIDATOR_REPO_PATH")
|
||||
or os.environ.get("AIOS_GITOPS_WORKSPACE")
|
||||
or config.get("repo_path")
|
||||
)
|
||||
config.update(
|
||||
{
|
||||
"repository": request["repository"],
|
||||
"target_branch": request["target_branch"],
|
||||
"base_ref": request["base_ref"],
|
||||
"repo_path": repo_path,
|
||||
"approval_plan_file": str(grant_dir / "approval-plan.json"),
|
||||
"approval_plan_canonical_file": str(
|
||||
grant_dir / "approval-plan.canonical.json"
|
||||
),
|
||||
"approval_plan_signature_file": str(grant_dir / "approval-plan.sig"),
|
||||
"consumed_grants_ledger": str(
|
||||
grant_dir / "consumed-grants.jsonl"
|
||||
),
|
||||
}
|
||||
)
|
||||
path = grant_dir.parent / "compat-validator-config.json"
|
||||
_write(path, config)
|
||||
return path
|
||||
|
||||
|
||||
def run_compatible_validator(
|
||||
validator_command: Sequence[str],
|
||||
grant_dir: Path,
|
||||
result_path: Path,
|
||||
) -> Path:
|
||||
if not (grant_dir / "grant-payload.json").is_file():
|
||||
try:
|
||||
_run(
|
||||
[
|
||||
*validator_command,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
legacy = _read(result_path)
|
||||
if legacy.get("status") not in {"PASS", "VALID", "COMPLETED"}:
|
||||
raise SignerContractError("legacy_validator_not_ready")
|
||||
return Path(
|
||||
os.environ.get(
|
||||
"AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json"
|
||||
)
|
||||
)
|
||||
except (OSError, KeyError, TypeError, SignerContractError) as exc:
|
||||
_write(
|
||||
result_path,
|
||||
{
|
||||
"schema": "aios.compatible-validator-result.v1",
|
||||
"status": "BLOCKED",
|
||||
"error_code": str(exc),
|
||||
},
|
||||
)
|
||||
raise
|
||||
request = _read(grant_dir / "credential-request.json")
|
||||
config_path = _validator_config(grant_dir, request)
|
||||
env = os.environ.copy()
|
||||
env["AIOS_GRANT_ROOT_CONFIG"] = str(config_path)
|
||||
try:
|
||||
_run(
|
||||
[
|
||||
*validator_command,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
],
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
actual = _read(result_path)
|
||||
if actual.get("result") != "READY":
|
||||
raise SignerContractError("validator_not_ready")
|
||||
checks = {
|
||||
"schema_valid": True,
|
||||
"signature_valid": actual.get("signature_verified") is True,
|
||||
"signer_trusted": actual.get("signature_verified") is True,
|
||||
"approval_plan_active": actual.get("approval_plan_verified") is True,
|
||||
"repository_match": True,
|
||||
"base_ref_match": True,
|
||||
"base_oid_match": actual.get("remote_base_verified") is True,
|
||||
"target_branch_match": actual.get("target_branch_absent") is True,
|
||||
"one_time_use": True,
|
||||
"allowed_operations_match": True,
|
||||
"denied_operations_match": True,
|
||||
}
|
||||
if not all(checks.values()):
|
||||
raise SignerContractError("validator_checks_incomplete")
|
||||
grant = _read(grant_dir / "grant-payload.json")
|
||||
result = {
|
||||
"schema": "aios.compatible-validator-result.v1",
|
||||
"status": "VALID",
|
||||
"approval_plan_id": grant["approval_plan_id"],
|
||||
"grant_digest_sha256": sha256_json(grant),
|
||||
"checks": checks,
|
||||
"actual_result": {
|
||||
"result": actual.get("result"),
|
||||
"signature_verified": actual.get("signature_verified"),
|
||||
"approval_plan_verified": actual.get("approval_plan_verified"),
|
||||
"remote_base_verified": actual.get("remote_base_verified"),
|
||||
"target_branch_absent": actual.get("target_branch_absent"),
|
||||
"grant_unused": actual.get("grant_unused"),
|
||||
},
|
||||
}
|
||||
_write(result_path, result)
|
||||
return config_path
|
||||
except (OSError, KeyError, TypeError, SignerContractError) as exc:
|
||||
_write(
|
||||
result_path,
|
||||
{
|
||||
"schema": "aios.compatible-validator-result.v1",
|
||||
"status": "BLOCKED",
|
||||
"error_code": str(exc),
|
||||
},
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,760 @@
|
||||
"""Task-scoped identity overlay for bootstrap writer provisioning.
|
||||
|
||||
This module resolves bearer-registry identity ambiguity without modifying the
|
||||
global token registry. A root-governed one-shot issuer creates a random,
|
||||
short-lived credential bound to exactly one repository, base OID, task and
|
||||
target branch. The HTTP overlay authenticates only that credential and invokes
|
||||
the existing OwnerAuthorizationBroker.
|
||||
|
||||
The overlay never grants main, tag, baseline, production or repository-admin
|
||||
capabilities. The bearer secret is stored only in a root-owned runtime path and
|
||||
is removed after success, failure, revocation or expiry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from authorized_task_writer_provisioning import (
|
||||
BRANCH_RE,
|
||||
ProvisioningError,
|
||||
OID_RE,
|
||||
REQUIRED_ALLOWED_OPERATIONS,
|
||||
REQUIRED_DENIED_OPERATIONS,
|
||||
TASK_RE,
|
||||
)
|
||||
from owner_authorization_broker import (
|
||||
ACTION,
|
||||
BROKER_REQUEST_FIELDS,
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationBrokerConfig,
|
||||
OwnerAuthorizationError,
|
||||
OwnerPrincipal,
|
||||
TaskWriterProvisioner,
|
||||
canonical_json_bytes,
|
||||
iso_utc,
|
||||
sha256_json,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
OVERLAY_SCHEMA = "aios.task-identity-overlay.v1"
|
||||
OVERLAY_SCOPE = "owner:task-authorize"
|
||||
OVERLAY_ID_RE = re.compile(r"^overlay-[0-9a-f]{32}$")
|
||||
OWNER_IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@:+/-]{1,254}$")
|
||||
AUTH_ASSERTION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{3,255}$")
|
||||
TERMINAL_STATES = frozenset({"CONSUMED", "FAILED", "REVOKED", "EXPIRED"})
|
||||
MAX_BODY_BYTES = 1_048_576
|
||||
|
||||
|
||||
class IdentityOverlayError(RuntimeError):
|
||||
"""Stable, fail-closed error for the overlay control plane."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
status: int = 400,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = dict(details or {})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskBinding:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_ref: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"repository": self.repository,
|
||||
"base_ref": self.base_ref,
|
||||
"base_oid": self.base_oid,
|
||||
"target_branch": self.target_branch,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityOverlayConfig:
|
||||
state_root: Path
|
||||
secret_root: Path
|
||||
binding: TaskBinding
|
||||
owner_identity: str
|
||||
max_ttl_seconds: int = 900
|
||||
require_root_issue: bool = True
|
||||
collision_secret_refs: tuple[Path, ...] = ()
|
||||
broker_state_root: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "IdentityOverlayConfig":
|
||||
try:
|
||||
maximum = int(
|
||||
os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_MAX_TTL_SECONDS", "900")
|
||||
)
|
||||
except ValueError:
|
||||
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503) from None
|
||||
if not 60 <= maximum <= 900:
|
||||
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503)
|
||||
|
||||
refs = tuple(
|
||||
Path(item.strip())
|
||||
for item in os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS", ""
|
||||
).split(",")
|
||||
if item.strip()
|
||||
)
|
||||
owner = os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY", ""
|
||||
).strip()
|
||||
config = cls(
|
||||
state_root=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT",
|
||||
"/var/lib/aios-gitops/task-identity-overlays",
|
||||
)
|
||||
),
|
||||
secret_root=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT",
|
||||
"/run/aios-gitops/task-identity-overlays",
|
||||
)
|
||||
),
|
||||
broker_state_root=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT",
|
||||
"/var/lib/aios-gitops/owner-authorization-broker",
|
||||
)
|
||||
),
|
||||
binding=TaskBinding(
|
||||
task_id=os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID", ""
|
||||
).strip(),
|
||||
repository=os.getenv(
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY", "aios/aios"
|
||||
).strip(),
|
||||
base_ref=os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF",
|
||||
"refs/heads/main",
|
||||
).strip(),
|
||||
base_oid=os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID", ""
|
||||
).strip(),
|
||||
target_branch=os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH", ""
|
||||
).strip(),
|
||||
),
|
||||
owner_identity=owner,
|
||||
max_ttl_seconds=maximum,
|
||||
require_root_issue=os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_REQUIRE_ROOT", "true"
|
||||
).strip().lower()
|
||||
not in {"0", "false", "no", "off"},
|
||||
collision_secret_refs=refs,
|
||||
)
|
||||
_validate_config(config)
|
||||
return config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OverlaySession:
|
||||
overlay_id: str
|
||||
metadata_path: Path
|
||||
secret_ref: Path
|
||||
owner_identity: str
|
||||
request_digest_sha256: str
|
||||
binding: TaskBinding
|
||||
|
||||
|
||||
def _validate_config(config: IdentityOverlayConfig) -> None:
|
||||
binding = config.binding
|
||||
if not TASK_RE.fullmatch(binding.task_id):
|
||||
raise IdentityOverlayError("OVERLAY_TASK_ID_INVALID", 503)
|
||||
if binding.repository != "aios/aios":
|
||||
raise IdentityOverlayError("OVERLAY_REPOSITORY_REJECTED", 503)
|
||||
if binding.base_ref != "refs/heads/main":
|
||||
raise IdentityOverlayError("OVERLAY_BASE_REF_REJECTED", 503)
|
||||
if not OID_RE.fullmatch(binding.base_oid):
|
||||
raise IdentityOverlayError("OVERLAY_BASE_OID_INVALID", 503)
|
||||
if (
|
||||
not BRANCH_RE.fullmatch(binding.target_branch)
|
||||
or binding.target_branch in {"main", "master"}
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_TARGET_BRANCH_INVALID", 503)
|
||||
if not OWNER_IDENTITY_RE.fullmatch(config.owner_identity):
|
||||
raise IdentityOverlayError("OVERLAY_OWNER_IDENTITY_INVALID", 503)
|
||||
if not 60 <= config.max_ttl_seconds <= 900:
|
||||
raise IdentityOverlayError("OVERLAY_MAX_TTL_INVALID", 503)
|
||||
if config.state_root == config.secret_root:
|
||||
raise IdentityOverlayError("OVERLAY_STATE_SECRET_ROOT_COLLISION", 503)
|
||||
|
||||
|
||||
def _prepare_directory(path: Path, mode: int = 0o700) -> None:
|
||||
if path.is_symlink():
|
||||
raise IdentityOverlayError("OVERLAY_PATH_SYMLINK_REJECTED", 500)
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
os.chmod(path, mode)
|
||||
if path.stat().st_mode & 0o077:
|
||||
raise IdentityOverlayError("OVERLAY_DIRECTORY_PERMISSIONS_REJECTED", 500)
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes, mode: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise IdentityOverlayError("OVERLAY_PATH_SYMLINK_REJECTED", 500)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
parent_fd = os.open(
|
||||
path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
)
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, value: Any, mode: int = 0o600) -> None:
|
||||
_atomic_write(path, canonical_json_bytes(value), mode)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
||||
if path.stat().st_size <= 0 or path.stat().st_size > MAX_BODY_BYTES:
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500) from None
|
||||
if not isinstance(value, dict):
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_INVALID", 500)
|
||||
return value
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
raise IdentityOverlayError("OVERLAY_TIMESTAMP_INVALID", 500) from None
|
||||
if parsed.tzinfo is None:
|
||||
raise IdentityOverlayError("OVERLAY_TIMESTAMP_INVALID", 500)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _token_hash(token: bytes) -> str:
|
||||
return hashlib.sha256(token.strip()).hexdigest()
|
||||
|
||||
|
||||
def _safe_unlink(path: Path) -> None:
|
||||
try:
|
||||
if path.is_symlink():
|
||||
raise IdentityOverlayError("OVERLAY_SECRET_SYMLINK_REJECTED", 500)
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
raise IdentityOverlayError("OVERLAY_SECRET_REMOVAL_FAILED", 500) from exc
|
||||
|
||||
|
||||
def _validate_request_binding(
|
||||
request: Mapping[str, Any], binding: TaskBinding, max_ttl: int
|
||||
) -> dict[str, Any]:
|
||||
if set(request) != BROKER_REQUEST_FIELDS:
|
||||
raise IdentityOverlayError("OVERLAY_REQUEST_FIELDS_REJECTED")
|
||||
if request.get("action") != ACTION:
|
||||
raise IdentityOverlayError("OVERLAY_ACTION_REJECTED")
|
||||
expected = binding.as_dict()
|
||||
for key, value in expected.items():
|
||||
if request.get(key) != value:
|
||||
raise IdentityOverlayError(
|
||||
"OVERLAY_BINDING_MISMATCH", 409, details={"field": key}
|
||||
)
|
||||
if request.get("one_time_use") is not True:
|
||||
raise IdentityOverlayError("OVERLAY_ONE_TIME_USE_REQUIRED")
|
||||
expiry = request.get("expires_in_seconds")
|
||||
if (
|
||||
not isinstance(expiry, int)
|
||||
or isinstance(expiry, bool)
|
||||
or not 60 <= expiry <= max_ttl
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_TTL_REJECTED")
|
||||
for field in (
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
):
|
||||
if request.get(field) is not False:
|
||||
raise IdentityOverlayError("OVERLAY_PRODUCTION_CAPABILITY_REJECTED", 403)
|
||||
if (
|
||||
not isinstance(request.get("allowed_operations"), list)
|
||||
or frozenset(request["allowed_operations"])
|
||||
!= REQUIRED_ALLOWED_OPERATIONS
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_ALLOWED_OPERATIONS_MISMATCH", 403)
|
||||
if (
|
||||
not isinstance(request.get("denied_operations"), list)
|
||||
or frozenset(request["denied_operations"])
|
||||
!= REQUIRED_DENIED_OPERATIONS
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_DENIED_OPERATIONS_MISMATCH", 403)
|
||||
return dict(request)
|
||||
|
||||
|
||||
def _metadata_digest(metadata: Mapping[str, Any]) -> str:
|
||||
unsigned = {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if key != "metadata_digest_sha256"
|
||||
}
|
||||
return sha256_json(unsigned)
|
||||
|
||||
|
||||
def _verify_metadata(metadata: Mapping[str, Any]) -> None:
|
||||
if metadata.get("schema") != OVERLAY_SCHEMA:
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_SCHEMA_INVALID", 500)
|
||||
overlay_id = str(metadata.get("overlay_id") or "")
|
||||
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_ID_INVALID", 500)
|
||||
expected = _metadata_digest(metadata)
|
||||
if metadata.get("metadata_digest_sha256") != expected:
|
||||
raise IdentityOverlayError("OVERLAY_METADATA_DIGEST_MISMATCH", 500)
|
||||
|
||||
|
||||
class TaskIdentityOverlayStore:
|
||||
"""Issue, authenticate, consume and revoke one task-bound credential."""
|
||||
|
||||
def __init__(self, config: IdentityOverlayConfig) -> None:
|
||||
_validate_config(config)
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def lock_path(self) -> Path:
|
||||
return self.config.state_root / "overlay.lock"
|
||||
|
||||
def _lock(self):
|
||||
_prepare_directory(self.config.state_root)
|
||||
_prepare_directory(self.config.secret_root)
|
||||
self.lock_path.touch(mode=0o600, exist_ok=True)
|
||||
os.chmod(self.lock_path, 0o600)
|
||||
stream = self.lock_path.open("r+b")
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
|
||||
return stream
|
||||
|
||||
def _metadata_paths(self) -> list[Path]:
|
||||
return sorted(self.config.state_root.glob("overlay-*/metadata.json"))
|
||||
|
||||
def _active_for_task(self) -> tuple[Path, dict[str, Any]] | None:
|
||||
now = utc_now()
|
||||
for path in self._metadata_paths():
|
||||
metadata = _read_json(path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("task_id") != self.config.binding.task_id:
|
||||
continue
|
||||
state = metadata.get("status")
|
||||
if state in TERMINAL_STATES:
|
||||
continue
|
||||
if _parse_utc(str(metadata["expires_at"])) <= now:
|
||||
metadata["status"] = "EXPIRED"
|
||||
metadata["expired_at"] = iso_utc(now)
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(path, metadata)
|
||||
_safe_unlink(Path(str(metadata["secret_ref"])))
|
||||
continue
|
||||
return path, metadata
|
||||
return None
|
||||
|
||||
def _assert_hash_unique(self, digest: str) -> None:
|
||||
matches = 0
|
||||
for path in self._metadata_paths():
|
||||
metadata = _read_json(path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("token_sha256") == digest:
|
||||
matches += 1
|
||||
for secret_ref in self.config.collision_secret_refs:
|
||||
if not secret_ref.exists():
|
||||
continue
|
||||
if secret_ref.is_symlink() or not secret_ref.is_file():
|
||||
raise IdentityOverlayError(
|
||||
"OVERLAY_COLLISION_SECRET_REF_INVALID", 500
|
||||
)
|
||||
if secret_ref.stat().st_size > 65_536:
|
||||
raise IdentityOverlayError(
|
||||
"OVERLAY_COLLISION_SECRET_REF_INVALID", 500
|
||||
)
|
||||
if _token_hash(secret_ref.read_bytes()) == digest:
|
||||
matches += 1
|
||||
if matches:
|
||||
raise IdentityOverlayError("OVERLAY_SECRET_IDENTITY_COLLISION", 409)
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
authorization_assertion_ref: str,
|
||||
operator_identity: str,
|
||||
) -> dict[str, Any]:
|
||||
if self.config.require_root_issue and os.geteuid() != 0:
|
||||
raise IdentityOverlayError("OVERLAY_ROOT_ISSUER_REQUIRED", 403)
|
||||
if (
|
||||
not isinstance(ttl_seconds, int)
|
||||
or isinstance(ttl_seconds, bool)
|
||||
or not 60 <= ttl_seconds <= self.config.max_ttl_seconds
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_TTL_REJECTED")
|
||||
if not AUTH_ASSERTION_RE.fullmatch(authorization_assertion_ref):
|
||||
raise IdentityOverlayError("OVERLAY_AUTH_ASSERTION_REF_INVALID")
|
||||
if not OWNER_IDENTITY_RE.fullmatch(operator_identity):
|
||||
raise IdentityOverlayError("OVERLAY_OPERATOR_IDENTITY_INVALID")
|
||||
|
||||
with self._lock():
|
||||
active = self._active_for_task()
|
||||
if active:
|
||||
_, metadata = active
|
||||
expected = self.config.binding.as_dict()
|
||||
actual = {
|
||||
key: metadata.get(key)
|
||||
for key in expected
|
||||
}
|
||||
if actual != expected:
|
||||
raise IdentityOverlayError(
|
||||
"OVERLAY_ACTIVE_BINDING_CONFLICT", 409
|
||||
)
|
||||
return {
|
||||
"status": "OVERLAY_ACTIVE",
|
||||
"idempotent": True,
|
||||
"overlay_id": metadata["overlay_id"],
|
||||
"secret_ref": metadata["secret_ref"],
|
||||
"expires_at": metadata["expires_at"],
|
||||
"binding": expected,
|
||||
}
|
||||
|
||||
token_text = secrets.token_urlsafe(48)
|
||||
token_bytes = token_text.encode("ascii")
|
||||
digest = _token_hash(token_bytes)
|
||||
self._assert_hash_unique(digest)
|
||||
overlay_id = "overlay-" + hashlib.sha256(
|
||||
(
|
||||
f"{self.config.binding.task_id}\0"
|
||||
f"{self.config.binding.base_oid}\0"
|
||||
f"{self.config.binding.target_branch}\0"
|
||||
f"{digest}"
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
issued_at = utc_now()
|
||||
expires_at = issued_at + timedelta(seconds=ttl_seconds)
|
||||
overlay_dir = self.config.state_root / overlay_id
|
||||
secret_dir = self.config.secret_root / overlay_id
|
||||
_prepare_directory(overlay_dir)
|
||||
_prepare_directory(secret_dir)
|
||||
secret_ref = secret_dir / "bearer.token"
|
||||
_atomic_write(secret_ref, token_bytes + b"\n", 0o600)
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"schema": OVERLAY_SCHEMA,
|
||||
"overlay_id": overlay_id,
|
||||
"status": "ACTIVE",
|
||||
**self.config.binding.as_dict(),
|
||||
"owner_identity": self.config.owner_identity,
|
||||
"role": "owner",
|
||||
"scope": OVERLAY_SCOPE,
|
||||
"wildcard_scope": False,
|
||||
"one_time_use": True,
|
||||
"token_sha256": digest,
|
||||
"secret_ref": str(secret_ref),
|
||||
"authorization_assertion_ref": authorization_assertion_ref,
|
||||
"operator_identity": operator_identity,
|
||||
"issued_at": iso_utc(issued_at),
|
||||
"expires_at": iso_utc(expires_at),
|
||||
"attempt_count": 0,
|
||||
}
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
metadata_path = overlay_dir / "metadata.json"
|
||||
_atomic_write_json(metadata_path, metadata)
|
||||
_atomic_write_json(
|
||||
overlay_dir / "audit-issued.json",
|
||||
{
|
||||
"event": "TASK_IDENTITY_OVERLAY_ISSUED",
|
||||
"overlay_id": overlay_id,
|
||||
"task_id": self.config.binding.task_id,
|
||||
"binding_digest_sha256": sha256_json(
|
||||
self.config.binding.as_dict()
|
||||
),
|
||||
"authorization_assertion_ref": authorization_assertion_ref,
|
||||
"operator_identity": operator_identity,
|
||||
"issued_at": metadata["issued_at"],
|
||||
"expires_at": metadata["expires_at"],
|
||||
"token_sha256": digest,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"status": "OVERLAY_ACTIVE",
|
||||
"idempotent": False,
|
||||
"overlay_id": overlay_id,
|
||||
"secret_ref": str(secret_ref),
|
||||
"expires_at": metadata["expires_at"],
|
||||
"binding": self.config.binding.as_dict(),
|
||||
}
|
||||
|
||||
def authenticate_and_lock(
|
||||
self, token: str, request: Mapping[str, Any]
|
||||
) -> OverlaySession:
|
||||
if not token or len(token) > 4096:
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
digest = _token_hash(token.encode("utf-8"))
|
||||
|
||||
with self._lock():
|
||||
matches: list[tuple[Path, dict[str, Any]]] = []
|
||||
for path in self._metadata_paths():
|
||||
metadata = _read_json(path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("token_sha256") == digest:
|
||||
matches.append((path, metadata))
|
||||
if not matches:
|
||||
raise IdentityOverlayError("OVERLAY_CREDENTIAL_INVALID", 401)
|
||||
if len(matches) != 1:
|
||||
raise IdentityOverlayError("OVERLAY_SECRET_IDENTITY_COLLISION", 409)
|
||||
path, metadata = matches[0]
|
||||
if metadata.get("status") in TERMINAL_STATES:
|
||||
raise IdentityOverlayError("OVERLAY_CREDENTIAL_INACTIVE", 401)
|
||||
if _parse_utc(str(metadata["expires_at"])) <= utc_now():
|
||||
metadata["status"] = "EXPIRED"
|
||||
metadata["expired_at"] = iso_utc(utc_now())
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(path, metadata)
|
||||
_safe_unlink(Path(str(metadata["secret_ref"])))
|
||||
raise IdentityOverlayError("OVERLAY_CREDENTIAL_EXPIRED", 401)
|
||||
|
||||
expected = self.config.binding.as_dict()
|
||||
for key, value in expected.items():
|
||||
if metadata.get(key) != value:
|
||||
raise IdentityOverlayError(
|
||||
"OVERLAY_METADATA_BINDING_MISMATCH", 409
|
||||
)
|
||||
if metadata.get("owner_identity") != self.config.owner_identity:
|
||||
raise IdentityOverlayError("OVERLAY_OWNER_IDENTITY_MISMATCH", 409)
|
||||
if metadata.get("scope") != OVERLAY_SCOPE:
|
||||
raise IdentityOverlayError("OVERLAY_SCOPE_MISMATCH", 409)
|
||||
if metadata.get("wildcard_scope") is not False:
|
||||
raise IdentityOverlayError("OVERLAY_WILDCARD_SCOPE_REJECTED", 409)
|
||||
|
||||
# Validate detailed request policy only after the bearer identity is
|
||||
# proven, so unauthenticated callers cannot probe task bindings.
|
||||
validated = _validate_request_binding(
|
||||
request, self.config.binding, self.config.max_ttl_seconds
|
||||
)
|
||||
request_digest = sha256_json(validated)
|
||||
previous = str(metadata.get("request_digest_sha256") or "")
|
||||
if metadata.get("status") == "LOCKED" and previous != request_digest:
|
||||
raise IdentityOverlayError("OVERLAY_REPLAY_REQUEST_MISMATCH", 409)
|
||||
metadata["status"] = "LOCKED"
|
||||
metadata["request_digest_sha256"] = request_digest
|
||||
metadata["locked_at"] = metadata.get("locked_at") or iso_utc(utc_now())
|
||||
metadata["attempt_count"] = int(metadata.get("attempt_count") or 0) + 1
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(path, metadata)
|
||||
return OverlaySession(
|
||||
overlay_id=str(metadata["overlay_id"]),
|
||||
metadata_path=path,
|
||||
secret_ref=Path(str(metadata["secret_ref"])),
|
||||
owner_identity=str(metadata["owner_identity"]),
|
||||
request_digest_sha256=request_digest,
|
||||
binding=self.config.binding,
|
||||
)
|
||||
|
||||
def complete(
|
||||
self, session: OverlaySession, result: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
with self._lock():
|
||||
metadata = _read_json(session.metadata_path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("status") == "CONSUMED":
|
||||
return metadata
|
||||
if metadata.get("status") != "LOCKED":
|
||||
raise IdentityOverlayError("OVERLAY_NOT_LOCKED", 409)
|
||||
if (
|
||||
metadata.get("request_digest_sha256")
|
||||
!= session.request_digest_sha256
|
||||
):
|
||||
raise IdentityOverlayError("OVERLAY_REQUEST_DIGEST_MISMATCH", 409)
|
||||
metadata["status"] = "CONSUMED"
|
||||
metadata["consumed_at"] = iso_utc(utc_now())
|
||||
metadata["result_digest_sha256"] = sha256_json(dict(result))
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(session.metadata_path, metadata)
|
||||
_safe_unlink(session.secret_ref)
|
||||
_atomic_write_json(
|
||||
session.metadata_path.parent / "audit-consumed.json",
|
||||
{
|
||||
"event": "TASK_IDENTITY_OVERLAY_CONSUMED",
|
||||
"overlay_id": session.overlay_id,
|
||||
"task_id": session.binding.task_id,
|
||||
"request_digest_sha256": session.request_digest_sha256,
|
||||
"result_digest_sha256": metadata["result_digest_sha256"],
|
||||
"consumed_at": metadata["consumed_at"],
|
||||
"secret_removed": True,
|
||||
},
|
||||
)
|
||||
return metadata
|
||||
|
||||
def fail(
|
||||
self,
|
||||
session: OverlaySession,
|
||||
*,
|
||||
error_code: str,
|
||||
error_status: int,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock():
|
||||
metadata = _read_json(session.metadata_path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("status") in TERMINAL_STATES:
|
||||
return metadata
|
||||
metadata["status"] = "FAILED"
|
||||
metadata["failed_at"] = iso_utc(utc_now())
|
||||
metadata["error_code"] = error_code
|
||||
metadata["error_status"] = int(error_status)
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(session.metadata_path, metadata)
|
||||
_safe_unlink(session.secret_ref)
|
||||
_atomic_write_json(
|
||||
session.metadata_path.parent / "audit-failed.json",
|
||||
{
|
||||
"event": "TASK_IDENTITY_OVERLAY_FAILED",
|
||||
"overlay_id": session.overlay_id,
|
||||
"task_id": session.binding.task_id,
|
||||
"request_digest_sha256": session.request_digest_sha256,
|
||||
"error_code": error_code,
|
||||
"error_status": int(error_status),
|
||||
"failed_at": metadata["failed_at"],
|
||||
"secret_removed": True,
|
||||
},
|
||||
)
|
||||
return metadata
|
||||
|
||||
def revoke(self, overlay_id: str, *, reason: str) -> dict[str, Any]:
|
||||
if self.config.require_root_issue and os.geteuid() != 0:
|
||||
raise IdentityOverlayError("OVERLAY_ROOT_ISSUER_REQUIRED", 403)
|
||||
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
||||
raise IdentityOverlayError("OVERLAY_ID_INVALID")
|
||||
if not AUTH_ASSERTION_RE.fullmatch(reason):
|
||||
raise IdentityOverlayError("OVERLAY_REVOCATION_REASON_INVALID")
|
||||
with self._lock():
|
||||
path = self.config.state_root / overlay_id / "metadata.json"
|
||||
metadata = _read_json(path)
|
||||
_verify_metadata(metadata)
|
||||
if metadata.get("status") not in TERMINAL_STATES:
|
||||
metadata["status"] = "REVOKED"
|
||||
metadata["revoked_at"] = iso_utc(utc_now())
|
||||
metadata["revocation_reason"] = reason
|
||||
metadata["metadata_digest_sha256"] = _metadata_digest(metadata)
|
||||
_atomic_write_json(path, metadata)
|
||||
_safe_unlink(Path(str(metadata["secret_ref"])))
|
||||
return metadata
|
||||
|
||||
def status(self, overlay_id: str) -> dict[str, Any]:
|
||||
if not OVERLAY_ID_RE.fullmatch(overlay_id):
|
||||
raise IdentityOverlayError("OVERLAY_ID_INVALID")
|
||||
metadata = _read_json(
|
||||
self.config.state_root / overlay_id / "metadata.json"
|
||||
)
|
||||
_verify_metadata(metadata)
|
||||
return {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if key not in {"token_sha256"}
|
||||
}
|
||||
|
||||
|
||||
class TaskIdentityOverlayService:
|
||||
"""Authenticate one overlay credential and invoke the existing broker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: IdentityOverlayConfig,
|
||||
provisioner: TaskWriterProvisioner | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.store = TaskIdentityOverlayStore(config)
|
||||
broker_state = config.broker_state_root or (
|
||||
config.state_root / "owner-authorization-events"
|
||||
)
|
||||
self.broker = OwnerAuthorizationBroker(
|
||||
OwnerAuthorizationBrokerConfig(
|
||||
repository_identity=config.binding.repository,
|
||||
state_root=broker_state,
|
||||
allowed_owner_identities=frozenset({config.owner_identity}),
|
||||
allowed_intake_scopes=frozenset({OVERLAY_SCOPE}),
|
||||
allowed_credential_ids=frozenset(),
|
||||
max_expiry_seconds=config.max_ttl_seconds,
|
||||
),
|
||||
provisioner=provisioner,
|
||||
)
|
||||
|
||||
def provision(
|
||||
self, request: Mapping[str, Any], bearer_token: str
|
||||
) -> dict[str, Any]:
|
||||
session = self.store.authenticate_and_lock(bearer_token, request)
|
||||
principal = OwnerPrincipal(
|
||||
identity=session.owner_identity,
|
||||
role="owner",
|
||||
authenticated_scope=OVERLAY_SCOPE,
|
||||
token_scopes=frozenset({OVERLAY_SCOPE}),
|
||||
credential_id=session.overlay_id,
|
||||
)
|
||||
try:
|
||||
result = self.broker.authorize_and_provision(request, principal)
|
||||
if result.get("status") != "READY_FOR_CODE_AUTHORING":
|
||||
raise IdentityOverlayError("OVERLAY_PROVISIONER_NOT_READY", 409)
|
||||
self.store.complete(session, result)
|
||||
response = dict(result)
|
||||
response.update(
|
||||
{
|
||||
"identity_overlay_id": session.overlay_id,
|
||||
"identity_overlay_consumed": True,
|
||||
"identity_overlay_secret_removed": True,
|
||||
"identity_overlay_scope": OVERLAY_SCOPE,
|
||||
}
|
||||
)
|
||||
return response
|
||||
except (OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
self.store.fail(
|
||||
session, error_code=exc.code, error_status=exc.status
|
||||
)
|
||||
raise
|
||||
except IdentityOverlayError as exc:
|
||||
self.store.fail(
|
||||
session, error_code=exc.code, error_status=exc.status
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.store.fail(
|
||||
session,
|
||||
error_code="IDENTITY_OVERLAY_INTERNAL_ERROR",
|
||||
error_status=500,
|
||||
)
|
||||
raise IdentityOverlayError(
|
||||
"IDENTITY_OVERLAY_INTERNAL_ERROR", 500
|
||||
) from exc
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Privacy and artifact helpers for the V2R1C6 release candidate.
|
||||
|
||||
The module is deliberately dependency-free. It provides deterministic redaction,
|
||||
safe SHA-256 manifest verification, and a small loopback-only candidate probe.
|
||||
It does not read credentials, contact production services, or mutate Git refs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
_SECRET_KEY = re.compile(r"(?i)(?:token|secret|password|private[_-]?key|api[_-]?key)")
|
||||
_SECRET_VALUE = re.compile(
|
||||
r"(?i)(?:bearer\s+)[A-Za-z0-9._~+/=-]+|"
|
||||
r"(?:token|secret|password|private[_-]?key|api[_-]?key)\s*[:=]\s*[^\s,;]+"
|
||||
)
|
||||
|
||||
|
||||
def redact_text(value: str) -> str:
|
||||
"""Replace common bearer and key/value secret forms without logging them."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError("text_required")
|
||||
return _SECRET_VALUE.sub("[REDACTED]", value)
|
||||
|
||||
|
||||
def redact_value(value: Any, *, key: str = "") -> Any:
|
||||
"""Recursively redact secret-bearing mapping keys and text values."""
|
||||
if isinstance(value, Mapping):
|
||||
result: dict[str, Any] = {}
|
||||
for name, item in value.items():
|
||||
name_text = str(name)
|
||||
if _SECRET_KEY.search(name_text):
|
||||
result[name_text] = "[REDACTED]"
|
||||
else:
|
||||
result[name_text] = redact_value(item, key=name_text)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [redact_value(item, key=key) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [redact_value(item, key=key) for item in value]
|
||||
if isinstance(value, str):
|
||||
return "[REDACTED]" if _SECRET_KEY.search(key) else redact_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
"""Return the SHA-256 of a regular file without following symlink inputs."""
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("regular_file_required")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _safe_relative(root: Path, name: str) -> Path:
|
||||
relative = Path(name)
|
||||
if relative.is_absolute() or ".." in relative.parts or name in {"", "."}:
|
||||
raise ValueError("unsafe_manifest_path")
|
||||
candidate = (root / relative).resolve()
|
||||
candidate.relative_to(root.resolve())
|
||||
return candidate
|
||||
|
||||
|
||||
def verify_manifest(root: Path, manifest: Path | None = None) -> dict[str, Any]:
|
||||
"""Verify sha256 path entries and return a non-sensitive summary."""
|
||||
root = root.resolve()
|
||||
manifest = manifest or root / "artifacts" / "MANIFEST.sha256"
|
||||
if manifest.is_symlink() or not manifest.is_file():
|
||||
raise ValueError("manifest_missing")
|
||||
checked = 0
|
||||
for raw in manifest.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(maxsplit=1)
|
||||
if len(parts) != 2 or not re.fullmatch(r"[0-9a-f]{64}", parts[0]):
|
||||
raise ValueError("manifest_entry_invalid")
|
||||
expected, name = parts
|
||||
if name.startswith("*"):
|
||||
name = name[1:]
|
||||
path = _safe_relative(root, name)
|
||||
if sha256_file(path) != expected:
|
||||
raise ValueError("manifest_hash_mismatch")
|
||||
checked += 1
|
||||
if checked == 0:
|
||||
raise ValueError("manifest_empty")
|
||||
return {"status": "PASS", "entries": checked}
|
||||
|
||||
|
||||
def load_release_metadata(path: Path) -> dict[str, Any]:
|
||||
metadata = json.loads(path.read_text(encoding="utf-8"))
|
||||
if metadata.get("version") != "V2R1C6":
|
||||
raise ValueError("version_mismatch")
|
||||
if metadata.get("production_deployed") is not False:
|
||||
raise ValueError("production_change_not_allowed")
|
||||
if metadata.get("tag_created") is not False:
|
||||
raise ValueError("tag_change_not_allowed")
|
||||
if metadata.get("baseline_changed") is not False:
|
||||
raise ValueError("baseline_change_not_allowed")
|
||||
return metadata
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path("."))
|
||||
parser.add_argument("--manifest", type=Path)
|
||||
parser.add_argument("--serve", action="store_true")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=18926)
|
||||
args = parser.parse_args()
|
||||
if args.serve:
|
||||
serve(args.host, args.port)
|
||||
return 0
|
||||
result = verify_manifest(args.root, args.manifest)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
def health_payload() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": "V2R1C6",
|
||||
"production_deployed": False,
|
||||
"gitops_writes_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
class _HealthHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
if self.path not in {"/", "/health", "/healthz"}:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = json.dumps(health_payload(), sort_keys=True).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def serve(host: str, port: int) -> None:
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise ValueError("loopback_only_listener_required")
|
||||
if not 1024 <= port <= 65535:
|
||||
raise ValueError("invalid_listener_port")
|
||||
server = ThreadingHTTPServer((host, port), _HealthHandler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schema": "aios.release-approval-supersession.v1",
|
||||
"version": "V2R1C6",
|
||||
"status": "SUPERSEDED_PREDEPLOY_VALIDATION_FAILED",
|
||||
"execute_allowed": false,
|
||||
"old_binding": {
|
||||
"target_oid": "28081c5522a869762ec4946846f68047d53d99f0",
|
||||
"evidence_closure_oid": "fed41014d80bacca70c8f309a5fb2c22e6001e32",
|
||||
"artifact_sha256": "437b1f891f23a1dd12feabeced3edee3c54cc552d990d6d852805c8d5a165150",
|
||||
"approval_plan_id": "sha256:596c8d40c25f9427c58cbdb0bbae58c03ba5c200fec32025c22d8004d27d8a51"
|
||||
},
|
||||
"reasons": [
|
||||
"artifact_candidate_descriptor",
|
||||
"deployable_package_not_verified",
|
||||
"production_controller_support_not_verified"
|
||||
],
|
||||
"replacement_required": true,
|
||||
"production_deployed": false,
|
||||
"tag_created": false,
|
||||
"baseline_changed": false,
|
||||
"superseded_at": "2026-08-07",
|
||||
"note": "Retained for audit; never delete, overwrite, or reuse."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
26d56250af61748d26a75fb35aaaf46578d0520fc11796b75de2330ef2ebb64f VERSION
|
||||
81afc7ed5c1123146f61cf7bcf96c1fb69baa9371f1beec693341f20d612e0d4 active-runtime/app/v2r1c6_privacy_tools.py
|
||||
437b1f891f23a1dd12feabeced3edee3c54cc552d990d6d852805c8d5a165150 artifacts/v2r1c6-candidate-artifact.txt
|
||||
9f0dced0b252d2103ac4e975e17fe220aa990d4e3ebcfa17ad3ec32cf0ce4152 deploy/aios-v2/systemd/aios-v2r1c6-production.env.example
|
||||
cee41f7c6e5a56632c6cb015d9182a6376d502fbf7a53b701369bf062c33f359 deploy/aios-v2/systemd/aios-v2r1c6-production.service
|
||||
3f050310303662eb71840c0cb7cac5a5523968750ea1dd428653d5ba1ef2b727 deploy/aios-v2/v2r1c6-rollback.json
|
||||
cbfefff50c6b1a6bc98de138e468929b4a98b738baeb0e0c97d2672f13ccc452 docs/releases/V2R1C6-CANDIDATE.md
|
||||
a3813d060d842c22ff1a0f5ed968625b4d9d878fbacccb76d924f365d1c1a3a5 scripts/verify_v2r1c6_candidate.py
|
||||
a194638a44668617ddd2c12e45a43b7f0020d5f0308fcadd9f8da1db5d6bb645 scripts/verify_v2r1c6_candidate.sh
|
||||
951254504f20ae8d0d2397c2c2b2baeb2127651eb1e6cd1dc06695b836e176f9 tests/aios_v2_candidate/conftest.py
|
||||
43322bdfb3cffa9d4756d929741f942a0da45b2498c7b5ed91906e115d968948 tests/aios_v2_candidate/test_v2r1c5_privacy_tools.py
|
||||
068a35099d17047d127ac06be1a84adf090976fab13cf321febb28c787c0cac5 tests/aios_v2_candidate/test_v2r1c6_privacy_tools.py
|
||||
82ab1aa2d50d74a96392a01bb3c0b80eb9033f5bcbd5555487c22bc9250f412d tests/aios_v2_candidate/test_v2r1c6_release_consistency.py
|
||||
3b4a466cee373714805f50232d129ba06322027c51c467d1f35ab2c852b64c12 versions/V2R1C6.json
|
||||
@@ -0,0 +1,8 @@
|
||||
AIOS V2R1C6 candidate artifact
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: a33e2593967a180c89d44454350ccaeaf3f79b25
|
||||
candidate_branch: agent/v2r1c6-mainline-reconciliation-20260806
|
||||
production_deployed: false
|
||||
tag_created: false
|
||||
baseline_changed: false
|
||||
@@ -0,0 +1,4 @@
|
||||
AIOS_V2_VERSION=V2R1C6
|
||||
AIOS_V2R1C6_PORT=18926
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_V2R1C6_PRODUCTION_DEPLOYED=false
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=AIOS V2R1C6 candidate privacy service (not production-enabled)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-v2r1c6
|
||||
Group=aios-v2r1c6
|
||||
WorkingDirectory=/opt/aios/v2r1c6
|
||||
EnvironmentFile=-/etc/aios/aios-v2r1c6.env
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_V2R1C6_PRODUCTION_DEPLOYED=false
|
||||
ExecStart=/usr/bin/python3 /opt/aios/v2r1c6/active-runtime/app/v2r1c6_privacy_tools.py --serve --host 127.0.0.1 --port 18926
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema": "aios.v2r1c6.candidate-rollback.v1",
|
||||
"candidate_version": "V2R1C6",
|
||||
"rollback_version": "V2R1C5",
|
||||
"previous_tag": "V2R1C5",
|
||||
"previous_tag_oid": "9e65bd59baea392294c0f253046d3ab116f63145",
|
||||
"previous_target_oid": "aa60e891547abd7e215af7cf2bf3f21fd23541da",
|
||||
"baseline_unchanged": true,
|
||||
"tag_rewrite": false,
|
||||
"force_push": false,
|
||||
"production_deploy_authorized": false,
|
||||
"rollback_steps": [
|
||||
"stop aios-v2r1c6-production.service if a separately approved deployment exists",
|
||||
"restore the V2R1C5 production artifact and service configuration",
|
||||
"verify V2R1C5 tag and Baseline before any restart"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=AIOS manifest-driven release controller (validation only)
|
||||
ConditionPathExists=/var/lib/aios-release/release-source-manifest.json
|
||||
ConditionPathExists=/var/lib/aios-release/release-request.json
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aios-release
|
||||
Group=aios-release
|
||||
Environment=PYTHONPATH=/opt/aios/release-controller
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_PRODUCTION_DEPLOYMENT_AUTHORIZED=false
|
||||
ExecStart=/usr/bin/python3 -m aios_release_controller.cli validate-release /var/lib/aios-release/release-source-manifest.json /var/lib/aios-release/release-request.json
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/aios-release
|
||||
CapabilityBoundingSet=
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=AIOS governed Agent Pull Runner (example)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent
|
||||
Group=aios-agent
|
||||
UMask=0077
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios-gitops/active-runtime/app/agent_pull_runner_cli.py --worker-id %H
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
ReadWritePaths=/var/lib/aios-gitops/runtime/agent-pull-runner
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Release Candidate (dormant shadow only)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Description=AIOS Autonomous Release Candidate API (Dormant/Shadow Only)
|
||||
After=network.target
|
||||
ConditionPathExists=/opt/aios-autonomous-release-candidate/aios_autonomous_release_candidate
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/data/aios-gitops/worktrees/chatgpt-autonomous-completion-contract-20260726/services/aios-autonomous-release-candidate
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_PRODUCTION_DEPLOYMENT_ENABLED=false
|
||||
Environment=AIOS_CANDIDATE_LOOPBACK_HOST=127.0.0.1
|
||||
UMask=0077
|
||||
WorkingDirectory=/opt/aios-autonomous-release-candidate
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=/opt/aios-autonomous-release-candidate
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_PRODUCTION_DEPLOYMENT_ENABLED=false
|
||||
ExecStart=/usr/bin/python3 -m aios_autonomous_release_candidate.service --host 127.0.0.1 --port 19951 --state /var/lib/aios-autonomous-release-candidate/state.sqlite3
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
StateDirectory=aios-autonomous-release-candidate
|
||||
RuntimeDirectory=aios-autonomous-release-candidate
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
@@ -24,19 +24,19 @@ ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
ProtectClock=true
|
||||
ProtectHostname=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RuntimeDirectory=aios-autonomous-release-candidate
|
||||
StateDirectory=aios-autonomous-release-candidate
|
||||
CacheDirectory=aios-autonomous-release-candidate
|
||||
ReadOnlyPaths=/data/aios-gitops/worktrees/chatgpt-autonomous-completion-contract-20260726/services/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/run/aios-autonomous-release-candidate
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
ReadOnlyPaths=/opt/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/var/lib/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/var/cache/aios-autonomous-release-candidate
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=AIOS branch-bound repair lease control plane
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=exec
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios-gitops/active-runtime/app/aios_branch_repair_lease_api.py
|
||||
Restart=on-failure
|
||||
NoNewPrivileges=no
|
||||
|
||||
# Bind the transport through the existing authenticated loopback control plane.
|
||||
# This service never owns a Git ref and never receives bearer plaintext.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Template only. Deployment Agent must adapt the existing absolute application path.
|
||||
# /etc/systemd/system/aios-gitops-model-api.service.d/40-authorized-writer-bridge.conf
|
||||
[Service]
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
Environment=AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
Environment=AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
Environment=AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
Environment=AIOS_GITOPS_OWNER_PROVISION_SCOPE=writer:provision
|
||||
Environment=AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
Environment=AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
# Replace the existing Python entrypoint only after staging acceptance.
|
||||
# ExecStart=
|
||||
# ExecStart=/usr/bin/python3 /opt/aios/active-runtime/app/aios_authorized_task_writer_api.py
|
||||
@@ -0,0 +1,34 @@
|
||||
# Template only. Deployment Agent must substitute non-secret, verified values.
|
||||
# /etc/systemd/system/aios-gitops-model-api.service.d/45-owner-authorization-broker.conf
|
||||
[Service]
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED=true
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT=/var/lib/aios-gitops/owner-authorization-broker
|
||||
|
||||
# Select exactly one scope already present on the intended non-wildcard Owner
|
||||
# credential. This is only an authentication intake scope; it is not copied
|
||||
# into the generated task-writer grant.
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES=commit:create
|
||||
|
||||
# Required explicit Owner account allowlist. Replace with the verified account
|
||||
# from the existing token registry; never put the bearer token here.
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES=REPLACE_WITH_OWNER_ACCOUNT
|
||||
|
||||
# If handler.need() does not return non-secret scope metadata, configure the
|
||||
# exact non-secret credential ID instead. Leave empty when scope readback is
|
||||
# available. Never put a token or token digest in this value.
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS=
|
||||
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS=900
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
Environment=AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
Environment=AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
Environment=AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
Environment=AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
Environment=AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
|
||||
# Replace the current Python entrypoint only after staging acceptance.
|
||||
# This wrapper preserves existing routes and both bridge endpoints.
|
||||
# ExecStart=
|
||||
# ExecStart=/usr/bin/python3 /opt/aios/active-runtime/app/aios_owner_authorization_broker_api.py
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=AIOS temporary governed local writer
|
||||
Requires=aios-governed-local-writer.socket
|
||||
After=aios-governed-local-writer.socket
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=true
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios-gitops/active-runtime/app/aios_governed_local_writer_service.py
|
||||
Restart=no
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=no
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=AIOS governed local writer Unix socket
|
||||
|
||||
[Socket]
|
||||
ListenStream=/run/aios-gitops/governed-local-writer.sock
|
||||
SocketUser=root
|
||||
SocketGroup=root
|
||||
SocketMode=0600
|
||||
DirectoryMode=0700
|
||||
RemoveOnStop=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=sockets.target
|
||||
@@ -0,0 +1,59 @@
|
||||
# Staging-only task identity overlay for V2R1C6 bootstrap.
|
||||
# Install as /etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
# after substituting the verified staging path.
|
||||
[Unit]
|
||||
Description=AIOS V2R1C6 task-scoped identity overlay
|
||||
After=network.target
|
||||
ConditionPathIsDirectory=/data/aios-gitops/repo.git
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=REPLACE_WITH_ROOT_OWNED_STAGING_DIR
|
||||
Environment=PYTHONPATH=REPLACE_WITH_ROOT_OWNED_STAGING_DIR/active-runtime/app
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_ENABLED=true
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_PORT=19864
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT=/var/lib/aios-gitops/task-identity-overlays
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT=/run/aios-gitops/task-identity-overlays
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID=V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY=aiman
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF=refs/heads/main
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID=875d41c3101093149414292067fd700dc214d4d3
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH=agent/v2r1c6-mainline-reconciliation-20260805
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_MAX_TTL_SECONDS=900
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_REQUIRE_ROOT=true
|
||||
# Agent must enumerate every active bearer secret reference discovered on-host.
|
||||
Environment=AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS=REPLACE_WITH_COMMA_SEPARATED_ACTIVE_SECRET_REFS
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT=/var/lib/aios-gitops/owner-authorization-broker
|
||||
Environment=AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
Environment=AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
Environment=AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
Environment=AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
Environment=AIOS_GITOPS_GOVERNED_USE_SUDO=false
|
||||
ExecStart=/usr/bin/python3 REPLACE_WITH_ROOT_OWNED_STAGING_DIR/active-runtime/app/aios_task_identity_overlay_api.py
|
||||
Restart=no
|
||||
RuntimeDirectory=aios-gitops/task-identity-overlays
|
||||
RuntimeDirectoryMode=0700
|
||||
StateDirectory=aios-gitops/task-identity-overlays
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"action": "AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"base_ref": "refs/heads/main",
|
||||
"baseline_change_authorized": false,
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
],
|
||||
"expires_in_seconds": 900,
|
||||
"main_write_authorized": false,
|
||||
"one_time_use": true,
|
||||
"production_change_authorized": false,
|
||||
"repository": "aios/aios",
|
||||
"tag_change_authorized": false,
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING"
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
# AIOS V2R1C4 Dormant Shadow-Only Release Execution
|
||||
|
||||
## Purpose
|
||||
|
||||
Deploy verified V2R1C4 code while keeping autonomous execution capabilities disabled.
|
||||
|
||||
This release separates code deployment from capability activation. Capability activation requires a later, independently approved release.
|
||||
|
||||
## Deployment mode
|
||||
|
||||
```text
|
||||
deployment_mode=dormant_shadow_only
|
||||
```
|
||||
|
||||
Required production flags:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
Required dispatch transport:
|
||||
|
||||
```text
|
||||
protectedDispatchTransport=sandbox
|
||||
```
|
||||
|
||||
Disabled capabilities:
|
||||
|
||||
```text
|
||||
native_openclaw_enabled=false
|
||||
native_codex_enabled=false
|
||||
automatic_release_enabled=false
|
||||
production_side_effects=false
|
||||
activation_deferred=true
|
||||
```
|
||||
|
||||
## Release flow
|
||||
|
||||
```text
|
||||
Integration verification
|
||||
|
|
||||
v
|
||||
Owner dormant release approval
|
||||
|
|
||||
v
|
||||
Merge PR
|
||||
|
|
||||
v
|
||||
Read final main merge OID
|
||||
|
|
||||
v
|
||||
Rebuild production artifact
|
||||
|
|
||||
v
|
||||
Deploy dormant runtime
|
||||
|
|
||||
v
|
||||
Create V2R1C4 tag
|
||||
|
|
||||
v
|
||||
Verify Registry and Baseline consistency
|
||||
```
|
||||
|
||||
## Two-stage approval model
|
||||
|
||||
### Pre-merge approval
|
||||
|
||||
The pre-merge approval binds:
|
||||
|
||||
- version
|
||||
- PR number
|
||||
- integration OID and tree OID
|
||||
- candidate artifact and evidence hashes
|
||||
- deployment mode
|
||||
- all production execution flags remaining false
|
||||
|
||||
It authorizes the governed merge and production artifact rebuild. It does not predeclare the final production target OID.
|
||||
|
||||
### Final release approval
|
||||
|
||||
After the governed merge, the final approval binds:
|
||||
|
||||
- final main merge OID
|
||||
- final main tree OID
|
||||
- production artifact, manifest, package closure, and evidence closure
|
||||
- rollback artifact and production configuration hashes
|
||||
- V2R1C4 tag target
|
||||
|
||||
The integration OID must never be used as the final production target OID.
|
||||
|
||||
## Deferred activation
|
||||
|
||||
The following are separate activation gates and must not block dormant code deployment:
|
||||
|
||||
- OpenClaw native session provider
|
||||
- Codex app-server provider
|
||||
- Gitea Admin API automation
|
||||
- Secret Manager V2
|
||||
- autonomous production execution
|
||||
|
||||
They require a later activation release with separate evidence and approval.
|
||||
|
||||
## Hard restrictions
|
||||
|
||||
Never:
|
||||
|
||||
- write main directly
|
||||
- force push
|
||||
- rewrite tags
|
||||
- export secrets
|
||||
- enable autonomous production execution without approval
|
||||
- treat integration OID as production target OID
|
||||
- update Registry or Baseline before remote and Gitea tag verification
|
||||
|
||||
## Dormant production acceptance
|
||||
|
||||
A dormant deployment is acceptable only when:
|
||||
|
||||
```text
|
||||
deployment_mode=dormant_shadow_only
|
||||
production_side_effects=false
|
||||
protectedDispatchTransport=sandbox
|
||||
native_openclaw_enabled=false
|
||||
native_codex_enabled=false
|
||||
automatic_release_enabled=false
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
The release report must preserve deferred activation blockers rather than falsely marking native-agent or automatic-release capabilities as enabled.
|
||||
@@ -1,82 +1,47 @@
|
||||
# AIOS Autonomous Release Execution Package
|
||||
|
||||
## Release Strategy
|
||||
## Mode
|
||||
|
||||
V2R1C4 uses a two-stage model:
|
||||
Candidate/shadow first. Production changes require an independent release approval.
|
||||
|
||||
1. Dormant code deployment.
|
||||
2. Capability activation through a separate approved release.
|
||||
## Flow
|
||||
|
||||
## Dormant Deployment
|
||||
1. Create TaskPlan.
|
||||
2. Validate policy and approval envelope.
|
||||
3. Route workers by capability and trust zone.
|
||||
4. Execute on isolated candidate branch.
|
||||
5. Collect evidence chain and artifact manifest.
|
||||
6. Verify release plan.
|
||||
7. Wait for owner approval.
|
||||
8. Deploy only after approval.
|
||||
|
||||
The first production release must keep autonomous execution disabled:
|
||||
## Hard stops
|
||||
|
||||
```text
|
||||
- main writes
|
||||
- force push
|
||||
- tag rewrite
|
||||
- baseline mutation
|
||||
- registry mutation
|
||||
- plaintext credential handling
|
||||
- missing evidence closure
|
||||
|
||||
## Required evidence
|
||||
|
||||
- git ref closure
|
||||
- test report
|
||||
- artifact checksum
|
||||
- manifest checksum
|
||||
- credential revoke proof
|
||||
- production invariant snapshot
|
||||
|
||||
## Production activation
|
||||
|
||||
The executor must keep:
|
||||
|
||||
```
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
Required mode:
|
||||
|
||||
```text
|
||||
deployment_mode=dormant_shadow_only
|
||||
```
|
||||
|
||||
Required properties:
|
||||
|
||||
```text
|
||||
production_side_effects=false
|
||||
native_openclaw_enabled=false
|
||||
native_codex_enabled=false
|
||||
automatic_release_enabled=false
|
||||
```
|
||||
|
||||
## Deployment Sequence
|
||||
|
||||
```text
|
||||
Verify Integration
|
||||
|
|
||||
v
|
||||
Owner Dormant Release Approval
|
||||
|
|
||||
v
|
||||
Merge Pull Request
|
||||
|
|
||||
v
|
||||
Read final main merge OID
|
||||
|
|
||||
v
|
||||
Build Production Artifact
|
||||
|
|
||||
v
|
||||
Deploy Dormant Runtime
|
||||
|
|
||||
v
|
||||
Create Version Tag
|
||||
|
|
||||
v
|
||||
Verify Registry and Baseline
|
||||
```
|
||||
|
||||
## Capability Activation (Separate)
|
||||
|
||||
The following require separate approval and evidence:
|
||||
|
||||
- OpenClaw native provider
|
||||
- Codex app-server provider
|
||||
- Gitea Admin API lifecycle
|
||||
- Secret Manager V2
|
||||
- Autonomous production execution
|
||||
|
||||
## Hard Governance Rules
|
||||
|
||||
Never:
|
||||
|
||||
- direct write main
|
||||
- force push
|
||||
- rewrite tags
|
||||
- export credentials
|
||||
- enable autonomous production execution without approval
|
||||
- use integration OID as production target OID
|
||||
- update Baseline before remote tag verification
|
||||
until release approval is independently verified.
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
# AIOS V2R1C4 Dormant Release — Final Agent Execution Contract
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document is the machine/operator execution contract for completing the governed V2R1C4 dormant release after Candidate development is frozen.
|
||||
|
||||
It does not grant production authority. Every consequential action remains subject to the exact approval, Git reference, artifact, tag, Registry and Baseline gates defined below.
|
||||
|
||||
## 2. Release mode
|
||||
|
||||
The only permitted deployment mode for V2R1C4 is:
|
||||
|
||||
```text
|
||||
deployment_mode=dormant_shadow_only
|
||||
```
|
||||
|
||||
The following values are mandatory before, during and after deployment:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
AIOS_PRODUCTION_DEPLOYMENT_ENABLED=false
|
||||
production_side_effects=false
|
||||
native_openclaw_enabled=false
|
||||
native_codex_enabled=false
|
||||
automatic_release_enabled=false
|
||||
activation_deferred=true
|
||||
protectedDispatchTransport=sandbox
|
||||
```
|
||||
|
||||
Any deviation is a hard stop.
|
||||
|
||||
## 3. Authoritative branches
|
||||
|
||||
```text
|
||||
candidate_branch=patch/chatgpt-autonomous-completion-contract-20260726
|
||||
integration_branch=integration/chatgpt-autonomous-completion-20260726
|
||||
production_branch=main
|
||||
version=V2R1C4
|
||||
pull_request=20
|
||||
```
|
||||
|
||||
Never infer a target OID from workspace HEAD. Always read and record full 40-character OIDs.
|
||||
|
||||
## 4. Phase A — Candidate freeze and verification
|
||||
|
||||
1. Read the current Candidate OID and tree OID.
|
||||
2. Verify the Candidate worktree is clean.
|
||||
3. Verify four-way Candidate reference closure:
|
||||
- local branch ref;
|
||||
- authoritative bare ref;
|
||||
- `git ls-remote` ref;
|
||||
- Gitea API branch ref.
|
||||
4. Run the complete test and static validation suite defined in `V2R1C4_TEST_AND_RELEASE_CHECKLIST.md`.
|
||||
5. Build the deterministic Candidate artifact and manifest from the exact Candidate OID.
|
||||
6. Record:
|
||||
- artifact SHA-256;
|
||||
- manifest SHA-256;
|
||||
- package closure SHA-256;
|
||||
- test evidence SHA-256;
|
||||
- rollback artifact SHA-256;
|
||||
- production configuration SHA-256.
|
||||
7. Do not reuse any digest generated from an older Candidate OID.
|
||||
|
||||
Failure state:
|
||||
|
||||
```text
|
||||
CANDIDATE_ACCEPTANCE_BLOCKED
|
||||
```
|
||||
|
||||
## 5. Phase B — Integration refresh
|
||||
|
||||
1. Refresh the Integration branch from the latest Candidate using a governed non-fast-forward merge.
|
||||
2. Use:
|
||||
|
||||
```text
|
||||
merge --no-ff
|
||||
```
|
||||
|
||||
3. Do not squash, rebase, force push or rewrite Candidate history.
|
||||
4. Verify the Candidate OID is reachable from the new Integration OID.
|
||||
5. Verify four-way Integration reference closure.
|
||||
6. Run the full Candidate test suite again from the Integration worktree.
|
||||
7. Rebuild Integration evidence and Candidate package metadata if any tracked file changed during the merge.
|
||||
|
||||
Failure state:
|
||||
|
||||
```text
|
||||
INTEGRATION_ACCEPTANCE_BLOCKED
|
||||
```
|
||||
|
||||
## 6. Phase C — Candidate acceptance
|
||||
|
||||
Create Candidate acceptance evidence and execute:
|
||||
|
||||
```text
|
||||
python scripts/autonomous-release/run_acceptance.py \
|
||||
--phase candidate \
|
||||
--input <candidate-evidence.json> \
|
||||
--output <candidate-acceptance.json>
|
||||
```
|
||||
|
||||
The result must contain:
|
||||
|
||||
```text
|
||||
phase=candidate
|
||||
status=READY
|
||||
production_action_authorized=false
|
||||
```
|
||||
|
||||
The Candidate gate must verify:
|
||||
|
||||
```text
|
||||
candidate_four_way_ref_closure=true
|
||||
integration_four_way_ref_closure=true
|
||||
candidate_reachable_from_integration=true
|
||||
worktree_clean=true
|
||||
tests_failed=0
|
||||
tests_passed>0
|
||||
```
|
||||
|
||||
It must also verify all dormant runtime flags remain disabled.
|
||||
|
||||
`READY` authorizes review only. It does not authorize merge, deployment, tagging, Registry writes or Baseline writes.
|
||||
|
||||
## 7. Phase D — Pre-merge owner approval
|
||||
|
||||
Obtain a governed Owner approval plan for the exact Integration package.
|
||||
|
||||
The approval subject must bind at least:
|
||||
|
||||
```text
|
||||
version
|
||||
pull_request
|
||||
candidate_oid
|
||||
candidate_tree_oid
|
||||
integration_oid
|
||||
integration_tree_oid
|
||||
candidate_acceptance_digest
|
||||
candidate_release_digest
|
||||
artifact_sha256
|
||||
manifest_sha256
|
||||
package_closure_sha256
|
||||
evidence_closure_oid
|
||||
rollback_artifact_sha256
|
||||
production_config_sha256
|
||||
deployment_mode=dormant_shadow_only
|
||||
all execution flags=false
|
||||
```
|
||||
|
||||
The approval must not predeclare the final production target OID.
|
||||
|
||||
Without an Owner-approved plan, stop with:
|
||||
|
||||
```text
|
||||
BLOCKED_EXTERNAL_OWNER_SIGNATURE
|
||||
```
|
||||
|
||||
## 8. Phase E — Governed merge
|
||||
|
||||
1. Merge PR #20 only through the governed merge path.
|
||||
2. Do not push directly to `main`.
|
||||
3. After merge, read the exact resulting `main` merge OID and tree OID.
|
||||
4. Verify four-way `main` reference closure.
|
||||
5. Verify the merged Integration OID is reachable from the final `main` merge OID.
|
||||
6. Never use the Integration OID as the production target OID.
|
||||
|
||||
Failure state:
|
||||
|
||||
```text
|
||||
MAIN_RECONCILIATION_REQUIRED
|
||||
```
|
||||
|
||||
## 9. Phase F — Production artifact rebuild
|
||||
|
||||
Rebuild all production artifacts from the exact final `main` merge OID.
|
||||
|
||||
Record new values for:
|
||||
|
||||
```text
|
||||
artifact_sha256
|
||||
manifest_sha256
|
||||
package_closure_sha256
|
||||
evidence_closure_oid
|
||||
rollback_artifact_sha256
|
||||
production_config_sha256
|
||||
```
|
||||
|
||||
Candidate or Integration artifacts may not be promoted by renaming or metadata substitution. The final artifact must be demonstrably rebuilt from the final production target.
|
||||
|
||||
## 10. Phase G — Final release digest and Owner approval
|
||||
|
||||
1. Construct the `FinalDormantRelease` object using the final `main` target and rebuilt artifact evidence.
|
||||
2. Compute its exact `release_digest`.
|
||||
3. Obtain an Owner approval receipt bound to that exact digest.
|
||||
4. Record:
|
||||
|
||||
```text
|
||||
approval_plan_id
|
||||
approval_receipt_sha256
|
||||
approved_release_digest
|
||||
owner_approval_verified=true
|
||||
```
|
||||
|
||||
Required equality:
|
||||
|
||||
```text
|
||||
approved_release_digest == FinalDormantRelease.digest()
|
||||
```
|
||||
|
||||
A generic approval, previous approval, Candidate approval or Integration approval is insufficient.
|
||||
|
||||
## 11. Phase H — Final acceptance
|
||||
|
||||
Execute:
|
||||
|
||||
```text
|
||||
python scripts/autonomous-release/run_acceptance.py \
|
||||
--phase final \
|
||||
--input <final-evidence.json> \
|
||||
--output <final-acceptance.json>
|
||||
```
|
||||
|
||||
The result must contain:
|
||||
|
||||
```text
|
||||
phase=final
|
||||
status=READY
|
||||
production_action_authorized=false
|
||||
```
|
||||
|
||||
Required final evidence:
|
||||
|
||||
```text
|
||||
main_four_way_ref_closure=true
|
||||
target_is_main_head=true
|
||||
worktree_clean=true
|
||||
artifact_rebuilt_from_target=true
|
||||
rollback_verified=true
|
||||
runtime_boundary_verified=true
|
||||
owner_approval_verified=true
|
||||
approved_release_digest=<exact final release digest>
|
||||
```
|
||||
|
||||
The acceptance report is evidence, not an execution credential.
|
||||
|
||||
## 12. Phase I — Dormant deployment
|
||||
|
||||
Deployment requires a separate production execution approval referencing:
|
||||
|
||||
```text
|
||||
version=V2R1C4
|
||||
target_oid=<final main merge OID>
|
||||
evidence_closure_oid=<final evidence closure OID>
|
||||
approval_plan_id=<approved plan>
|
||||
final_acceptance_digest=<final acceptance digest>
|
||||
```
|
||||
|
||||
Deploy only the dormant runtime. Before starting services, verify all disabled flags and the sandbox transport in the rendered production configuration.
|
||||
|
||||
After start, verify:
|
||||
|
||||
```text
|
||||
services_active=true
|
||||
loopback_bindings_only=true
|
||||
production_side_effects=false
|
||||
write_endpoints_disabled=true
|
||||
task_plane_execution_disabled=true
|
||||
silent_release_execution_disabled=true
|
||||
native_agent_execution_disabled=true
|
||||
```
|
||||
|
||||
If any side-effect-capable path is active, stop services and mark:
|
||||
|
||||
```text
|
||||
DORMANT_RUNTIME_BOUNDARY_VIOLATION
|
||||
```
|
||||
|
||||
## 13. Phase J — Tag governance
|
||||
|
||||
Tagging is permitted only after dormant deployment verification and exact Owner approval.
|
||||
|
||||
Every tag request must carry:
|
||||
|
||||
```text
|
||||
version=V2R1C4
|
||||
target_oid=<full final main merge OID>
|
||||
evidence_closure_oid=<full final evidence closure OID>
|
||||
approval_plan_id=<approved plan ID>
|
||||
```
|
||||
|
||||
When an existing same-name tag is found:
|
||||
|
||||
- identical tag OID and peeled target: treat as idempotent and reverify;
|
||||
- different tag OID or peeled target: stop with `MANUAL_OWNER_REVIEW_REQUIRED`.
|
||||
|
||||
Never force push, delete, move or rewrite a formal tag.
|
||||
|
||||
## 14. Phase K — Remote and Gitea tag acceptance
|
||||
|
||||
Before any Registry or Baseline update, verify:
|
||||
|
||||
```text
|
||||
local_tag_exists=true
|
||||
remote_tag_exists=true
|
||||
gitea_api_tag_exists=true
|
||||
local_tag_oid == remote_tag_oid
|
||||
remote_peeled_target_oid == target_oid
|
||||
gitea_peeled_target_oid == target_oid
|
||||
repository_identity=aios/aios
|
||||
```
|
||||
|
||||
For annotated tags, verify both the tag object and peeled commit.
|
||||
|
||||
Failure state:
|
||||
|
||||
```text
|
||||
REMOTE_PUSH_PENDING
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
RECONCILIATION_REQUIRED
|
||||
```
|
||||
|
||||
## 15. Phase L — Registry and Baseline
|
||||
|
||||
Only after remote and Gitea tag acceptance:
|
||||
|
||||
1. register or update the V2R1C4 Registry entry;
|
||||
2. reread the Registry and verify the exact target OID;
|
||||
3. update the Production Baseline to V2R1C4;
|
||||
4. reread the Baseline;
|
||||
5. run final release consistency acceptance.
|
||||
|
||||
Required equality:
|
||||
|
||||
```text
|
||||
Registry version
|
||||
= Production Baseline
|
||||
= local refs/tags/V2R1C4
|
||||
= remote refs/tags/V2R1C4
|
||||
= Gitea API tag
|
||||
= expected final main target
|
||||
```
|
||||
|
||||
Do not write `PRODUCTION_DEPLOYED` before this equality is proven.
|
||||
|
||||
## 16. Rollback
|
||||
|
||||
If deployment verification fails:
|
||||
|
||||
1. disable the V2R1C4 Candidate services;
|
||||
2. preserve evidence and logs;
|
||||
3. submit a governed rollback request with the target version and reason;
|
||||
4. distinguish `requested`, `approved`, `executed` and `verified` states.
|
||||
|
||||
Do not delete tags, rewrite history or independently perform a production rollback.
|
||||
|
||||
## 17. Mandatory stop conditions
|
||||
|
||||
Stop without consequential execution when any of the following is missing or false:
|
||||
|
||||
```text
|
||||
four_way_ref_closure
|
||||
clean_worktree
|
||||
full_test_closure
|
||||
artifact_digest_verification
|
||||
rollback_evidence
|
||||
runtime_boundary_verification
|
||||
exact_owner_approval
|
||||
exact_target_oid
|
||||
exact_evidence_closure_oid
|
||||
exact_approval_plan_id
|
||||
remote_tag_verification
|
||||
gitea_tag_verification
|
||||
```
|
||||
|
||||
## 18. Final report schema
|
||||
|
||||
Return:
|
||||
|
||||
```text
|
||||
execution_status=COMPLETED|PARTIALLY_COMPLETED|BLOCKED|FAILED
|
||||
candidate_oid=<full OID>
|
||||
integration_oid=<full OID>
|
||||
main_target_oid=<full OID>
|
||||
artifact_sha256=<digest>
|
||||
manifest_sha256=<digest>
|
||||
package_closure_sha256=<digest>
|
||||
evidence_closure_oid=<full OID>
|
||||
candidate_acceptance_status=<status>
|
||||
final_acceptance_status=<status>
|
||||
owner_approval_verified=<bool>
|
||||
dormant_deployment_verified=<bool>
|
||||
local_tag_verified=<bool>
|
||||
remote_tag_verified=<bool>
|
||||
gitea_tag_verified=<bool>
|
||||
registry_verified=<bool>
|
||||
baseline_verified=<bool>
|
||||
release_consistency=<status>
|
||||
```
|
||||
|
||||
Always include:
|
||||
|
||||
```text
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -1,30 +0,0 @@
|
||||
# AIOS V2R1C4 Dormant Release Agent Execution
|
||||
|
||||
## Scope
|
||||
|
||||
This package is a dormant, shadow-only Candidate release. It does not authorize a production deployment, tag, baseline update, registry update, or a write to main.
|
||||
|
||||
## Required runtime boundary
|
||||
|
||||
deployment_mode=dormant_shadow_only
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
production_side_effects=false
|
||||
|
||||
## Governed sequence
|
||||
|
||||
1. Verify the Integration branch with the Candidate test suite and four-way Git reference closure.
|
||||
2. Obtain Owner approval for dormant review only.
|
||||
3. Merge only after a separate production release approval; this document does not grant that approval.
|
||||
4. Read the resulting main merge OID before building an artifact.
|
||||
5. Deploy only the dormant Candidate runtime after explicit production approval.
|
||||
6. Create a tag and update Registry or Baseline only under their separate governed approvals.
|
||||
|
||||
## Evidence requirements
|
||||
|
||||
The execution agent must retain task ID, commit OID, artifact and manifest digests, test evidence, rollback package reference, and approval receipt metadata. It must not include plaintext credentials or private keys.
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Stop with NOT_EXECUTED if any approval, four-way reference closure, artifact verification, or deployment gate is absent. Do not infer production authorization from Candidate authorization.
|
||||
@@ -0,0 +1,29 @@
|
||||
# AIOS autonomy v1 and V2R1C6 governance components
|
||||
|
||||
This branch adds bounded control-plane primitives used by the V2R1C6 release
|
||||
workflow. The components are deliberately transport-neutral; deployment
|
||||
binds them to the existing task plane and governed local writer.
|
||||
|
||||
- `agent_pull_runner.py`: worker registration, heartbeat, long-poll with a
|
||||
60-second fallback, jittered bounded backoff, claim/renew/report and restart
|
||||
recovery with duplicate suppression.
|
||||
- `autonomy_notification_plane.py`: SQLite durable outbox for Owner and GPT,
|
||||
delivery retry and acknowledgement. Notification failure never rolls back
|
||||
task execution.
|
||||
- `recurring_task_scheduler.py`: interval scheduling with `next_run_at`, run
|
||||
bounds, cancellation and idempotent successor creation.
|
||||
- `release_state_machine.py`: ordered release states with fail-closed
|
||||
production acceptance evidence.
|
||||
- `reconciliation_controller.py`: read-only drift detection for refs, tags,
|
||||
targets, registry/Baseline, artifact digest and service health.
|
||||
- `governed_merge_gate.py`: supplements native Gitea protection when the
|
||||
installed API cannot express Owner approval identity or conversation
|
||||
resolution. It never writes refs and never authorizes force-push.
|
||||
|
||||
No component grants main, tag, Baseline, force-push, arbitrary-shell or
|
||||
production-deploy capability.
|
||||
|
||||
The task writer persists request identity, idempotency keys, final commit OIDs,
|
||||
parent OIDs and sanitized audit digests. A caller queries the receipt before
|
||||
recovering an ambiguous request, and every branch update remains a
|
||||
fast-forward CAS append.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Owner Authorization → Task Writer Provisioning Bridge
|
||||
|
||||
## Purpose
|
||||
|
||||
This change closes the control-plane gap where an authenticated Owner can
|
||||
approve a task in the API, but the runtime cannot automatically produce the
|
||||
machine-verifiable, signed, one-time grant required by
|
||||
`aios-task-writer-provisioner`.
|
||||
|
||||
The bridge adds one high-risk endpoint:
|
||||
|
||||
`POST /api/task-writer/provision-authorized`
|
||||
|
||||
It does not add a general branch endpoint to the hardened writer. Branch and
|
||||
worktree creation remain the responsibility of the existing root-governed
|
||||
one-shot provisioner. Source commits continue through the isolated writer's
|
||||
existing `POST /api/commit-file` route.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
The API must authenticate the caller as `role=owner` and require the
|
||||
`writer:provision` scope. The request contains an auditable Owner authorization
|
||||
reference; it never contains a private key, token or signature.
|
||||
|
||||
The bridge:
|
||||
|
||||
1. Validates an exact repository, base ref, base OID and target branch.
|
||||
2. Rejects all production, tag, baseline and direct-main capabilities.
|
||||
3. Generates a canonical approval plan and short-lived grant request.
|
||||
4. Calls the root-owned signer adapter.
|
||||
5. Calls the independent grant validator.
|
||||
6. Calls the one-shot task writer provisioner.
|
||||
7. Verifies the resulting branch, writer scope and negative policy tests.
|
||||
8. Stores a redacted, fsynced idempotency receipt.
|
||||
|
||||
The bridge never executes `git update-ref`, `git branch`, `git push`, a tag
|
||||
operation, a baseline mutation or a deployment command directly.
|
||||
|
||||
## Required runtime configuration
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
AIOS_GITOPS_OWNER_PROVISION_SCOPE=writer:provision
|
||||
AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
```
|
||||
|
||||
When the API service is non-root, `AIOS_GITOPS_GOVERNED_USE_SUDO=true`
|
||||
uses the fixed `/usr/bin/sudo -n -- <governed executable>` form. Sudoers must
|
||||
allow only the three exact executables and must not allow a shell, arbitrary
|
||||
environment preservation or arbitrary command paths.
|
||||
|
||||
The three governed executables must be absolute, regular, executable,
|
||||
non-symlink files and must not be group/world writable. Each must expose:
|
||||
|
||||
```text
|
||||
--help
|
||||
--self-test
|
||||
--grant-dir
|
||||
--result-json
|
||||
```
|
||||
|
||||
The signer result must bind `approval_plan_id`, `grant_id`,
|
||||
`grant_digest_sha256`, `signing_key_identity` and proof that a signature is
|
||||
present. The validator must independently attest the exact repository,
|
||||
base/target bindings, signature, lifetime, operation lists and one-time-use
|
||||
property. The provisioner must return `READY_FOR_CODE_AUTHORING`, consume the
|
||||
grant, create the exact branch at the exact base OID, start a writer bound only
|
||||
to `POST /api/commit-file`, and report successful negative policy tests.
|
||||
|
||||
## Deployment model
|
||||
|
||||
Run `aios_authorized_task_writer_api.py` as the ChatGPT Actions API entrypoint.
|
||||
It delegates all existing routes to `aios_chatgpt_actions_api` and intercepts
|
||||
only the new provisioning endpoint.
|
||||
|
||||
Do not enable this endpoint until:
|
||||
|
||||
- the Owner scope is present in the API authorization registry;
|
||||
- the signer adapter uses a runtime secret reference;
|
||||
- the validator and provisioner pass their self-tests;
|
||||
- the state directory is root-owned and mode `0700`;
|
||||
- the negative tests show main, other branches, tags, baseline, force push and
|
||||
branch deletion are denied.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Every mismatch fails closed. A changed `main` OID, existing conflicting branch,
|
||||
expired or untrusted signature, missing negative test, writer scope expansion,
|
||||
or unconsumed grant returns an error and leaves production state untouched.
|
||||
|
||||
Idempotent retries return the existing redacted receipt. A retry with a
|
||||
different task, repository, branch, base or Owner authorization reference is
|
||||
rejected.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Branch-bound repair lease
|
||||
|
||||
A repair lease is a short-lived, append-only capability bound to one branch,
|
||||
one Owner identity, and one path allowlist. The default maximum lifetime is
|
||||
24 hours. State is persisted atomically under a root-only `0700` directory so
|
||||
the service can restart and recover without recreating authorization.
|
||||
|
||||
Supported operations are `issue`, `attach_existing_branch`,
|
||||
`authorize_commit`, `record_commit`, `recover`, `renew`, `complete`, and
|
||||
`revoke`. Every commit is checked for the exact expected parent, fast-forward
|
||||
lineage, allowed paths, protected-path exclusion, secret-like content, and
|
||||
current branch head. A stale head, unrelated path, protected path, identity
|
||||
mismatch, expired/revoked lease, non-fast-forward movement, or failed Git
|
||||
probe fails closed.
|
||||
|
||||
The lease layer is a policy and evidence layer. It does not replace the
|
||||
existing GitOps writer and it never writes Git refs directly.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Governed local writer
|
||||
|
||||
`governed-local-writer` is a temporary bootstrap seam for a task-scoped
|
||||
recovery when the normal bearer credential is intentionally unavailable.
|
||||
|
||||
The service listens only on `/run/aios-gitops/governed-local-writer.sock`,
|
||||
owned by `root:root` with mode `0600`. It validates Linux `SO_PEERCRED` and
|
||||
accepts only UID 0. Authorization is a root-owned mode `0600` JSON file with a
|
||||
canonical SHA-256 digest, a short expiry, exact branch/OID/path policy, and
|
||||
one-shot state. Audit records contain only OIDs, task metadata, peer identity,
|
||||
operation, result, and timestamps; they never contain request content or
|
||||
credentials.
|
||||
|
||||
The adapter delegates checkout, secret scanning, commit, and fast-forward
|
||||
synchronization to the existing GitOps handler implementation. It does not
|
||||
invoke `git update-ref`, direct main writes, force pushes, tag writes, Baseline
|
||||
writes, or production deployment.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Merge this fragment into docs/gitops/openapi-chatgpt-actions.yaml.
|
||||
components:
|
||||
schemas:
|
||||
AuthorizedTaskWriterProvisionRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- task_id
|
||||
- repository
|
||||
- base_ref
|
||||
- base_oid
|
||||
- target_branch
|
||||
- owner_authorization_reference
|
||||
- one_time_use
|
||||
- expires_in_seconds
|
||||
- production_change_authorized
|
||||
- tag_change_authorized
|
||||
- baseline_change_authorized
|
||||
- main_write_authorized
|
||||
- allowed_operations
|
||||
- denied_operations
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9][A-Z0-9._:-]{7,127}$'
|
||||
repository:
|
||||
type: string
|
||||
const: aios/aios
|
||||
base_ref:
|
||||
type: string
|
||||
const: refs/heads/main
|
||||
base_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{40}$'
|
||||
target_branch:
|
||||
type: string
|
||||
pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'
|
||||
owner_authorization_reference:
|
||||
type: string
|
||||
minLength: 4
|
||||
maxLength: 256
|
||||
one_time_use:
|
||||
type: boolean
|
||||
const: true
|
||||
expires_in_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 1800
|
||||
production_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
tag_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
baseline_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
main_write_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
allowed_operations:
|
||||
type: array
|
||||
minItems: 4
|
||||
maxItems: 4
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- create_worktree
|
||||
- create_target_branch_if_absent
|
||||
- commit_to_target_branch
|
||||
- push_target_branch
|
||||
denied_operations:
|
||||
type: array
|
||||
minItems: 11
|
||||
maxItems: 11
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- direct_main_push
|
||||
- force_push
|
||||
- tag_create
|
||||
- tag_update
|
||||
- tag_delete
|
||||
- branch_delete
|
||||
- repository_admin
|
||||
- baseline_write
|
||||
- production_deploy
|
||||
- production_marker_write
|
||||
- history_rewrite
|
||||
|
||||
paths:
|
||||
/api/task-writer/provision-authorized:
|
||||
post:
|
||||
operationId: provisionAuthorizedTaskWriter
|
||||
summary: Convert authenticated Owner authorization into a signed one-time task writer grant
|
||||
description: >
|
||||
Generates an approval plan, invokes the root-governed signer and validator,
|
||||
consumes a one-time provisioning grant, and returns a scoped writer
|
||||
attestation. It never authorizes main, tag, baseline, or production changes.
|
||||
x-openai-isConsequential: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthorizedTaskWriterProvisionRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: READY_FOR_CODE_AUTHORING or idempotent existing attestation
|
||||
'400':
|
||||
description: Invalid or over-broad request
|
||||
'403':
|
||||
description: Authenticated Owner or exact scope required
|
||||
'409':
|
||||
description: Base drift, branch conflict, or idempotency scope conflict
|
||||
'503':
|
||||
description: Signer, validator, provisioner, or attestation unavailable
|
||||
@@ -0,0 +1,41 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: AIOS Branch Repair Lease Fragment
|
||||
version: v1
|
||||
paths:
|
||||
/api/governance/branch-repair-leases:
|
||||
post:
|
||||
summary: Issue or operate one branch-bound repair lease
|
||||
description: >-
|
||||
The existing authenticated control plane transports this operation.
|
||||
Git ref mutation is delegated to the protected writer handler.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [operation, owner_identity]
|
||||
properties:
|
||||
operation:
|
||||
type: string
|
||||
enum: [issue, attach_existing_branch, authorize_commit, record_commit, recover, renew, complete, revoke]
|
||||
owner_identity:
|
||||
type: string
|
||||
lease_id:
|
||||
type: string
|
||||
branch:
|
||||
type: string
|
||||
expected_head_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{40}$'
|
||||
paths:
|
||||
type: array
|
||||
items: {type: string}
|
||||
responses:
|
||||
'200':
|
||||
description: Sanitized lease metadata
|
||||
'403':
|
||||
description: Lease policy rejected
|
||||
'409':
|
||||
description: Stale or concurrently advanced branch
|
||||
@@ -0,0 +1,127 @@
|
||||
# Merge this fragment after openapi-authorized-task-writer-fragment.yaml.
|
||||
components:
|
||||
schemas:
|
||||
OwnerAuthorizationTaskWriterRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- action
|
||||
- task_id
|
||||
- repository
|
||||
- base_ref
|
||||
- base_oid
|
||||
- target_branch
|
||||
- one_time_use
|
||||
- expires_in_seconds
|
||||
- production_change_authorized
|
||||
- tag_change_authorized
|
||||
- baseline_change_authorized
|
||||
- main_write_authorized
|
||||
- allowed_operations
|
||||
- denied_operations
|
||||
properties:
|
||||
action:
|
||||
type: string
|
||||
const: AUTHORIZE_TASK_WRITER_PROVISIONING
|
||||
task_id:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9][A-Z0-9._:-]{7,127}$'
|
||||
repository:
|
||||
type: string
|
||||
const: aios/aios
|
||||
base_ref:
|
||||
type: string
|
||||
const: refs/heads/main
|
||||
base_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{40}$'
|
||||
target_branch:
|
||||
type: string
|
||||
pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'
|
||||
one_time_use:
|
||||
type: boolean
|
||||
const: true
|
||||
expires_in_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 900
|
||||
production_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
tag_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
baseline_change_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
main_write_authorized:
|
||||
type: boolean
|
||||
const: false
|
||||
allowed_operations:
|
||||
type: array
|
||||
minItems: 4
|
||||
maxItems: 4
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- create_worktree
|
||||
- create_target_branch_if_absent
|
||||
- commit_to_target_branch
|
||||
- push_target_branch
|
||||
denied_operations:
|
||||
type: array
|
||||
minItems: 11
|
||||
maxItems: 11
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- direct_main_push
|
||||
- force_push
|
||||
- tag_create
|
||||
- tag_update
|
||||
- tag_delete
|
||||
- branch_delete
|
||||
- repository_admin
|
||||
- baseline_write
|
||||
- production_deploy
|
||||
- production_marker_write
|
||||
- history_rewrite
|
||||
|
||||
paths:
|
||||
/api/governance/owner-authorizations:
|
||||
post:
|
||||
operationId: authorizeAndProvisionTaskWriter
|
||||
summary: Record an audited Owner authorization and provision a scoped writer
|
||||
description: >
|
||||
Authenticates an existing non-wildcard, allow-listed Owner credential
|
||||
through a pre-existing exact scope. It does not mint or modify bearer
|
||||
credentials. It records a task-bound authorization event and delegates
|
||||
grant signing, validation and one-shot provisioning to the existing
|
||||
governed bridge. Main, tag, Baseline and production capabilities are
|
||||
always denied.
|
||||
x-openai-isConsequential: true
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OwnerAuthorizationTaskWriterRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: READY_FOR_CODE_AUTHORING and audited authorization receipt
|
||||
'400':
|
||||
description: Invalid, incomplete or over-broad request
|
||||
'401':
|
||||
description: Existing Owner credential is missing or invalid
|
||||
'403':
|
||||
description: Owner, identity, exact scope or credential proof rejected
|
||||
'409':
|
||||
description: Main drift, branch conflict or provisioning conflict
|
||||
'500':
|
||||
description: Receipt safety or broker internal failure
|
||||
'503':
|
||||
description: Broker disabled or governance tool unavailable
|
||||
@@ -0,0 +1,118 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS Task Identity Overlay
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/healthz:
|
||||
get:
|
||||
operationId: getTaskIdentityOverlayHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Overlay runtime status
|
||||
/api/governance/owner-authorizations:
|
||||
post:
|
||||
operationId: provisionAuthorizedWriterThroughTaskIdentityOverlay
|
||||
security:
|
||||
- taskIdentityOverlayBearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TaskWriterAuthorizationRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Writer is ready for code authoring
|
||||
"400":
|
||||
description: Invalid or over-broad request
|
||||
"401":
|
||||
description: Overlay credential absent, invalid, expired or consumed
|
||||
"403":
|
||||
description: Production capability or policy violation
|
||||
"409":
|
||||
description: Binding, identity, base or branch conflict
|
||||
"500":
|
||||
description: Fail-closed internal error
|
||||
components:
|
||||
securitySchemes:
|
||||
taskIdentityOverlayBearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: >-
|
||||
Root-issued, one-time, task-bound bearer stored only in a runtime
|
||||
secret reference. It is not registered in the global token registry.
|
||||
schemas:
|
||||
TaskWriterAuthorizationRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- action
|
||||
- task_id
|
||||
- repository
|
||||
- base_ref
|
||||
- base_oid
|
||||
- target_branch
|
||||
- one_time_use
|
||||
- expires_in_seconds
|
||||
- production_change_authorized
|
||||
- tag_change_authorized
|
||||
- baseline_change_authorized
|
||||
- main_write_authorized
|
||||
- allowed_operations
|
||||
- denied_operations
|
||||
properties:
|
||||
action:
|
||||
const: AUTHORIZE_TASK_WRITER_PROVISIONING
|
||||
task_id:
|
||||
const: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository:
|
||||
const: aios/aios
|
||||
base_ref:
|
||||
const: refs/heads/main
|
||||
base_oid:
|
||||
const: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch:
|
||||
const: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
one_time_use:
|
||||
const: true
|
||||
expires_in_seconds:
|
||||
type: integer
|
||||
minimum: 60
|
||||
maximum: 900
|
||||
production_change_authorized:
|
||||
const: false
|
||||
tag_change_authorized:
|
||||
const: false
|
||||
baseline_change_authorized:
|
||||
const: false
|
||||
main_write_authorized:
|
||||
const: false
|
||||
allowed_operations:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items:
|
||||
enum:
|
||||
- create_worktree
|
||||
- create_target_branch_if_absent
|
||||
- commit_to_target_branch
|
||||
- push_target_branch
|
||||
minItems: 4
|
||||
maxItems: 4
|
||||
denied_operations:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items:
|
||||
enum:
|
||||
- direct_main_push
|
||||
- force_push
|
||||
- tag_create
|
||||
- tag_update
|
||||
- tag_delete
|
||||
- branch_delete
|
||||
- repository_admin
|
||||
- baseline_write
|
||||
- production_deploy
|
||||
- production_marker_write
|
||||
- history_rewrite
|
||||
minItems: 11
|
||||
maxItems: 11
|
||||
@@ -0,0 +1,78 @@
|
||||
# Audited Owner Authorization Broker
|
||||
|
||||
## Purpose
|
||||
|
||||
The existing `AuthorizedTaskWriterProvisioner` requires an Owner authorization
|
||||
reference, but the active bearer-token registry has no governed way to issue a
|
||||
new exact `writer:provision` credential. The broker removes that circular
|
||||
dependency.
|
||||
|
||||
It does **not** modify the token registry and does **not** mint a new Owner
|
||||
credential. Instead, it:
|
||||
|
||||
1. authenticates an existing Owner credential through one configured,
|
||||
pre-existing exact scope;
|
||||
2. rejects wildcard credentials;
|
||||
3. requires an explicit Owner identity allowlist;
|
||||
4. records a task-bound authorization event;
|
||||
5. generates the authorization reference internally;
|
||||
6. calls the existing signer/validator/provisioner bridge;
|
||||
7. stores a non-secret, tamper-evident receipt.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
The HTTP intake scope authenticates the Owner. It is not copied into the
|
||||
writer grant. The generated grant remains constrained to the fixed repository,
|
||||
base OID and target branch and explicitly denies:
|
||||
|
||||
- direct `main` writes;
|
||||
- tag creation, update or deletion;
|
||||
- Baseline changes;
|
||||
- production deployment;
|
||||
- force push and history rewrite;
|
||||
- branch deletion and repository administration.
|
||||
|
||||
When the API authentication result does not expose exact scope metadata, the
|
||||
broker requires a configured non-secret credential ID allowlist. It fails
|
||||
closed rather than accepting an unprovable or wildcard credential.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Existing bridge endpoint:
|
||||
|
||||
```text
|
||||
POST /api/task-writer/provision-authorized
|
||||
```
|
||||
|
||||
New bootstrap-safe endpoint:
|
||||
|
||||
```text
|
||||
POST /api/governance/owner-authorizations
|
||||
```
|
||||
|
||||
The new endpoint omits `owner_authorization_reference`; the broker creates the
|
||||
reference from the authenticated Owner and canonical request.
|
||||
|
||||
## State
|
||||
|
||||
Default roots:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/owner-authorization-broker
|
||||
/var/lib/aios-gitops/authorized-writer-bridge
|
||||
```
|
||||
|
||||
Broker directories are `0700`; JSON receipts are `0600`. No bearer token,
|
||||
private key, raw signature or secret value is written to broker receipts.
|
||||
|
||||
## Idempotency
|
||||
|
||||
The event ID is derived from:
|
||||
|
||||
```text
|
||||
Owner identity + authenticated intake scope + canonical request digest
|
||||
```
|
||||
|
||||
A repeated identical request returns the stored successful receipt and does
|
||||
not call the provisioner again. Main drift, same-name branch conflicts and
|
||||
scope changes continue to fail closed in the underlying bridge.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Task-scoped identity overlay
|
||||
|
||||
## Purpose
|
||||
|
||||
The overlay resolves a bootstrap deadlock where the global bearer registry maps
|
||||
one secret to more than one identity and no active, non-wildcard Owner
|
||||
credential is available.
|
||||
|
||||
It does **not** modify `gitops.tokens`, enable a disabled credential or add a
|
||||
long-lived Owner scope. A root-governed issuer creates a fresh random bearer
|
||||
that is accepted only by a localhost-only staging API and only for one exact
|
||||
task binding.
|
||||
|
||||
## Fixed V2R1C6 binding
|
||||
|
||||
- task: `V2R1C6_TASK_SCOPED_WRITER_PROVISIONING`
|
||||
- repository: `aios/aios`
|
||||
- base ref: `refs/heads/main`
|
||||
- base OID: `875d41c3101093149414292067fd700dc214d4d3`
|
||||
- target branch: `agent/v2r1c6-mainline-reconciliation-20260805`
|
||||
- maximum credential lifetime: 900 seconds
|
||||
- one-time use: required
|
||||
|
||||
## Security model
|
||||
|
||||
1. Issuance is a root-only, audited bootstrap operation.
|
||||
2. A cryptographically random bearer is written to a mode-0600 runtime file.
|
||||
3. Metadata contains only the SHA-256 digest of the bearer.
|
||||
4. The issuer checks configured active secret references for a hash collision.
|
||||
5. The API ignores the global bearer registry.
|
||||
6. The request must exactly match the configured task, repository, base and
|
||||
target branch.
|
||||
7. Main, tag, Baseline, production, branch deletion, force push and repository
|
||||
administration remain explicitly denied.
|
||||
8. The credential is deleted after success, failure, revocation or expiry.
|
||||
9. The Owner Authorization Broker remains responsible for the audited
|
||||
authorization event, signed grant, validator and provisioner chain.
|
||||
|
||||
## Components
|
||||
|
||||
- `task_identity_overlay.py`: state machine and broker integration.
|
||||
- `aios_task_identity_overlay_cli.py`: root-only issue/status/revoke CLI.
|
||||
- `aios_task_identity_overlay_api.py`: localhost-only overlay API.
|
||||
- `call_task_identity_overlay.py`: client that keeps the bearer out of argv.
|
||||
- `smoke_task_identity_overlay.py`: HTTP end-to-end smoke test.
|
||||
|
||||
## Expected lifecycle
|
||||
|
||||
```text
|
||||
root-governed issue
|
||||
-> unique task secret
|
||||
-> localhost overlay authentication
|
||||
-> Owner Authorization Broker event
|
||||
-> signer
|
||||
-> validator
|
||||
-> one-shot writer provisioner
|
||||
-> READY_FOR_CODE_AUTHORING
|
||||
-> overlay secret removed
|
||||
```
|
||||
|
||||
## Failure states
|
||||
|
||||
The overlay fails closed on:
|
||||
|
||||
- base or branch mismatch;
|
||||
- request-field expansion;
|
||||
- production capability request;
|
||||
- wildcard scope metadata;
|
||||
- token collision;
|
||||
- token replay with a different request;
|
||||
- expired or terminal credential;
|
||||
- provisioner, validator or signer failure;
|
||||
- metadata digest failure;
|
||||
- state or secret symlinks;
|
||||
- directory permission expansion.
|
||||
|
||||
A failed provisioning attempt revokes the overlay credential. A new overlay can
|
||||
be issued for the same fixed task without changing the Owner business approval.
|
||||
@@ -0,0 +1,18 @@
|
||||
# V2R1C6 candidate
|
||||
|
||||
This branch is a reviewable candidate only. It is based on the post-PR #46
|
||||
main line and does not create a tag, change the production Baseline, or deploy
|
||||
the V2R1C6 application.
|
||||
|
||||
candidate_base_ref: refs/heads/main
|
||||
candidate_base_oid: a33e2593967a180c89d44454350ccaeaf3f79b25
|
||||
artifact_sha256: 437b1f891f23a1dd12feabeced3edee3c54cc552d990d6d852805c8d5a165150
|
||||
production_deployed: false
|
||||
tag_created: false
|
||||
baseline_changed: false
|
||||
|
||||
The candidate retains the dependency-free privacy/artifact helper, release
|
||||
metadata, loopback-only systemd example with GitOps writes disabled, SHA-256
|
||||
verification, rollback metadata, and bounded tests. A fresh release approval
|
||||
plan is required after the candidate commit is reviewed; no production
|
||||
promotion is implied.
|
||||
@@ -0,0 +1,47 @@
|
||||
# V2R1C6 release-ready engineering record
|
||||
|
||||
This record prepares a release candidate for governed review. It does not
|
||||
authorize a production deployment, tag publication, registry mutation, or
|
||||
baseline update.
|
||||
|
||||
## Binding
|
||||
|
||||
The source manifest is 'release-manifests/V2R1C6.json'. It uses
|
||||
'aios.release-source.v2' and binds the engineering branch to the approved
|
||||
parent OID through 'binding_mode: external_exact_oid'. The final merged target
|
||||
OID is deliberately kept outside the source tree and is supplied only by the
|
||||
post-merge evidence and non-executing approval request.
|
||||
|
||||
The historical V2R1C6 approval is retained in
|
||||
'approvals/V2R1C6-OLD-APPROVAL-SUPERSEDED-20260807.json'. It is not deleted,
|
||||
overwritten, or reused because its candidate descriptor was not a deployable
|
||||
artifact and its pre-deployment controller validation did not close.
|
||||
|
||||
## Artifact
|
||||
|
||||
'scripts/build_v2r1c6_release_artifact.py' creates a deterministic
|
||||
'aios-V2R1C6-<target-short-oid>.tar.zst'. It requires a clean tree and an
|
||||
exact HEAD OID, normalizes archive ownership, permissions, ordering, and
|
||||
timestamps, and excludes Git metadata, credentials, private keys, and caches.
|
||||
|
||||
The artifact contains the runtime health entrypoint, the rendered production
|
||||
unit, release source manifest, controller validator, build metadata, artifact
|
||||
manifest, rollback metadata, and the installation verifier.
|
||||
|
||||
## Controller and staging
|
||||
|
||||
The controller reads the source manifest and an external release request. It
|
||||
fails closed on schema, base/target OID, artifact, protected V2R1C5 tag,
|
||||
service, rollback, and approval errors. Its systemd unit is a validation-only
|
||||
oneshot with Git writes and production deployment disabled.
|
||||
|
||||
The V2R1C6 health endpoint is loopback-only and reports the engineering-only
|
||||
state. Staging acceptance must prove installation, systemd syntax, health,
|
||||
restart recovery, on-disk artifact digest, and a non-executing rollback plan.
|
||||
|
||||
## Next gate
|
||||
|
||||
After protected merge, rebuild the artifact against the new main OID, rerun
|
||||
the complete acceptance suite, create a fresh evidence closure, and request
|
||||
a fresh 'execute: false' Owner approval. Production actions remain blocked
|
||||
until a separate exact approval is granted.
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"AUTONOMOUS_ACCEPTANCE_COMPLETED": true,
|
||||
"CURRENT_VERSION_FAST_ACTIVATION": true,
|
||||
"OPENBAO_AUTONOMY_BOOTSTRAP_READY": true,
|
||||
"OPENBAO_PRODUCTION_ACTIVE": true,
|
||||
"OpenBao_version": "2.6.1",
|
||||
"RECONCILIATION_REQUIRED": false,
|
||||
"RUNTIME_PRODUCTION_ACTIVE": true,
|
||||
"activation_mode": "CURRENT_VERIFIED_RUNTIME_FAST_ACTIVATION",
|
||||
"completed_at": "2026-08-04T15:40:25.329762Z",
|
||||
"execution_status": "COMPLETED",
|
||||
"failure_code": null,
|
||||
"formal_new_version_created": false,
|
||||
"new_release_tag_created": false,
|
||||
"production_baseline": "V2R1C3",
|
||||
"production_baseline_promoted": false,
|
||||
"run_id": "20260804T152318Z-a0cb20f1",
|
||||
"schema": "aios.openbao.current-version-fast-activation.v1"
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# AIOS OpenBao 2.6.1 当前版本快速生产激活报告
|
||||
|
||||
- 执行状态:`COMPLETED`
|
||||
- Run ID:`20260804T152318Z-a0cb20f1`
|
||||
- 激活模式:`CURRENT_VERIFIED_RUNTIME_FAST_ACTIVATION`
|
||||
- OpenBao:`2.6.1`,active、initialized、unsealed
|
||||
- Controller:重启后自动重连,受限 AppRole 读写事务通过,未授权读取被拒绝
|
||||
- 即时稳定窗口:`900` 秒,全部异常计数为 0
|
||||
- 回滚就绪:2.6.0 隔离回滚及 2.6.1 前向恢复证据有效
|
||||
- Production Baseline:`V2R1C3`,未变
|
||||
- Patch 提交:待受治理隔离 Writer 完成后写入独立审计回执
|
||||
|
||||
## 版本边界
|
||||
|
||||
当前 OpenBao 2.6.1 运行版本已上线,不等于新的 AIOS 正式版本已发布。
|
||||
|
||||
```text
|
||||
formal_new_version_created=false
|
||||
new_release_tag_created=false
|
||||
production_baseline_promoted=false
|
||||
```
|
||||
|
||||
## 治理声明
|
||||
|
||||
```text
|
||||
force_push_used=false
|
||||
tag_rewritten=false
|
||||
direct_main_push=false
|
||||
secrets_committed=false
|
||||
production_baseline_changed=false
|
||||
owner_approval_respected=true
|
||||
72_hour_gate_waived_by_owner=true
|
||||
current_version_fast_activation_authorized=true
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
{"base_oid":"875d41c3101093149414292067fd700dc214d4d3","branch":"patch/chatgpt-autonomous-completion-contract-20260726","created_at":"2026-08-04T09:30:00Z","final_ready_condition":{"all_required":["main_gitea_oid_reconciliation","plaintext_private_key_cleanup","writer_residual_cleanup","openbao_binary_rollback_drill"],"status":"BLOCKED_UNTIL_ALL_GATES_PASS"},"gates":{"main_gitea_oid_reconciliation":{"required_evidence":["authority_main_oid","gitea_main_ref_oid","gitea_api_main_oid","all_main_oids_equal"],"status":"REQUIRED","success_condition":"Authority, Gitea ref, and Gitea API main OIDs are identical."},"openbao_binary_rollback_drill":{"required_evidence":["installed_binary_sha256","rollback_binary_sha256","rollback_execution_receipt","restore_execution_receipt","post_restore_smoke_test"],"status":"REQUIRED","success_condition":"Verified rollback and restore complete without software-key fallback or production mutation."},"plaintext_private_key_cleanup":{"required_evidence":["scanned_path_manifest","plaintext_private_key_match_count_zero","cleanup_receipt_digest","post_cleanup_rescan_pass"],"status":"REQUIRED","success_condition":"No plaintext private-key material remains in governed runtime, evidence, logs, or repository scopes."},"writer_residual_cleanup":{"required_evidence":["task_credentials_revoked","runtime_projection_removed","temporary_writer_units_stopped","writer_socket_removed","writer_process_count_zero"],"status":"REQUIRED","success_condition":"All task-scoped Writer credentials and residual runtime processes are removed after closure."}},"governance":{"72_hour_gate_waived_by_owner":true,"baseline_update_allowed":false,"direct_main_push":false,"force_push":false,"main_write_allowed":false,"secrets_committed":false,"tag_operation_allowed":false},"repository":"aios/aios","runtime_state":{"OPENBAO_AUTONOMY_BOOTSTRAP_READY":false,"OPENBAO_PRODUCTION_ACTIVE":false,"RECONCILIATION_REQUIRED":true,"RUNTIME_PRODUCTION_ACTIVE":true},"schema_id":"AIOS_OPENBAO_HARD_GATE_CLOSURE_CONTRACT_V1","schema_version":1}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
# AIOS OpenBao 硬门禁关闭报告
|
||||
|
||||
- 执行状态:`COMPLETED`
|
||||
- Run ID:`20260804T121711Z-3322267`
|
||||
- 生成时间:`2026-08-04T12:18:14.425028+00:00`
|
||||
- Production Baseline:`V2R1C3`(未修改)
|
||||
- Authority main:`875d41c3101093149414292067fd700dc214d4d3`(未修改)
|
||||
- 72 小时时间门禁:所有者豁免
|
||||
- 其他硬门禁:全部要求独立通过
|
||||
|
||||
## 门禁结果
|
||||
|
||||
```json
|
||||
{
|
||||
"BINARY_ROLLBACK_VERIFICATION": {
|
||||
"details": {
|
||||
"forward_restore_to": "2.6.1",
|
||||
"previous_binary_sha256": "291aaca2d5f9b4f371b4596c6ccbd7a4132f3667ddf5fee8efe11614d84cd928",
|
||||
"previous_version": "2.6.0"
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:44.578065+00:00"
|
||||
},
|
||||
"FINAL_GIT_BASELINE_REVALIDATION": {
|
||||
"details": {
|
||||
"main_after": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"main_before": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"main_unchanged": true,
|
||||
"tags_after": "56f2d68ea7361c2adfa232b87fc5753358d760829f6329b2685f62e0d364963e",
|
||||
"tags_before": "56f2d68ea7361c2adfa232b87fc5753358d760829f6329b2685f62e0d364963e",
|
||||
"tags_unchanged": true
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:14.423477+00:00"
|
||||
},
|
||||
"FINAL_RUNTIME_REVALIDATION": {
|
||||
"details": {
|
||||
"controlled_restart_verified": true,
|
||||
"controller_active": true,
|
||||
"openbao_active": true
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:10.671081+00:00"
|
||||
},
|
||||
"FINAL_SECRET_LEAK_SCAN": {
|
||||
"details": {
|
||||
"private_keys": 0,
|
||||
"text_findings": 0,
|
||||
"writer_runtime_paths": 0
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:10.863905+00:00"
|
||||
},
|
||||
"PLAINTEXT_KEYS_AND_STALE_WRITERS": {
|
||||
"details": {
|
||||
"managed_migrations": [
|
||||
"ca_key",
|
||||
"server_key",
|
||||
"seal_key",
|
||||
"approle_secret"
|
||||
],
|
||||
"plaintext_private_key_count": 0,
|
||||
"stale_key_actions": 0,
|
||||
"stale_writer_resource_count": 0
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:18.012919+00:00"
|
||||
},
|
||||
"REPOSITORY_MAIN_RECONCILIATION": {
|
||||
"details": {
|
||||
"authority_main": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"classification": "INTENTIONAL_BRANCH_SCOPED_PROJECTION",
|
||||
"gitea_main": "8291b8368bf690416a3159ac5d21d9f8139c2e61",
|
||||
"patch_oid": "c2391bd7f4b2d71e660a4caad8dc22276d99abc8"
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:17.133323+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最终状态
|
||||
|
||||
```text
|
||||
OPENBAO_AUTONOMY_BOOTSTRAP_READY=true
|
||||
OPENBAO_PRODUCTION_ACTIVE=true
|
||||
AUTONOMOUS_ACCEPTANCE_COMPLETED=true
|
||||
RECONCILIATION_REQUIRED=false
|
||||
```
|
||||
|
||||
## 治理声明
|
||||
|
||||
```text
|
||||
force_push_used=false
|
||||
tag_rewritten=false
|
||||
direct_main_push=false
|
||||
secrets_committed=false
|
||||
production_baseline_changed=false
|
||||
owner_approval_respected=true
|
||||
72_hour_gate_waived_by_owner=true
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"authority_main_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"execution_status": "COMPLETED",
|
||||
"gates": {
|
||||
"BINARY_ROLLBACK_VERIFICATION": {
|
||||
"details": {
|
||||
"forward_restore_to": "2.6.1",
|
||||
"previous_binary_sha256": "291aaca2d5f9b4f371b4596c6ccbd7a4132f3667ddf5fee8efe11614d84cd928",
|
||||
"previous_version": "2.6.0"
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:44.578065+00:00"
|
||||
},
|
||||
"FINAL_GIT_BASELINE_REVALIDATION": {
|
||||
"details": {
|
||||
"main_after": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"main_before": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"main_unchanged": true,
|
||||
"tags_after": "56f2d68ea7361c2adfa232b87fc5753358d760829f6329b2685f62e0d364963e",
|
||||
"tags_before": "56f2d68ea7361c2adfa232b87fc5753358d760829f6329b2685f62e0d364963e",
|
||||
"tags_unchanged": true
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:14.423477+00:00"
|
||||
},
|
||||
"FINAL_RUNTIME_REVALIDATION": {
|
||||
"details": {
|
||||
"controlled_restart_verified": true,
|
||||
"controller_active": true,
|
||||
"openbao_active": true
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:10.671081+00:00"
|
||||
},
|
||||
"FINAL_SECRET_LEAK_SCAN": {
|
||||
"details": {
|
||||
"private_keys": 0,
|
||||
"text_findings": 0,
|
||||
"writer_runtime_paths": 0
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:18:10.863905+00:00"
|
||||
},
|
||||
"PLAINTEXT_KEYS_AND_STALE_WRITERS": {
|
||||
"details": {
|
||||
"managed_migrations": [
|
||||
"ca_key",
|
||||
"server_key",
|
||||
"seal_key",
|
||||
"approle_secret"
|
||||
],
|
||||
"plaintext_private_key_count": 0,
|
||||
"stale_key_actions": 0,
|
||||
"stale_writer_resource_count": 0
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:18.012919+00:00"
|
||||
},
|
||||
"REPOSITORY_MAIN_RECONCILIATION": {
|
||||
"details": {
|
||||
"authority_main": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"classification": "INTENTIONAL_BRANCH_SCOPED_PROJECTION",
|
||||
"gitea_main": "8291b8368bf690416a3159ac5d21d9f8139c2e61",
|
||||
"patch_oid": "c2391bd7f4b2d71e660a4caad8dc22276d99abc8"
|
||||
},
|
||||
"passed": true,
|
||||
"verified_at": "2026-08-04T12:17:17.133323+00:00"
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-08-04T12:18:14.425202+00:00",
|
||||
"production_baseline": "V2R1C3",
|
||||
"ready": true,
|
||||
"run_id": "20260804T121711Z-3322267",
|
||||
"schema": "aios.openbao.hard-gate-patch-summary.v1",
|
||||
"secrets_included": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema": "aios.governed-local-writer-bootstrap.v1",
|
||||
"task_id": "V2R1C6_GOVERNED_LOCAL_BOOTSTRAP",
|
||||
"principal": "governed-local-bootstrap",
|
||||
"branch": "fix/v2r1c6-branch-repair-lease-20260806",
|
||||
"parent_oid": "ba517a6808ea90eaf874b9b141b46ce8231d07f2",
|
||||
"authorization_sha256": "2ee9777425abf8ec4c26f57de36728714222ebc5ca94b7a20b378dda11be13ed"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "fix/v2r1c6-post-merge-signer-fixture-20260806",
|
||||
"base": "main",
|
||||
"created_by": "aiman",
|
||||
"created_at": "2026-08-06T03:09:33Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "fix/v2r1c6-signer-contract-compatibility-20260806",
|
||||
"base": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"created_by": "codex-gitops-bot",
|
||||
"created_at": "2026-08-06T01:24:48Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schema": "aios.release-source.v2",
|
||||
"version": "V2R1C6",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "28081c5522a869762ec4946846f68047d53d99f0",
|
||||
"binding_mode": "external_exact_oid",
|
||||
"release_readiness": "READY",
|
||||
"production_deployed": false,
|
||||
"tag_created": false,
|
||||
"baseline_changed": false,
|
||||
"production_service": "aios-v2r1c6-production.service",
|
||||
"production_port": 18926,
|
||||
"rollback_metadata": "deploy/aios-v2/v2r1c6-rollback.json",
|
||||
"controller_schema": "aios.release-request.v2",
|
||||
"required_checks": [
|
||||
"source_manifest",
|
||||
"deterministic_artifact",
|
||||
"systemd_verify",
|
||||
"health_probe",
|
||||
"restart_recovery",
|
||||
"rollback"
|
||||
]
|
||||
}
|
||||
@@ -1,61 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deterministic dormant-release candidate artifacts."""
|
||||
"""Build a deterministic Candidate artifact without overwriting evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder
|
||||
|
||||
|
||||
def sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
def exclusive_write(path: Path, content: bytes, mode: int) -> None:
|
||||
if path.is_symlink():
|
||||
raise RuntimeError("artifact output symlink rejected")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, mode)
|
||||
try:
|
||||
os.write(descriptor, content)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def build(source: Path, output: Path, source_oid: str, tree_oid: str) -> dict[str, str]:
|
||||
if not source.exists() or not source.is_dir():
|
||||
raise SystemExit("source directory missing")
|
||||
files = []
|
||||
for item in sorted(source.rglob("*")):
|
||||
if item.is_file() and ".git" not in item.parts:
|
||||
files.append(item)
|
||||
artifact = output / "candidate.tar"
|
||||
manifest = output / "manifest.json"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(artifact, "w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for item in files:
|
||||
info = archive.gettarinfo(str(item), arcname=str(item.relative_to(source)))
|
||||
info.mtime = 0
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
with item.open("rb") as handle:
|
||||
archive.addfile(info, handle)
|
||||
payload = {
|
||||
"source_oid": source_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"artifact_sha256": sha256(artifact.read_bytes()),
|
||||
"files": {
|
||||
str(item.relative_to(source)): sha256(item.read_bytes())
|
||||
for item in files
|
||||
},
|
||||
}
|
||||
manifest.write_text(json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8")
|
||||
return {
|
||||
"artifact_sha256": payload["artifact_sha256"],
|
||||
"manifest_sha256": sha256(manifest.read_bytes()),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("source_oid")
|
||||
parser.add_argument("tree_oid")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.source, args.output, args.source_oid, args.tree_oid)))
|
||||
parser.add_argument("--source", required=True, type=Path)
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
parser.add_argument("--tree-oid", required=True)
|
||||
parser.add_argument("--output-directory", required=True, type=Path)
|
||||
parser.add_argument("--name", default="aios-autonomous-release-candidate")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
output = args.output_directory
|
||||
output.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
if output.is_symlink():
|
||||
raise RuntimeError("artifact output directory symlink rejected")
|
||||
os.chmod(output, output.stat().st_mode & 0o750)
|
||||
builder = ArtifactBuilder()
|
||||
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
|
||||
builder.verify(built)
|
||||
artifact_path = output / f"{args.name}.tar"
|
||||
manifest_path = output / f"{args.name}.manifest.json"
|
||||
exclusive_write(artifact_path, built.content, 0o640)
|
||||
exclusive_write(manifest_path, built.manifest.as_json_bytes(), 0o640)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": str(artifact_path),
|
||||
"manifest": str(manifest_path),
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"secrets_included": False,
|
||||
"production_side_effects": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,141 +1,513 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate AIOS V2R1C4 dormant release evidence.
|
||||
|
||||
This command performs no Git, network, deployment, tag, Registry or Baseline
|
||||
write. It evaluates explicit evidence supplied by a governed execution agent
|
||||
and writes a deterministic JSON decision report.
|
||||
"""
|
||||
"""Run Candidate acceptance and emit a non-overwriting JSON evidence record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Mapping
|
||||
import tempfile
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
PACKAGE_ROOT = REPOSITORY_ROOT / "services" / "aios-autonomous-release-candidate"
|
||||
if str(PACKAGE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PACKAGE_ROOT))
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from aios_autonomous_release_candidate.acceptance import ( # noqa: E402
|
||||
AcceptancePhase,
|
||||
AcceptanceStatus,
|
||||
DormantAcceptanceGate,
|
||||
DormantAcceptanceInput,
|
||||
FinalDormantAcceptanceGate,
|
||||
FinalDormantAcceptanceInput,
|
||||
|
||||
ALLOWED_PREFIXES = (
|
||||
"services/aios-autonomous-release-candidate/",
|
||||
"tests/autonomous_release/",
|
||||
"tests/autonomous-release/",
|
||||
"scripts/autonomous-release/",
|
||||
"docs/autonomous-release/",
|
||||
)
|
||||
ALLOWED_EXACT = {
|
||||
"deploy/systemd/aios-autonomous-release-candidate.service",
|
||||
"deploy/systemd/aios-autonomous-release-worker-candidate.service",
|
||||
}
|
||||
SCAN_PREFIXES = (
|
||||
"services/aios-autonomous-release-candidate",
|
||||
"tests/autonomous_release",
|
||||
"tests/autonomous-release",
|
||||
"scripts/autonomous-release",
|
||||
"docs/autonomous-release",
|
||||
)
|
||||
from aios_autonomous_release_candidate.models import sha256_json # noqa: E402
|
||||
|
||||
|
||||
REPORT_SCHEMA = "aios.dormant-release.acceptance/v1"
|
||||
def command(repo: Path, argv: Sequence[str], environment: dict[str, str]) -> dict[str, object]:
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=repo,
|
||||
env=environment,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"argv": list(argv),
|
||||
"exit_code": completed.returncode,
|
||||
"output": completed.stdout[-12000:],
|
||||
"passed": completed.returncode == 0,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunnerDecision:
|
||||
phase: str
|
||||
status: str
|
||||
report: Mapping[str, Any]
|
||||
exit_code: int
|
||||
def text_files(repo: Path):
|
||||
for prefix in SCAN_PREFIXES:
|
||||
root = repo / prefix
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if path.is_file() and not path.is_symlink() and "__pycache__" not in path.parts:
|
||||
try:
|
||||
yield path, path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def read_json_object(path: Path) -> Mapping[str, Any]:
|
||||
def scans(repo: Path) -> dict[str, object]:
|
||||
forbidden_source = (
|
||||
"GITEA_" + "DATABASE",
|
||||
"sqlite3.connect(" + "GITEA_" + "DATABASE)",
|
||||
"SELECT " + "* FROM access_token",
|
||||
"DELETE " + "FROM access_token",
|
||||
"gitea admin " + "user generate-access-token",
|
||||
"ssh-key" + "scan",
|
||||
)
|
||||
secret_patterns = (
|
||||
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
|
||||
re.compile(r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
||||
re.compile(r"(?i)(?:api[_-]?key|password|token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{20,}"),
|
||||
)
|
||||
forbidden_hits: list[str] = []
|
||||
secret_hits: list[str] = []
|
||||
for path, content in text_files(repo):
|
||||
relative = path.relative_to(repo).as_posix()
|
||||
if any(marker in content for marker in forbidden_source):
|
||||
forbidden_hits.append(relative)
|
||||
if any(pattern.search(content) for pattern in secret_patterns):
|
||||
secret_hits.append(relative)
|
||||
changed = subprocess.run(
|
||||
["git", "diff", "--name-only", "main...HEAD"],
|
||||
cwd=repo,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
changed_paths = tuple(item for item in changed.stdout.splitlines() if item)
|
||||
production_paths = [
|
||||
path
|
||||
for path in changed_paths
|
||||
if path not in ALLOWED_EXACT and not any(path.startswith(prefix) for prefix in ALLOWED_PREFIXES)
|
||||
]
|
||||
return {
|
||||
"direct_gitea_database_access": False if not forbidden_hits else True,
|
||||
"forbidden_source_hits": sorted(set(forbidden_hits)),
|
||||
"secret_hits": sorted(set(secret_hits)),
|
||||
"production_path_hits": production_paths,
|
||||
"passed": not forbidden_hits and not secret_hits and not production_paths,
|
||||
}
|
||||
|
||||
|
||||
def exclusive_json(path: Path, payload: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o640)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"input file does not exist: {path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"input is not valid JSON: {exc.msg}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("input must contain one JSON object")
|
||||
return value
|
||||
content = (json.dumps(payload, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||
os.write(descriptor, content)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, payload: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
def git_object_oid(kind: str, content: bytes) -> str:
|
||||
header = f"{kind} {len(content)}\0".encode("ascii")
|
||||
return hashlib.sha1(header + content, usedforsecurity=False).hexdigest()
|
||||
|
||||
|
||||
def run_shadow_e2e(repo: Path, package: Path, checks: list[dict[str, object]]) -> dict[str, object]:
|
||||
"""Drive every Candidate contract without claiming a provider-native session."""
|
||||
if subprocess.check_output(["git", "status", "--porcelain"], cwd=repo, text=True).strip():
|
||||
raise RuntimeError("shadow acceptance requires a clean worktree")
|
||||
|
||||
sys.path.insert(0, str(package))
|
||||
from aios_autonomous_release_candidate.adapters import ShadowAdapter
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder
|
||||
from aios_autonomous_release_candidate.credential import (
|
||||
CredentialBroker,
|
||||
InMemoryCredentialProvider,
|
||||
)
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def evaluate(phase: AcceptancePhase, evidence: Mapping[str, Any]) -> RunnerDecision:
|
||||
if phase is AcceptancePhase.CANDIDATE:
|
||||
parsed = DormantAcceptanceInput.from_mapping(evidence)
|
||||
result = DormantAcceptanceGate().evaluate(parsed)
|
||||
else:
|
||||
parsed = FinalDormantAcceptanceInput.from_mapping(evidence)
|
||||
result = FinalDormantAcceptanceGate().evaluate(parsed)
|
||||
result_payload = dict(result.payload())
|
||||
report = {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"phase": result.phase.value,
|
||||
"status": result.status.value,
|
||||
"decision": result_payload,
|
||||
"input_sha256": sha256_json(evidence),
|
||||
"production_action_authorized": False,
|
||||
}
|
||||
return RunnerDecision(
|
||||
phase=result.phase.value,
|
||||
status=result.status.value,
|
||||
report=report,
|
||||
exit_code=0 if result.status is AcceptanceStatus.READY else 3,
|
||||
from aios_autonomous_release_candidate.errors import ReleaseBlocked
|
||||
from aios_autonomous_release_candidate.evidence import EvidenceChain
|
||||
from aios_autonomous_release_candidate.models import (
|
||||
CredentialRequest,
|
||||
ReleasePlan,
|
||||
ResourceBinding,
|
||||
RiskClass,
|
||||
TaskPlan,
|
||||
WorkerDescriptor,
|
||||
WorkflowStep,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_autonomous_release_candidate.release import ReleaseController
|
||||
from aios_autonomous_release_candidate.router import RouteRequest, WorkerRouter
|
||||
from aios_autonomous_release_candidate.store import StateStore
|
||||
from aios_autonomous_release_candidate.workflow import WorkflowEngine
|
||||
|
||||
|
||||
def invalid_report(phase: str, message: str) -> RunnerDecision:
|
||||
report = {
|
||||
"schema": REPORT_SCHEMA,
|
||||
"phase": phase,
|
||||
"status": AcceptanceStatus.BLOCKED.value,
|
||||
"decision": {
|
||||
"blocking_code": "INVALID_ACCEPTANCE_INPUT",
|
||||
"blocking_message": message,
|
||||
},
|
||||
"production_action_authorized": False,
|
||||
}
|
||||
return RunnerDecision(
|
||||
phase=phase,
|
||||
status=AcceptanceStatus.BLOCKED.value,
|
||||
report=report,
|
||||
exit_code=2,
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
tree = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True
|
||||
).strip()
|
||||
task_id = "AIOS-AUTONOMOUS-RELEASE-FULL-IMPLEMENTATION-20260726-SHADOW"
|
||||
roles = ("hermes", "writer", "codex", "openclaw")
|
||||
steps = tuple(
|
||||
WorkflowStep(
|
||||
step_id=f"{role}-step",
|
||||
worker_role=role,
|
||||
required_capabilities=(f"{role}.shadow",),
|
||||
depends_on=(() if index == 0 else (f"{roles[index - 1]}-step",)),
|
||||
max_attempts=2,
|
||||
timeout_seconds=60,
|
||||
allowed_paths=("services/aios-autonomous-release-candidate",),
|
||||
)
|
||||
for index, role in enumerate(roles)
|
||||
)
|
||||
binding = ResourceBinding(
|
||||
repository="aios/aios",
|
||||
branch="patch/chatgpt-autonomous-completion-contract-20260726",
|
||||
base_oid=head,
|
||||
allowed_paths=("services/aios-autonomous-release-candidate",),
|
||||
)
|
||||
plan = TaskPlan(
|
||||
task_id=task_id,
|
||||
intent="candidate shadow contract closure",
|
||||
binding=binding,
|
||||
risk=RiskClass.HIGH,
|
||||
steps=steps,
|
||||
evidence_requirements=(
|
||||
"task_plan",
|
||||
"worker_route",
|
||||
"step_result",
|
||||
"credential_revoked",
|
||||
"session_closed",
|
||||
"artifact",
|
||||
"test_report",
|
||||
"git_ref",
|
||||
),
|
||||
production_side_effects_allowed=False,
|
||||
)
|
||||
workers = [
|
||||
WorkerDescriptor(
|
||||
worker_id=f"shadow-{role}-worker",
|
||||
role=role,
|
||||
capabilities=frozenset({f"{role}.shadow"}),
|
||||
trust_zone="candidate-shadow",
|
||||
endpoint_id=f"loopback:{role}",
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
router = WorkerRouter(workers)
|
||||
provider = InMemoryCredentialProvider()
|
||||
now = datetime.now(timezone.utc)
|
||||
worker_routes: dict[str, str] = {}
|
||||
session_ids: dict[str, dict[str, str]] = {}
|
||||
credential_ids: list[str] = []
|
||||
credential_revocation: dict[str, dict[str, object]] = {}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="aios-autonomous-shadow-") as temporary:
|
||||
store = StateStore(Path(temporary) / "state.sqlite3")
|
||||
workflow = WorkflowEngine(store)
|
||||
workflow.plan(plan)
|
||||
evidence = EvidenceChain(store, task_id)
|
||||
evidence.append(
|
||||
"task_plan",
|
||||
task_id,
|
||||
{"task_plan_sha256": plan.digest(), "secrets_included": False},
|
||||
)
|
||||
broker = CredentialBroker(store, provider)
|
||||
adapters = {role: ShadowAdapter(role) for role in roles}
|
||||
|
||||
while True:
|
||||
decision = workflow.next_steps(plan)
|
||||
states = store.list_steps(task_id)
|
||||
if states and all(item.state.value == "COMPLETED" for item in states.values()):
|
||||
break
|
||||
if not decision.ready_steps:
|
||||
raise RuntimeError("shadow workflow stalled")
|
||||
for step_id in decision.ready_steps:
|
||||
step = next(item for item in steps if item.step_id == step_id)
|
||||
worker = router.route(
|
||||
RouteRequest(task_id, step.required_capabilities, "candidate-shadow")
|
||||
)
|
||||
adapter = adapters[step.worker_role]
|
||||
session = adapter.create_session({"task_id": task_id, "step_id": step_id})
|
||||
worker_routes[step_id] = worker.worker_id
|
||||
session_ids[step_id] = {
|
||||
"broker_session_id": session.session_id,
|
||||
"provider_session_id": session.provider_session_id,
|
||||
"provider": session.provider,
|
||||
}
|
||||
evidence.append(
|
||||
"worker_route",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"worker_id": worker.worker_id,
|
||||
"trust_zone": worker.trust_zone,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
request = CredentialRequest(
|
||||
request_id=f"request-{step_id}",
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
worker_id=worker.worker_id,
|
||||
audience=f"{step.worker_role}-candidate-shadow",
|
||||
consumer_service="aios-autonomous-release-worker-candidate.service",
|
||||
binding=binding,
|
||||
operations=(f"{step.worker_role}.shadow",),
|
||||
provider=f"{step.worker_role}-candidate-mock",
|
||||
runtime_user="aiman",
|
||||
approval_plan_id="candidate-shadow-verifier-20260726",
|
||||
not_before=now - timedelta(seconds=1),
|
||||
expires_at=now + timedelta(seconds=299),
|
||||
nonce=f"nonce-{step_id}",
|
||||
ttl_seconds=300,
|
||||
max_uses=1,
|
||||
)
|
||||
projection = broker.issue(request, now)
|
||||
broker.project(projection)
|
||||
broker.authorize(
|
||||
projection,
|
||||
task_id=task_id,
|
||||
session_id=session.session_id,
|
||||
worker_id=worker.worker_id,
|
||||
audience=request.audience,
|
||||
consumer_service=request.consumer_service,
|
||||
operation=request.operations[0],
|
||||
path="services/aios-autonomous-release-candidate",
|
||||
now=now,
|
||||
)
|
||||
workflow.claim(task_id, step_id, worker.worker_id, session.session_id)
|
||||
workflow.start(task_id, step_id, worker.worker_id, session.session_id, now=now)
|
||||
workflow.heartbeat(task_id, step_id, worker.worker_id, session.session_id, now=now)
|
||||
result = adapter.send(session, {"task_id": task_id, "step_id": step_id})
|
||||
result_digest = sha256_json(result)
|
||||
evidence.append(
|
||||
"step_result",
|
||||
task_id,
|
||||
{
|
||||
"step_id": step_id,
|
||||
"worker_id": worker.worker_id,
|
||||
"result_sha256": result_digest,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
workflow.complete(task_id, step_id, result_digest=result_digest)
|
||||
revoked = broker.revoke(projection.credential_id, projection.reference, now)
|
||||
broker.verify_revoked(projection.credential_id, projection.reference)
|
||||
credential_ids.append(projection.credential_id)
|
||||
credential_revocation[projection.credential_id] = {
|
||||
"state": revoked.state.value,
|
||||
"post_revocation_auth": "REJECTED",
|
||||
}
|
||||
evidence.append(
|
||||
"credential_revoked",
|
||||
task_id,
|
||||
{
|
||||
"credential_id": projection.credential_id,
|
||||
"state": revoked.state.value,
|
||||
"post_revocation_auth": "REJECTED",
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
adapter.close(session)
|
||||
evidence.append(
|
||||
"session_closed",
|
||||
task_id,
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"provider_session_id": session.provider_session_id,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
|
||||
built = ArtifactBuilder().build_from_directory(head, tree, package)
|
||||
ArtifactBuilder().verify(built)
|
||||
evidence.append(
|
||||
"artifact",
|
||||
task_id,
|
||||
{
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
evidence.append(
|
||||
"test_report",
|
||||
task_id,
|
||||
{
|
||||
"checks_passed": all(bool(item["passed"]) for item in checks),
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
evidence.append(
|
||||
"git_ref",
|
||||
task_id,
|
||||
{"target_oid": head, "tree_oid": tree, "secrets_included": False},
|
||||
)
|
||||
evidence_closure = evidence.close(plan.evidence_requirements)
|
||||
evidence_bytes = json.dumps(
|
||||
evidence.export(), sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
evidence_closure_oid = git_object_oid("blob", evidence_bytes)
|
||||
tag_body = (
|
||||
f"object {head}\ntype commit\ntag SHADOW-0.1.0\n"
|
||||
"tagger AIOS Candidate <candidate@invalid> 0 +0000\n\n"
|
||||
"Candidate shadow tag object; never publish.\n"
|
||||
).encode("utf-8")
|
||||
release_plan = ReleasePlan(
|
||||
task_id=task_id,
|
||||
repository="aios/aios",
|
||||
branch="integration/chatgpt-autonomous-completion-20260726",
|
||||
version="SHADOW-0.1.0",
|
||||
target_oid=head,
|
||||
tree_oid=tree,
|
||||
artifact_sha256=built.manifest.artifact_sha256,
|
||||
manifest_sha256=built.manifest.manifest_sha256,
|
||||
package_closure_sha256=built.manifest.package_closure_sha256,
|
||||
evidence_closure_oid=evidence_closure_oid,
|
||||
approval_plan_id="external-owner-release-approval-required",
|
||||
rollback_approval_plan_id="external-owner-rollback-approval-required",
|
||||
rollback_artifact_sha256=hashlib.sha256(b"previous-production-artifact").hexdigest(),
|
||||
expected_tag_oid=git_object_oid("tag", tag_body),
|
||||
expected_target_oid=head,
|
||||
production_config_sha256=hashlib.sha256(
|
||||
b"candidate-shadow-production-disabled"
|
||||
).hexdigest(),
|
||||
)
|
||||
release = ReleaseController()
|
||||
release.attach_plan(release_plan)
|
||||
release.mark_built()
|
||||
release.verify(artifact_ok=True, evidence_ok=True)
|
||||
release.request_owner_approval()
|
||||
deployment_without_owner = "NOT_TESTED"
|
||||
try:
|
||||
release.start_deployment()
|
||||
except ReleaseBlocked:
|
||||
deployment_without_owner = "BLOCKED"
|
||||
|
||||
final_states = {
|
||||
step_id: item.state.value for step_id, item in store.list_steps(task_id).items()
|
||||
}
|
||||
return {
|
||||
"passed": all(state == "COMPLETED" for state in final_states.values())
|
||||
and deployment_without_owner == "BLOCKED"
|
||||
and evidence.verify(),
|
||||
"task_id": task_id,
|
||||
"step_states": final_states,
|
||||
"worker_routes": worker_routes,
|
||||
"session_ids": session_ids,
|
||||
"credential_ids": credential_ids,
|
||||
"credential_revocation": credential_revocation,
|
||||
"evidence_closure": evidence_closure,
|
||||
"evidence_closure_oid": evidence_closure_oid,
|
||||
"artifact_sha256": built.manifest.artifact_sha256,
|
||||
"manifest_sha256": built.manifest.manifest_sha256,
|
||||
"package_closure_sha256": built.manifest.package_closure_sha256,
|
||||
"release_state": release.status().state.value,
|
||||
"deployment_without_owner_approval": deployment_without_owner,
|
||||
"broker_session_verified": True,
|
||||
"openclaw_native_child_session_verified": False,
|
||||
"codex_child_session_verified": False,
|
||||
"simulation": True,
|
||||
"human_intervention_count": 0,
|
||||
"production_side_effects": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"registry_write": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="run_acceptance.py")
|
||||
parser.add_argument(
|
||||
"--phase",
|
||||
required=True,
|
||||
choices=[phase.value for phase in AcceptancePhase],
|
||||
)
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-root", type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
repo = (args.repo_root or Path(__file__).resolve().parents[2]).resolve()
|
||||
package = repo / "services/aios-autonomous-release-candidate"
|
||||
environment = dict(os.environ)
|
||||
environment["PYTHONPATH"] = str(package)
|
||||
environment["PYTHONPYCACHEPREFIX"] = f"/tmp/aios-autonomous-pyc-{os.getpid()}"
|
||||
checks = [
|
||||
command(
|
||||
repo,
|
||||
[sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/autonomous_release"],
|
||||
environment,
|
||||
),
|
||||
command(
|
||||
repo,
|
||||
[sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", "tests/autonomous-release"],
|
||||
environment,
|
||||
),
|
||||
command(repo, [sys.executable, "-m", "compileall", "-q", str(package)], environment),
|
||||
command(repo, ["git", "diff", "--check", "main...HEAD"], environment),
|
||||
]
|
||||
scan_result = scans(repo)
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
||||
main_oid = subprocess.check_output(["git", "rev-parse", "main"], cwd=repo, text=True).strip()
|
||||
try:
|
||||
phase = AcceptancePhase(args.phase)
|
||||
evidence = read_json_object(args.input)
|
||||
decision = evaluate(phase, evidence)
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
decision = invalid_report(args.phase, str(exc))
|
||||
|
||||
envelope = dict(decision.report)
|
||||
envelope["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
atomic_write_json(args.output, envelope)
|
||||
print(json.dumps(envelope, sort_keys=True))
|
||||
return decision.exit_code
|
||||
shadow = run_shadow_e2e(repo, package, checks)
|
||||
except Exception as error:
|
||||
shadow = {
|
||||
"passed": False,
|
||||
"error": type(error).__name__,
|
||||
"production_side_effects": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
all_passed = (
|
||||
all(bool(item["passed"]) for item in checks)
|
||||
and bool(scan_result["passed"])
|
||||
and bool(shadow["passed"])
|
||||
)
|
||||
report: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"execution_mode": "candidate-shadow-only",
|
||||
"candidate_oid": head,
|
||||
"main_oid": main_oid,
|
||||
"checks": checks,
|
||||
"scans": scan_result,
|
||||
"shadow_e2e": shadow,
|
||||
"skipped": 0,
|
||||
"all_passed": all_passed,
|
||||
"production_side_effects": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"registry_write": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
exclusive_json(args.output, report)
|
||||
print(json.dumps({"report": str(args.output), "all_passed": all_passed}, sort_keys=True))
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_FILES = [
|
||||
"VERSION",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"deploy/aios-v2/systemd/aios-v2r1c6-production.service",
|
||||
"deploy/aios-v2/systemd/aios-v2r1c6-production.env.example",
|
||||
"deploy/aios-v2/v2r1c6-rollback.json",
|
||||
"release-manifests/V2R1C6.json",
|
||||
"services/aios-release-controller/aios_release_controller/__init__.py",
|
||||
"services/aios-release-controller/aios_release_controller/manifest.py",
|
||||
"services/aios-release-controller/aios_release_controller/cli.py",
|
||||
"scripts/verify_v2r1c6_release_source.py",
|
||||
"scripts/rollback_v2r1c6_release.sh",
|
||||
]
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_git(repo: Path, *args: str) -> str:
|
||||
return subprocess.check_output(["git", "-C", str(repo), *args], text=True).strip()
|
||||
|
||||
|
||||
def json_bytes(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
def build(repo: Path, target_oid: str, base_oid: str, output: Path, epoch: int) -> dict[str, object]:
|
||||
if run_git(repo, "rev-parse", "HEAD") != target_oid:
|
||||
raise SystemExit("target_oid_does_not_match_HEAD")
|
||||
if run_git(repo, "status", "--porcelain"):
|
||||
raise SystemExit("clean_tree_required")
|
||||
source_path = repo / "release-manifests" / "V2R1C6.json"
|
||||
source = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
if source["schema"] != "aios.release-source.v2" or source["base_oid"] != base_oid:
|
||||
raise SystemExit("source_manifest_binding_invalid")
|
||||
if source["release_readiness"] != "READY" or any(
|
||||
source[key] is not False for key in ("production_deployed", "tag_created", "baseline_changed")
|
||||
):
|
||||
raise SystemExit("source_manifest_not_engineering_only")
|
||||
for relative in SOURCE_FILES:
|
||||
path = repo / relative
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise SystemExit("required_source_missing:" + relative)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="v2r1c6-build-") as temp:
|
||||
staging = Path(temp) / "payload"
|
||||
staging.mkdir()
|
||||
copied: list[str] = []
|
||||
for relative in SOURCE_FILES:
|
||||
destination = staging / relative
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(repo / relative, destination)
|
||||
os.chmod(destination, 0o644)
|
||||
copied.append("payload/" + relative)
|
||||
metadata = {
|
||||
"schema": "aios.release-artifact-build.v1",
|
||||
"version": "V2R1C6",
|
||||
"repository": "aios/aios",
|
||||
"target_oid": target_oid,
|
||||
"base_oid": base_oid,
|
||||
"source_date_epoch": epoch,
|
||||
"clean_tree": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
manifest = {
|
||||
"schema": "aios.release-artifact-manifest.v1",
|
||||
"version": "V2R1C6",
|
||||
"target_oid": target_oid,
|
||||
"base_oid": base_oid,
|
||||
"files": sorted(copied + ["payload/BUILD-METADATA.json", "payload/ARTIFACT-MANIFEST.json", "payload/ROLLBACK-METADATA.json"]),
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}
|
||||
(staging / "BUILD-METADATA.json").write_bytes(json_bytes(metadata))
|
||||
(staging / "ARTIFACT-MANIFEST.json").write_bytes(json_bytes(manifest))
|
||||
rollback = {
|
||||
"schema": "aios.release-rollback.v1",
|
||||
"version": "V2R1C6",
|
||||
"previous_version": "V2R1C5",
|
||||
"previous_tag_oid": "9e65bd59baea392294c0f253046d3ab116f63145",
|
||||
"previous_target_oid": "aa60e891547abd7e215af7cf2bf3f21fd23541da",
|
||||
"baseline_unchanged": True,
|
||||
"production_deploy_authorized": False,
|
||||
"execution": "manual_owner_approval_required",
|
||||
}
|
||||
(staging / "ROLLBACK-METADATA.json").write_bytes(json_bytes(rollback))
|
||||
tar_path = Path(temp) / "release.tar"
|
||||
with tarfile.open(tar_path, "w") as archive:
|
||||
for path in sorted(staging.rglob("*")):
|
||||
name = "payload/" + str(path.relative_to(staging)).replace(os.sep, "/")
|
||||
info = archive.gettarinfo(str(path), arcname=name)
|
||||
info.uid = 0
|
||||
info.gid = 0
|
||||
info.uname = "root"
|
||||
info.gname = "root"
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if path.is_dir() else 0o644
|
||||
if path.is_file():
|
||||
with path.open("rb") as stream:
|
||||
archive.addfile(info, stream)
|
||||
else:
|
||||
archive.addfile(info)
|
||||
short = target_oid[:12]
|
||||
artifact = output / f"aios-V2R1C6-{short}.tar.zst"
|
||||
subprocess.run(
|
||||
["zstd", "-T1", "-19", "--no-progress", "-q", "-f", str(tar_path), "-o", str(artifact)],
|
||||
check=True,
|
||||
)
|
||||
receipt = {
|
||||
"schema": "aios.release-artifact-receipt.v1",
|
||||
"version": "V2R1C6",
|
||||
"target_oid": target_oid,
|
||||
"base_oid": base_oid,
|
||||
"artifact": artifact.name,
|
||||
"artifact_sha256": digest(artifact),
|
||||
"artifact_size": artifact.stat().st_size,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}
|
||||
(output / f"{artifact.stem}.manifest.json").write_bytes(json_bytes(receipt))
|
||||
(output / "BUILD-RECEIPT.json").write_bytes(json_bytes(receipt))
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-root", type=Path, default=ROOT)
|
||||
parser.add_argument("--target-oid", required=True)
|
||||
parser.add_argument("--base-oid", required=True)
|
||||
parser.add_argument("--output-directory", type=Path, required=True)
|
||||
parser.add_argument("--source-date-epoch", type=int, default=0)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.repo_root, args.target_oid, args.base_oid, args.output_directory, args.source_date_epoch), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Call the loopback identity-overlay API without exposing the token in argv."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _read_secret(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RuntimeError("SECRET_REF_INVALID")
|
||||
if path.stat().st_size <= 0 or path.stat().st_size > 16_384:
|
||||
raise RuntimeError("SECRET_REF_INVALID")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if not value:
|
||||
raise RuntimeError("SECRET_REF_EMPTY")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_write(path: Path, value: dict) -> None:
|
||||
data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(data, encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--secret-ref", required=True, type=Path)
|
||||
parser.add_argument("--request-json", required=True, type=Path)
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
default="http://127.0.0.1:19864/api/governance/owner-authorizations",
|
||||
)
|
||||
parser.add_argument("--result-json", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
token = _read_secret(args.secret_ref)
|
||||
body = args.request_json.read_bytes()
|
||||
request = urllib.request.Request(
|
||||
args.url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
payload = json.loads(response.read())
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
payload = json.loads(exc.read())
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("RESPONSE_OBJECT_REQUIRED")
|
||||
payload["http_status"] = status
|
||||
_safe_write(args.result_json, payload)
|
||||
return 0 if status == 200 else 2
|
||||
except Exception:
|
||||
_safe_write(
|
||||
args.result_json,
|
||||
{"status": "FAILED", "error": "OVERLAY_CLIENT_FAILED"},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
approved="$(printenv AIOS_V2R1C6_ROLLBACK_APPROVED || true)"
|
||||
target="$(printenv AIOS_V2R1C6_ROLLBACK_TARGET || true)"
|
||||
if [ "$approved" != "true" ]; then
|
||||
echo "BLOCKED: rollback approval required" >&2
|
||||
exit 42
|
||||
fi
|
||||
if [ "$target" != "V2R1C5" ]; then
|
||||
echo "BLOCKED: only V2R1C5 rollback target is allowed" >&2
|
||||
exit 42
|
||||
fi
|
||||
echo "ROLLBACK_PLAN_VALIDATED"
|
||||
echo "production execution remains separately disabled"
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CORE = ROOT / "active-runtime" / "app" / "authorized_task_writer_provisioning.py"
|
||||
ADAPTER = ROOT / "active-runtime" / "app" / "aios_authorized_task_writer_api.py"
|
||||
OPENAPI = ROOT / "docs" / "gitops" / "openapi-authorized-task-writer-fragment.yaml"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
for path in (CORE, ADAPTER):
|
||||
try:
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
failures.append(f"{path}: {exc}")
|
||||
core = CORE.read_text(encoding="utf-8")
|
||||
required = [
|
||||
"OWNER_AUTHORIZATION_REQUIRED",
|
||||
"AUTHORIZED_BASE_OID_DRIFT",
|
||||
"EXISTING_BRANCH_OID_CONFLICT",
|
||||
"GOVERNED_GRANT_VALIDATION_FAILED",
|
||||
"NEGATIVE_POLICY_TESTS_FAILED",
|
||||
"ONE_TIME_GRANT_NOT_CONSUMED",
|
||||
]
|
||||
for marker in required:
|
||||
if marker not in core:
|
||||
failures.append(f"missing fail-closed marker: {marker}")
|
||||
forbidden_direct_git = ["update-ref", "push --force"]
|
||||
for marker in forbidden_direct_git:
|
||||
if marker in core:
|
||||
failures.append(f"direct mutation marker found: {marker}")
|
||||
schema = OPENAPI.read_text(encoding="utf-8")
|
||||
for marker in [
|
||||
"provisionAuthorizedTaskWriter",
|
||||
"x-openai-isConsequential: true",
|
||||
"production_change_authorized",
|
||||
"const: false",
|
||||
]:
|
||||
if marker not in schema:
|
||||
failures.append(f"OpenAPI marker missing: {marker}")
|
||||
if failures:
|
||||
print(json.dumps({"status": "FAILED", "failures": failures}, indent=2))
|
||||
return 1
|
||||
print(json.dumps({"status": "PASS", "files": 3}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal local smoke for the lease state machine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from branch_repair_lease import TASK_ID, LeaseStore, RepairLeaseManager
|
||||
|
||||
|
||||
class Probe:
|
||||
def __init__(self) -> None:
|
||||
self.head = "a" * 40
|
||||
self.parents: dict[str, str] = {}
|
||||
self.paths: dict[str, tuple[str, ...]] = {}
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
return self.head
|
||||
|
||||
def parent(self, commit: str) -> str:
|
||||
return self.parents[commit]
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return older == self.parents.get(newer)
|
||||
|
||||
def changed_paths(self, commit: str) -> tuple[str, ...]:
|
||||
return self.paths[commit]
|
||||
|
||||
def commits_between(self, older: str, newer: str) -> tuple[str, ...]:
|
||||
return (newer,)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
probe = Probe()
|
||||
manager = RepairLeaseManager(LeaseStore(Path(root) / "leases"), probe)
|
||||
lease = manager.issue(
|
||||
task_id=TASK_ID,
|
||||
owner_identity="smoke-owner",
|
||||
branch="fix/smoke",
|
||||
description="smoke",
|
||||
allowed_paths=("active-runtime/app/example.py",),
|
||||
)
|
||||
manager.attach_existing_branch(lease.lease_id, owner_identity="smoke-owner", expected_head_oid=probe.head)
|
||||
print("GOVERNED_BRANCH_REPAIR_LEASE_SMOKE=PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smoke entrypoint for the governed local writer contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite = unittest.defaultTestLoader.loadTestsFromName("test_governed_local_writer")
|
||||
result = unittest.TextTestRunner(verbosity=0).run(suite)
|
||||
print("GOVERNED_LOCAL_WRITER_SMOKE=PASS" if result.wasSuccessful() else "GOVERNED_LOCAL_WRITER_SMOKE=FAIL")
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BROKER = ROOT / "active-runtime" / "app" / "owner_authorization_broker.py"
|
||||
ADAPTER = (
|
||||
ROOT
|
||||
/ "active-runtime"
|
||||
/ "app"
|
||||
/ "aios_owner_authorization_broker_api.py"
|
||||
)
|
||||
OPENAPI = (
|
||||
ROOT
|
||||
/ "docs"
|
||||
/ "gitops"
|
||||
/ "openapi-owner-authorization-broker-fragment.yaml"
|
||||
)
|
||||
SYSTEMD = (
|
||||
ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-gitops-model-api-owner-authorization-broker.conf.example"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
failures = []
|
||||
for path in (BROKER, ADAPTER):
|
||||
try:
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
failures.append(f"{path}: {exc}")
|
||||
|
||||
broker = BROKER.read_text(encoding="utf-8")
|
||||
for marker in [
|
||||
"WILDCARD_OWNER_CREDENTIAL_REJECTED",
|
||||
"OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE",
|
||||
"PRODUCTION_CAPABILITY_REJECTED",
|
||||
"PROVISIONING_BLOCKED",
|
||||
"owner_authorization_reference",
|
||||
"AuthorizedTaskWriterProvisioner",
|
||||
]:
|
||||
if marker not in broker:
|
||||
failures.append(f"broker marker missing: {marker}")
|
||||
|
||||
for marker in [
|
||||
"update-ref",
|
||||
"git branch",
|
||||
"git push",
|
||||
"gitops.tokens",
|
||||
"OpenBao",
|
||||
]:
|
||||
if marker in broker:
|
||||
failures.append(f"broker contains forbidden direct mutation: {marker}")
|
||||
|
||||
adapter = ADAPTER.read_text(encoding="utf-8")
|
||||
for marker in [
|
||||
"/api/governance/owner-authorizations",
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED",
|
||||
"WILDCARD_OWNER_CREDENTIAL_REJECTED",
|
||||
]:
|
||||
if marker not in adapter:
|
||||
failures.append(f"adapter marker missing: {marker}")
|
||||
|
||||
schema = OPENAPI.read_text(encoding="utf-8")
|
||||
for marker in [
|
||||
"authorizeAndProvisionTaskWriter",
|
||||
"x-openai-isConsequential: true",
|
||||
"AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"const: false",
|
||||
]:
|
||||
if marker not in schema:
|
||||
failures.append(f"OpenAPI marker missing: {marker}")
|
||||
|
||||
systemd = SYSTEMD.read_text(encoding="utf-8")
|
||||
for marker in [
|
||||
"OWNER_AUTHORIZATION_BROKER_ENABLED=true",
|
||||
"OWNER_AUTHORIZATION_INTAKE_SCOPES",
|
||||
"OWNER_AUTHORIZATION_ALLOWED_IDENTITIES",
|
||||
"aios_owner_authorization_broker_api.py",
|
||||
]:
|
||||
if marker not in systemd:
|
||||
failures.append(f"systemd marker missing: {marker}")
|
||||
|
||||
if failures:
|
||||
print(json.dumps({"status": "FAILED", "failures": failures}, indent=2))
|
||||
return 1
|
||||
print(json.dumps({"status": "PASS", "files": 4}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke test for the loopback task identity overlay."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_task_identity_overlay_api import OverlayServer # noqa: E402
|
||||
from owner_authorization_broker import ACTION # noqa: E402
|
||||
from task_identity_overlay import ( # noqa: E402
|
||||
IdentityOverlayConfig,
|
||||
TaskBinding,
|
||||
TaskIdentityOverlayService,
|
||||
TaskIdentityOverlayStore,
|
||||
)
|
||||
|
||||
|
||||
TASK_ID = "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING"
|
||||
BRANCH = "agent/v2r1c6-mainline-reconciliation-20260805"
|
||||
BASE_OID = "875d41c3101093149414292067fd700dc214d4d3"
|
||||
|
||||
|
||||
class FakeProvisioner:
|
||||
def provision(self, raw, owner):
|
||||
return {
|
||||
"status": "READY_FOR_CODE_AUTHORING",
|
||||
"approval_plan_id": "sha256:" + "a" * 64,
|
||||
"grant_id": "grant-smoke",
|
||||
"grant_digest_sha256": "b" * 64,
|
||||
"branch": BRANCH,
|
||||
"branch_oid": BASE_OID,
|
||||
"base_oid": BASE_OID,
|
||||
"grant_consumed": True,
|
||||
"writer_scope": {
|
||||
"workspace_role": "task-scoped-isolated-writer",
|
||||
"writes_enabled": True,
|
||||
"allowed_branch": BRANCH,
|
||||
"allowed_base_oid": BASE_OID,
|
||||
"allowed_routes": ["POST /api/commit-file"],
|
||||
"allow_main_write": False,
|
||||
"allow_tag_write": False,
|
||||
"allow_baseline_write": False,
|
||||
"allow_force_push": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def request_body():
|
||||
return {
|
||||
"action": ACTION,
|
||||
"task_id": TASK_ID,
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": BASE_OID,
|
||||
"target_branch": BRANCH,
|
||||
"one_time_use": True,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch",
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.environ["AIOS_GITOPS_IDENTITY_OVERLAY_ENABLED"] = "true"
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
config = IdentityOverlayConfig(
|
||||
state_root=root / "state",
|
||||
secret_root=root / "run",
|
||||
broker_state_root=root / "broker",
|
||||
binding=TaskBinding(
|
||||
task_id=TASK_ID,
|
||||
repository="aios/aios",
|
||||
base_ref="refs/heads/main",
|
||||
base_oid=BASE_OID,
|
||||
target_branch=BRANCH,
|
||||
),
|
||||
owner_identity="aiman",
|
||||
max_ttl_seconds=900,
|
||||
require_root_issue=False,
|
||||
)
|
||||
store = TaskIdentityOverlayStore(config)
|
||||
issued = store.issue(
|
||||
ttl_seconds=900,
|
||||
authorization_assertion_ref="owner-session:V2R1C6:smoke",
|
||||
operator_identity="smoke-agent",
|
||||
)
|
||||
secret_ref = Path(issued["secret_ref"])
|
||||
token = secret_ref.read_text().strip()
|
||||
|
||||
server = OverlayServer(
|
||||
("127.0.0.1", 0),
|
||||
TaskIdentityOverlayService(
|
||||
config, provisioner=FakeProvisioner()
|
||||
),
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = server.server_address
|
||||
health = json.loads(
|
||||
urllib.request.urlopen(
|
||||
f"http://{host}:{port}/healthz", timeout=5
|
||||
).read()
|
||||
)
|
||||
if health.get("status") != "active":
|
||||
raise RuntimeError("health failed")
|
||||
|
||||
body = json.dumps(request_body()).encode("utf-8")
|
||||
http_request = urllib.request.Request(
|
||||
f"http://{host}:{port}/api/governance/owner-authorizations",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
result = json.loads(
|
||||
urllib.request.urlopen(http_request, timeout=10).read()
|
||||
)
|
||||
assert result["status"] == "READY_FOR_CODE_AUTHORING"
|
||||
assert result["identity_overlay_consumed"] is True
|
||||
assert result["identity_overlay_secret_removed"] is True
|
||||
assert not secret_ref.exists()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
print("task identity overlay smoke: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, os, re, subprocess, sys
|
||||
from pathlib import Path
|
||||
OID_RE=re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
BASE_REF="refs/heads/main"
|
||||
BRANCH="agent/v2r1c6-mainline-reconciliation-20260806"
|
||||
def fail(code): raise ValueError(code)
|
||||
def oid(v,field):
|
||||
if not isinstance(v,str) or OID_RE.fullmatch(v) is None: fail("malformed_oid:"+field)
|
||||
return v.lower()
|
||||
def read_json(p):
|
||||
try: v=json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError,json.JSONDecodeError): fail("unreadable:"+str(p))
|
||||
if not isinstance(v,dict): fail("object_required:"+str(p))
|
||||
return v
|
||||
def sha(p):
|
||||
if p.is_symlink() or not p.is_file(): fail("regular_file_required:"+str(p))
|
||||
h=hashlib.sha256()
|
||||
with p.open("rb") as f:
|
||||
for chunk in iter(lambda:f.read(1048576),b""): h.update(chunk)
|
||||
return h.hexdigest()
|
||||
def kv(text,key):
|
||||
prefix=key+":"
|
||||
for line in text.splitlines():
|
||||
if line.startswith(prefix): return line[len(prefix):].strip()
|
||||
fail("missing_field:"+key)
|
||||
def expected_base(root,explicit):
|
||||
value=explicit or os.getenv("AIOS_PR_BASE_OID")
|
||||
if value: return oid(value,"expected_base")
|
||||
p=subprocess.run(["git","-C",str(root),"merge-base",BASE_REF,"HEAD"],text=True,capture_output=True)
|
||||
if p.returncode!=0: fail("merge_base_unavailable")
|
||||
return oid(p.stdout.strip(),"merge_base")
|
||||
def manifest(root):
|
||||
p=root/"artifacts/MANIFEST.sha256"
|
||||
if p.is_symlink() or not p.is_file(): fail("manifest_missing")
|
||||
seen=set(); n=0
|
||||
for raw in p.read_text(encoding="utf-8").splitlines():
|
||||
if not raw.strip(): continue
|
||||
parts=raw.split(maxsplit=1)
|
||||
if len(parts)!=2 or re.fullmatch(r"[0-9a-f]{64}",parts[0]) is None: fail("manifest_entry_invalid")
|
||||
name=parts[1][1:] if parts[1].startswith("*") else parts[1]
|
||||
if name in seen: fail("manifest_duplicate:"+name)
|
||||
seen.add(name); candidate=(root/name).resolve()
|
||||
try: candidate.relative_to(root.resolve())
|
||||
except ValueError: fail("manifest_path_escape")
|
||||
if sha(candidate)!=parts[0]: fail("manifest_hash_mismatch:"+name)
|
||||
n+=1
|
||||
if not n: fail("manifest_empty")
|
||||
return n
|
||||
def verify(root,base):
|
||||
m=read_json(root/"versions/V2R1C6.json")
|
||||
if m.get("version")!="V2R1C6" or m.get("candidate_branch")!=BRANCH: fail("metadata_identity")
|
||||
if m.get("base_ref")!=BASE_REF or oid(m.get("base_oid"),"metadata_base")!=base: fail("metadata_base_mismatch")
|
||||
if any(m.get(k) is not False for k in ("production_deployed","tag_created","baseline_changed")): fail("metadata_mutation_flag")
|
||||
art=root/str(m.get("artifact_path","artifacts/v2r1c6-candidate-artifact.txt")); digest=sha(art)
|
||||
if digest!=m.get("artifact_sha256"): fail("artifact_digest_mismatch")
|
||||
text=art.read_text(encoding="utf-8")
|
||||
if kv(text,"base_ref")!=BASE_REF or oid(kv(text,"base_oid"),"artifact_base")!=base or kv(text,"candidate_branch")!=BRANCH: fail("artifact_binding")
|
||||
doc=(root/"docs/releases/V2R1C6-CANDIDATE.md").read_text(encoding="utf-8")
|
||||
if kv(doc,"candidate_base_ref")!=BASE_REF or oid(kv(doc,"candidate_base_oid"),"docs_base")!=base or kv(doc,"artifact_sha256")!=digest: fail("documentation_binding")
|
||||
if any(kv(doc,k)!="false" for k in ("production_deployed","tag_created","baseline_changed")): fail("documentation_mutation_flag")
|
||||
service=(root/"deploy/aios-v2/systemd/aios-v2r1c6-production.service").read_text(encoding="utf-8")
|
||||
if any(x not in service for x in ("AIOS_GITOPS_WRITES_ENABLED=false","IPAddressAllow=localhost","NoNewPrivileges=true")): fail("service_contract")
|
||||
rb=read_json(root/"deploy/aios-v2/v2r1c6-rollback.json")
|
||||
if rb.get("previous_tag_oid")!="9e65bd59baea392294c0f253046d3ab116f63145" or rb.get("previous_target_oid")!="aa60e891547abd7e215af7cf2bf3f21fd23541da": fail("rollback_binding")
|
||||
return {"status":"PASS","version":"V2R1C6","expected_base_oid":base,"artifact_sha256":digest,"manifest_entries":manifest(root)}
|
||||
def main():
|
||||
p=argparse.ArgumentParser(); p.add_argument("--root",type=Path,default=Path(".")); p.add_argument("--expected-base-oid"); a=p.parse_args()
|
||||
try: out=verify(a.root.resolve(),expected_base(a.root.resolve(),a.expected_base_oid))
|
||||
except ValueError as e: print(json.dumps({"status":"FAIL","error":str(e)},sort_keys=True),file=sys.stderr); return 1
|
||||
print(json.dumps(out,sort_keys=True)); return 0
|
||||
if __name__=="__main__": raise SystemExit(main())
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
exec python3 "$(dirname "$0")/verify_v2r1c6_candidate.py" --root "$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from aios_release_controller.manifest import validate_source_manifest
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def archive_names(path: Path) -> set[str]:
|
||||
raw = subprocess.check_output(["zstd", "-dc", str(path)])
|
||||
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as archive:
|
||||
return {member.name for member in archive.getmembers()}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo-root", type=Path, required=True)
|
||||
parser.add_argument("--target-oid", required=True)
|
||||
parser.add_argument("--base-oid", required=True)
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
repo = args.repo_root
|
||||
head = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
|
||||
if head != args.target_oid:
|
||||
raise SystemExit("target_oid_does_not_match_HEAD")
|
||||
source = validate_source_manifest(repo / "release-manifests" / "V2R1C6.json", args.base_oid)
|
||||
receipt = json.loads(next(args.artifact.parent.glob("*.manifest.json")).read_text(encoding="utf-8"))
|
||||
if receipt["target_oid"] != args.target_oid or receipt["base_oid"] != args.base_oid:
|
||||
raise SystemExit("receipt_binding_mismatch")
|
||||
if receipt["artifact_sha256"] != sha256(args.artifact) or receipt["artifact_size"] != args.artifact.stat().st_size:
|
||||
raise SystemExit("receipt_artifact_mismatch")
|
||||
required = {
|
||||
"payload/VERSION",
|
||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"payload/deploy/aios-v2/systemd/aios-v2r1c6-production.service",
|
||||
"payload/release-manifests/V2R1C6.json",
|
||||
"payload/services/aios-release-controller/aios_release_controller/manifest.py",
|
||||
"payload/BUILD-METADATA.json",
|
||||
"payload/ARTIFACT-MANIFEST.json",
|
||||
"payload/ROLLBACK-METADATA.json",
|
||||
}
|
||||
names = archive_names(args.artifact)
|
||||
missing = sorted(required - names)
|
||||
if missing:
|
||||
raise SystemExit("artifact_members_missing:" + ",".join(missing))
|
||||
print(json.dumps({
|
||||
"status": "PASS",
|
||||
"version": source["version"],
|
||||
"target_oid": args.target_oid,
|
||||
"base_oid": args.base_oid,
|
||||
"artifact_sha256": sha256(args.artifact),
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
-419
@@ -1,419 +0,0 @@
|
||||
"""Machine-verifiable acceptance contracts for dormant V2R1C4 delivery.
|
||||
|
||||
The contracts consume explicit evidence produced by a governed execution agent.
|
||||
They never infer remote Git consistency, approval validity or artifact
|
||||
provenance from a local checkout alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import MISSING, dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .dormant import (
|
||||
DeploymentMode,
|
||||
DormantReleaseCandidate,
|
||||
DormantReleaseGate,
|
||||
ExecutionFlags,
|
||||
FinalDormantRelease,
|
||||
)
|
||||
from .errors import ReleaseBlocked
|
||||
from .models import is_git_oid, is_sha256, sha256_json
|
||||
|
||||
|
||||
class AcceptanceStatus(str, Enum):
|
||||
READY = "READY"
|
||||
BLOCKED = "BLOCKED"
|
||||
|
||||
|
||||
class AcceptancePhase(str, Enum):
|
||||
CANDIDATE = "candidate"
|
||||
FINAL = "final"
|
||||
|
||||
|
||||
def _required_field_names(model: type[Any]) -> set[str]:
|
||||
return {
|
||||
name
|
||||
for name, field in model.__dataclass_fields__.items()
|
||||
if field.default is MISSING and field.default_factory is MISSING
|
||||
}
|
||||
|
||||
|
||||
def _strict_mapping(model: type[Any], value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("acceptance evidence must be a JSON object")
|
||||
allowed = set(model.__dataclass_fields__)
|
||||
unknown = sorted(set(value) - allowed)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown acceptance fields: {', '.join(unknown)}")
|
||||
missing = sorted(_required_field_names(model) - set(value))
|
||||
if missing:
|
||||
raise ValueError(f"missing acceptance fields: {', '.join(missing)}")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _require_bool(name: str, value: Any) -> None:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{name} must be a boolean")
|
||||
|
||||
|
||||
def _require_non_negative_int(name: str, value: Any) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a non-negative integer")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DormantAcceptanceInput:
|
||||
"""Immutable pre-merge evidence for the dormant Candidate package."""
|
||||
|
||||
version: str
|
||||
pull_request: int
|
||||
candidate_oid: str
|
||||
candidate_tree_oid: str
|
||||
integration_oid: str
|
||||
integration_tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
package_closure_sha256: str
|
||||
evidence_closure_oid: str
|
||||
rollback_artifact_sha256: str
|
||||
production_config_sha256: str
|
||||
test_evidence_sha256: str
|
||||
tests_passed: int
|
||||
tests_failed: int
|
||||
audited_skips: int
|
||||
candidate_four_way_ref_closure: bool
|
||||
integration_four_way_ref_closure: bool
|
||||
candidate_reachable_from_integration: bool
|
||||
worktree_clean: bool
|
||||
gitops_writes_enabled: bool
|
||||
task_plane_execution_enabled: bool
|
||||
silent_release_execution_enabled: bool
|
||||
protected_dispatch_transport: str = "sandbox"
|
||||
native_openclaw_enabled: bool = False
|
||||
native_codex_enabled: bool = False
|
||||
automatic_release_enabled: bool = False
|
||||
production_side_effects: bool = False
|
||||
activation_deferred: bool = True
|
||||
deployment_mode: str = DeploymentMode.DORMANT_SHADOW_ONLY.value
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.version != "V2R1C4":
|
||||
raise ValueError("version must be V2R1C4")
|
||||
if isinstance(self.pull_request, bool) or not isinstance(self.pull_request, int) or self.pull_request < 1:
|
||||
raise ValueError("pull_request must be a positive integer")
|
||||
for name in (
|
||||
"candidate_oid",
|
||||
"candidate_tree_oid",
|
||||
"integration_oid",
|
||||
"integration_tree_oid",
|
||||
"evidence_closure_oid",
|
||||
):
|
||||
if not is_git_oid(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
|
||||
for name in (
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"package_closure_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"production_config_sha256",
|
||||
"test_evidence_sha256",
|
||||
):
|
||||
if not is_sha256(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a lowercase SHA-256 digest")
|
||||
for name in ("tests_passed", "tests_failed", "audited_skips"):
|
||||
_require_non_negative_int(name, getattr(self, name))
|
||||
for name in (
|
||||
"candidate_four_way_ref_closure",
|
||||
"integration_four_way_ref_closure",
|
||||
"candidate_reachable_from_integration",
|
||||
"worktree_clean",
|
||||
"gitops_writes_enabled",
|
||||
"task_plane_execution_enabled",
|
||||
"silent_release_execution_enabled",
|
||||
"native_openclaw_enabled",
|
||||
"native_codex_enabled",
|
||||
"automatic_release_enabled",
|
||||
"production_side_effects",
|
||||
"activation_deferred",
|
||||
):
|
||||
_require_bool(name, getattr(self, name))
|
||||
try:
|
||||
DeploymentMode(self.deployment_mode)
|
||||
except ValueError as exc:
|
||||
raise ValueError("deployment_mode is invalid") from exc
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "DormantAcceptanceInput":
|
||||
return cls(**_strict_mapping(cls, value))
|
||||
|
||||
def release_candidate(self) -> DormantReleaseCandidate:
|
||||
return DormantReleaseCandidate(
|
||||
version=self.version,
|
||||
pull_request=self.pull_request,
|
||||
integration_oid=self.integration_oid,
|
||||
integration_tree_oid=self.integration_tree_oid,
|
||||
artifact_sha256=self.artifact_sha256,
|
||||
manifest_sha256=self.manifest_sha256,
|
||||
package_closure_sha256=self.package_closure_sha256,
|
||||
evidence_closure_oid=self.evidence_closure_oid,
|
||||
rollback_artifact_sha256=self.rollback_artifact_sha256,
|
||||
production_config_sha256=self.production_config_sha256,
|
||||
execution_flags=ExecutionFlags(
|
||||
gitops_writes_enabled=self.gitops_writes_enabled,
|
||||
task_plane_execution_enabled=self.task_plane_execution_enabled,
|
||||
silent_release_execution_enabled=self.silent_release_execution_enabled,
|
||||
),
|
||||
deployment_mode=DeploymentMode(self.deployment_mode),
|
||||
protected_dispatch_transport=self.protected_dispatch_transport,
|
||||
native_openclaw_enabled=self.native_openclaw_enabled,
|
||||
native_codex_enabled=self.native_codex_enabled,
|
||||
automatic_release_enabled=self.automatic_release_enabled,
|
||||
production_side_effects=self.production_side_effects,
|
||||
activation_deferred=self.activation_deferred,
|
||||
)
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {name: getattr(self, name) for name in self.__dataclass_fields__}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FinalDormantAcceptanceInput:
|
||||
"""Post-merge evidence bound to the exact main merge commit and approval."""
|
||||
|
||||
candidate: DormantAcceptanceInput
|
||||
target_oid: str
|
||||
target_tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
package_closure_sha256: str
|
||||
evidence_closure_oid: str
|
||||
rollback_artifact_sha256: str
|
||||
production_config_sha256: str
|
||||
approval_plan_id: str
|
||||
approval_receipt_sha256: str
|
||||
approved_release_digest: str
|
||||
main_four_way_ref_closure: bool
|
||||
target_is_main_head: bool
|
||||
worktree_clean: bool
|
||||
artifact_rebuilt_from_target: bool
|
||||
rollback_verified: bool
|
||||
runtime_boundary_verified: bool
|
||||
owner_approval_verified: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.candidate, DormantAcceptanceInput):
|
||||
raise ValueError("candidate must be DormantAcceptanceInput")
|
||||
for name in ("target_oid", "target_tree_oid", "evidence_closure_oid"):
|
||||
if not is_git_oid(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
|
||||
for name in (
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"package_closure_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"production_config_sha256",
|
||||
"approval_receipt_sha256",
|
||||
"approved_release_digest",
|
||||
):
|
||||
if not is_sha256(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a lowercase SHA-256 digest")
|
||||
if not self.approval_plan_id.strip():
|
||||
raise ValueError("approval_plan_id is required")
|
||||
for name in (
|
||||
"main_four_way_ref_closure",
|
||||
"target_is_main_head",
|
||||
"worktree_clean",
|
||||
"artifact_rebuilt_from_target",
|
||||
"rollback_verified",
|
||||
"runtime_boundary_verified",
|
||||
"owner_approval_verified",
|
||||
):
|
||||
_require_bool(name, getattr(self, name))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "FinalDormantAcceptanceInput":
|
||||
data = _strict_mapping(cls, value)
|
||||
candidate = data.get("candidate")
|
||||
if isinstance(candidate, DormantAcceptanceInput):
|
||||
parsed_candidate = candidate
|
||||
elif isinstance(candidate, Mapping):
|
||||
parsed_candidate = DormantAcceptanceInput.from_mapping(candidate)
|
||||
else:
|
||||
raise ValueError("candidate must be a JSON object")
|
||||
data["candidate"] = parsed_candidate
|
||||
return cls(**data)
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"candidate": dict(self.candidate.payload()),
|
||||
**{
|
||||
name: getattr(self, name)
|
||||
for name in self.__dataclass_fields__
|
||||
if name != "candidate"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DormantAcceptanceResult:
|
||||
phase: AcceptancePhase
|
||||
status: AcceptanceStatus
|
||||
acceptance_digest: str
|
||||
release_digest: str | None
|
||||
blocking_code: str | None
|
||||
blocking_message: str | None
|
||||
evidence: Mapping[str, Any]
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"phase": self.phase.value,
|
||||
"status": self.status.value,
|
||||
"acceptance_digest": self.acceptance_digest,
|
||||
"release_digest": self.release_digest,
|
||||
"blocking_code": self.blocking_code,
|
||||
"blocking_message": self.blocking_message,
|
||||
"evidence": dict(self.evidence),
|
||||
}
|
||||
|
||||
|
||||
class DormantAcceptanceGate:
|
||||
"""Fail-closed pre-merge gate for a dormant release candidate."""
|
||||
|
||||
def verify(self, evidence: DormantAcceptanceInput) -> DormantReleaseCandidate:
|
||||
if evidence.candidate_oid == evidence.integration_oid:
|
||||
raise ReleaseBlocked(
|
||||
"INTEGRATION_MERGE_REQUIRED",
|
||||
"integration must be a distinct governed merge commit",
|
||||
)
|
||||
if not evidence.candidate_four_way_ref_closure:
|
||||
raise ReleaseBlocked(
|
||||
"CANDIDATE_REF_CLOSURE_REQUIRED",
|
||||
"candidate local, bare, remote and Gitea references are not closed",
|
||||
)
|
||||
if not evidence.integration_four_way_ref_closure:
|
||||
raise ReleaseBlocked(
|
||||
"INTEGRATION_REF_CLOSURE_REQUIRED",
|
||||
"integration local, bare, remote and Gitea references are not closed",
|
||||
)
|
||||
if not evidence.candidate_reachable_from_integration:
|
||||
raise ReleaseBlocked(
|
||||
"CANDIDATE_LINEAGE_REQUIRED",
|
||||
"candidate is not reachable from the integration commit",
|
||||
)
|
||||
if not evidence.worktree_clean:
|
||||
raise ReleaseBlocked("WORKTREE_NOT_CLEAN", "acceptance worktree contains uncommitted changes")
|
||||
if evidence.tests_failed:
|
||||
raise ReleaseBlocked("TEST_FAILURES_PRESENT", "candidate test failures are present")
|
||||
if evidence.tests_passed < 1:
|
||||
raise ReleaseBlocked("TEST_EVIDENCE_REQUIRED", "at least one passing test is required")
|
||||
candidate = evidence.release_candidate()
|
||||
DormantReleaseGate().verify_candidate(candidate)
|
||||
return candidate
|
||||
|
||||
def evaluate(self, evidence: DormantAcceptanceInput) -> DormantAcceptanceResult:
|
||||
acceptance_digest = sha256_json(evidence.payload())
|
||||
try:
|
||||
candidate = self.verify(evidence)
|
||||
except (ReleaseBlocked, ValueError) as exc:
|
||||
return DormantAcceptanceResult(
|
||||
phase=AcceptancePhase.CANDIDATE,
|
||||
status=AcceptanceStatus.BLOCKED,
|
||||
acceptance_digest=acceptance_digest,
|
||||
release_digest=None,
|
||||
blocking_code=getattr(exc, "code", "INVALID_ACCEPTANCE_INPUT"),
|
||||
blocking_message=str(exc),
|
||||
evidence=evidence.payload(),
|
||||
)
|
||||
return DormantAcceptanceResult(
|
||||
phase=AcceptancePhase.CANDIDATE,
|
||||
status=AcceptanceStatus.READY,
|
||||
acceptance_digest=acceptance_digest,
|
||||
release_digest=candidate.digest(),
|
||||
blocking_code=None,
|
||||
blocking_message=None,
|
||||
evidence=evidence.payload(),
|
||||
)
|
||||
|
||||
|
||||
class FinalDormantAcceptanceGate:
|
||||
"""Fail-closed post-merge gate before deployment and tag execution."""
|
||||
|
||||
def verify(self, evidence: FinalDormantAcceptanceInput) -> FinalDormantRelease:
|
||||
candidate = DormantAcceptanceGate().verify(evidence.candidate)
|
||||
if not evidence.main_four_way_ref_closure:
|
||||
raise ReleaseBlocked(
|
||||
"MAIN_REF_CLOSURE_REQUIRED",
|
||||
"main local, bare, remote and Gitea references are not closed",
|
||||
)
|
||||
if not evidence.target_is_main_head:
|
||||
raise ReleaseBlocked("TARGET_NOT_MAIN_HEAD", "target OID is not the verified main HEAD")
|
||||
if not evidence.worktree_clean:
|
||||
raise ReleaseBlocked("WORKTREE_NOT_CLEAN", "final release worktree contains changes")
|
||||
if not evidence.artifact_rebuilt_from_target:
|
||||
raise ReleaseBlocked(
|
||||
"ARTIFACT_REBUILD_REQUIRED",
|
||||
"production artifact was not rebuilt from the final main target",
|
||||
)
|
||||
if not evidence.rollback_verified:
|
||||
raise ReleaseBlocked("ROLLBACK_EVIDENCE_REQUIRED", "rollback package is not verified")
|
||||
if not evidence.runtime_boundary_verified:
|
||||
raise ReleaseBlocked(
|
||||
"RUNTIME_BOUNDARY_REQUIRED",
|
||||
"dormant runtime flags and protected transport are not verified",
|
||||
)
|
||||
final = DormantReleaseGate().finalize(
|
||||
candidate,
|
||||
target_oid=evidence.target_oid,
|
||||
target_tree_oid=evidence.target_tree_oid,
|
||||
artifact_sha256=evidence.artifact_sha256,
|
||||
manifest_sha256=evidence.manifest_sha256,
|
||||
package_closure_sha256=evidence.package_closure_sha256,
|
||||
evidence_closure_oid=evidence.evidence_closure_oid,
|
||||
rollback_artifact_sha256=evidence.rollback_artifact_sha256,
|
||||
production_config_sha256=evidence.production_config_sha256,
|
||||
)
|
||||
if not evidence.owner_approval_verified:
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_REQUIRED", "exact final release approval is absent")
|
||||
if evidence.approved_release_digest != final.digest():
|
||||
raise ReleaseBlocked(
|
||||
"APPROVAL_SUBJECT_MISMATCH",
|
||||
"owner approval is not bound to the exact final release digest",
|
||||
)
|
||||
return final
|
||||
|
||||
def evaluate(self, evidence: FinalDormantAcceptanceInput) -> DormantAcceptanceResult:
|
||||
acceptance_digest = sha256_json(evidence.payload())
|
||||
try:
|
||||
final = self.verify(evidence)
|
||||
except (ReleaseBlocked, ValueError) as exc:
|
||||
return DormantAcceptanceResult(
|
||||
phase=AcceptancePhase.FINAL,
|
||||
status=AcceptanceStatus.BLOCKED,
|
||||
acceptance_digest=acceptance_digest,
|
||||
release_digest=None,
|
||||
blocking_code=getattr(exc, "code", "INVALID_ACCEPTANCE_INPUT"),
|
||||
blocking_message=str(exc),
|
||||
evidence=evidence.payload(),
|
||||
)
|
||||
return DormantAcceptanceResult(
|
||||
phase=AcceptancePhase.FINAL,
|
||||
status=AcceptanceStatus.READY,
|
||||
acceptance_digest=acceptance_digest,
|
||||
release_digest=final.digest(),
|
||||
blocking_code=None,
|
||||
blocking_message=None,
|
||||
evidence=evidence.payload(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AcceptancePhase",
|
||||
"AcceptanceStatus",
|
||||
"DormantAcceptanceInput",
|
||||
"FinalDormantAcceptanceInput",
|
||||
"DormantAcceptanceResult",
|
||||
"DormantAcceptanceGate",
|
||||
"FinalDormantAcceptanceGate",
|
||||
]
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
"""Dormant/shadow-only release profile for fast, governed production delivery.
|
||||
|
||||
This module separates verified code deployment from capability activation. A
|
||||
dormant release may install approved code in production while all autonomous
|
||||
execution, native-agent and automatic-release capabilities remain fail-closed.
|
||||
Activation is a later, independently approved operation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .errors import ReleaseBlocked
|
||||
from .models import is_git_oid, is_sha256, sha256_json
|
||||
|
||||
|
||||
class DeploymentMode(str, Enum):
|
||||
DORMANT_SHADOW_ONLY = "dormant_shadow_only"
|
||||
ACTIVATED_PRODUCTION = "activated_production"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExecutionFlags:
|
||||
"""Production execution switches captured in release evidence."""
|
||||
|
||||
gitops_writes_enabled: bool
|
||||
task_plane_execution_enabled: bool
|
||||
silent_release_execution_enabled: bool
|
||||
|
||||
def dormant(self) -> bool:
|
||||
return not (
|
||||
self.gitops_writes_enabled
|
||||
or self.task_plane_execution_enabled
|
||||
or self.silent_release_execution_enabled
|
||||
)
|
||||
|
||||
def as_mapping(self) -> Mapping[str, bool]:
|
||||
return {
|
||||
"AIOS_GITOPS_WRITES_ENABLED": self.gitops_writes_enabled,
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED": self.task_plane_execution_enabled,
|
||||
"AIOS_SILENT_RELEASE_EXECUTION_ENABLED": self.silent_release_execution_enabled,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DormantReleaseCandidate:
|
||||
"""Immutable pre-merge release description for dormant production delivery."""
|
||||
|
||||
version: str
|
||||
pull_request: int
|
||||
integration_oid: str
|
||||
integration_tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
package_closure_sha256: str
|
||||
evidence_closure_oid: str
|
||||
rollback_artifact_sha256: str
|
||||
production_config_sha256: str
|
||||
execution_flags: ExecutionFlags
|
||||
deployment_mode: DeploymentMode = DeploymentMode.DORMANT_SHADOW_ONLY
|
||||
protected_dispatch_transport: str = "sandbox"
|
||||
native_openclaw_enabled: bool = False
|
||||
native_codex_enabled: bool = False
|
||||
automatic_release_enabled: bool = False
|
||||
production_side_effects: bool = False
|
||||
activation_deferred: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.version or self.pull_request < 1:
|
||||
raise ValueError("version and a positive pull request number are required")
|
||||
for name in ("integration_oid", "integration_tree_oid", "evidence_closure_oid"):
|
||||
if not is_git_oid(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
|
||||
for name in (
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"package_closure_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"production_config_sha256",
|
||||
):
|
||||
if not is_sha256(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a SHA-256 digest")
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"pull_request": self.pull_request,
|
||||
"integration_oid": self.integration_oid,
|
||||
"integration_tree_oid": self.integration_tree_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"manifest_sha256": self.manifest_sha256,
|
||||
"package_closure_sha256": self.package_closure_sha256,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"rollback_artifact_sha256": self.rollback_artifact_sha256,
|
||||
"production_config_sha256": self.production_config_sha256,
|
||||
"execution_flags": dict(self.execution_flags.as_mapping()),
|
||||
"deployment_mode": self.deployment_mode.value,
|
||||
"protected_dispatch_transport": self.protected_dispatch_transport,
|
||||
"native_openclaw_enabled": self.native_openclaw_enabled,
|
||||
"native_codex_enabled": self.native_codex_enabled,
|
||||
"automatic_release_enabled": self.automatic_release_enabled,
|
||||
"production_side_effects": self.production_side_effects,
|
||||
"activation_deferred": self.activation_deferred,
|
||||
"production_target_oid": "UNRESOLVED",
|
||||
}
|
||||
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.payload())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FinalDormantRelease:
|
||||
"""Post-merge release binding to the exact production target commit."""
|
||||
|
||||
candidate: DormantReleaseCandidate
|
||||
target_oid: str
|
||||
target_tree_oid: str
|
||||
artifact_sha256: str
|
||||
manifest_sha256: str
|
||||
package_closure_sha256: str
|
||||
evidence_closure_oid: str
|
||||
rollback_artifact_sha256: str
|
||||
production_config_sha256: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("target_oid", "target_tree_oid", "evidence_closure_oid"):
|
||||
if not is_git_oid(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a full lowercase 40-character Git OID")
|
||||
for name in (
|
||||
"artifact_sha256",
|
||||
"manifest_sha256",
|
||||
"package_closure_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"production_config_sha256",
|
||||
):
|
||||
if not is_sha256(getattr(self, name)):
|
||||
raise ValueError(f"{name} must be a SHA-256 digest")
|
||||
|
||||
def payload(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
**dict(self.candidate.payload()),
|
||||
"production_target_oid": self.target_oid,
|
||||
"production_target_tree_oid": self.target_tree_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"manifest_sha256": self.manifest_sha256,
|
||||
"package_closure_sha256": self.package_closure_sha256,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"rollback_artifact_sha256": self.rollback_artifact_sha256,
|
||||
"production_config_sha256": self.production_config_sha256,
|
||||
}
|
||||
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.payload())
|
||||
|
||||
|
||||
class DormantReleaseGate:
|
||||
"""Validate that a production deployment is dormant and side-effect free."""
|
||||
|
||||
_DORMANT_TRANSPORTS = frozenset({"mock", "sandbox"})
|
||||
|
||||
def verify_candidate(self, candidate: DormantReleaseCandidate) -> None:
|
||||
if candidate.deployment_mode is not DeploymentMode.DORMANT_SHADOW_ONLY:
|
||||
raise ReleaseBlocked("DORMANT_MODE_REQUIRED", "release is not dormant/shadow-only")
|
||||
if not candidate.execution_flags.dormant():
|
||||
raise ReleaseBlocked("DORMANT_FLAGS_REQUIRED", "all production execution flags must be false")
|
||||
if candidate.protected_dispatch_transport not in self._DORMANT_TRANSPORTS:
|
||||
raise ReleaseBlocked(
|
||||
"DORMANT_TRANSPORT_REQUIRED",
|
||||
"production dispatch transport must remain mock or sandbox",
|
||||
)
|
||||
if candidate.native_openclaw_enabled or candidate.native_codex_enabled:
|
||||
raise ReleaseBlocked(
|
||||
"NATIVE_AGENT_ACTIVATION_FORBIDDEN",
|
||||
"native agents must remain disabled",
|
||||
)
|
||||
if candidate.automatic_release_enabled:
|
||||
raise ReleaseBlocked(
|
||||
"AUTOMATIC_RELEASE_FORBIDDEN",
|
||||
"automatic release must remain disabled",
|
||||
)
|
||||
if candidate.production_side_effects:
|
||||
raise ReleaseBlocked(
|
||||
"PRODUCTION_SIDE_EFFECT_FORBIDDEN",
|
||||
"dormant release cannot enable side effects",
|
||||
)
|
||||
if not candidate.activation_deferred:
|
||||
raise ReleaseBlocked(
|
||||
"ACTIVATION_MUST_BE_DEFERRED",
|
||||
"capability activation requires a separate release",
|
||||
)
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
candidate: DormantReleaseCandidate,
|
||||
*,
|
||||
target_oid: str,
|
||||
target_tree_oid: str,
|
||||
artifact_sha256: str,
|
||||
manifest_sha256: str,
|
||||
package_closure_sha256: str,
|
||||
evidence_closure_oid: str,
|
||||
rollback_artifact_sha256: str,
|
||||
production_config_sha256: str,
|
||||
) -> FinalDormantRelease:
|
||||
self.verify_candidate(candidate)
|
||||
final = FinalDormantRelease(
|
||||
candidate=candidate,
|
||||
target_oid=target_oid,
|
||||
target_tree_oid=target_tree_oid,
|
||||
artifact_sha256=artifact_sha256,
|
||||
manifest_sha256=manifest_sha256,
|
||||
package_closure_sha256=package_closure_sha256,
|
||||
evidence_closure_oid=evidence_closure_oid,
|
||||
rollback_artifact_sha256=rollback_artifact_sha256,
|
||||
production_config_sha256=production_config_sha256,
|
||||
)
|
||||
if final.target_oid == candidate.integration_oid:
|
||||
raise ReleaseBlocked(
|
||||
"FINAL_TARGET_NOT_MAIN_MERGE",
|
||||
"final production target must be the governed main merge commit, not the integration commit",
|
||||
)
|
||||
return final
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DeploymentMode",
|
||||
"ExecutionFlags",
|
||||
"DormantReleaseCandidate",
|
||||
"FinalDormantRelease",
|
||||
"DormantReleaseGate",
|
||||
]
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
"""Dormant candidate worker process.
|
||||
|
||||
The worker intentionally performs no autonomous production action. It exists to
|
||||
exercise lifecycle, heartbeat and shutdown behavior in candidate/shadow mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerRuntime:
|
||||
worker_id: str
|
||||
state_path: Path
|
||||
heartbeat_seconds: float
|
||||
running: bool = True
|
||||
|
||||
def stop(self, *_args: object) -> None:
|
||||
self.running = False
|
||||
|
||||
def heartbeat(self) -> None:
|
||||
payload = {
|
||||
"worker_id": self.worker_id,
|
||||
"mode": "candidate-shadow-only",
|
||||
"production_side_effects": False,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.state_path.with_suffix(self.state_path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(self.state_path)
|
||||
|
||||
def run(self) -> int:
|
||||
signal.signal(signal.SIGTERM, self.stop)
|
||||
signal.signal(signal.SIGINT, self.stop)
|
||||
while self.running:
|
||||
self.heartbeat()
|
||||
deadline = time.monotonic() + self.heartbeat_seconds
|
||||
while self.running and time.monotonic() < deadline:
|
||||
time.sleep(min(0.25, max(0.0, deadline - time.monotonic())))
|
||||
self.heartbeat()
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="aios-autonomous-release-worker")
|
||||
parser.add_argument("--worker-id", required=True)
|
||||
parser.add_argument("--state", type=Path, required=True)
|
||||
parser.add_argument("--heartbeat-seconds", type=float, default=5.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.heartbeat_seconds < 1 or args.heartbeat_seconds > 60:
|
||||
raise SystemExit("heartbeat interval must be between 1 and 60 seconds")
|
||||
runtime = WorkerRuntime(
|
||||
worker_id=args.worker_id,
|
||||
state_path=args.state,
|
||||
heartbeat_seconds=args.heartbeat_seconds,
|
||||
)
|
||||
return runtime.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,12 +1,15 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-autonomous-release-candidate"
|
||||
version = "0.1.0"
|
||||
description = "Dormant candidate orchestration and release governance for AIOS"
|
||||
description = "Candidate-only governed autonomous release control plane"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [{ name = "AIOS" }]
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
@@ -16,3 +19,7 @@ aios-autonomous-release-service = "aios_autonomous_release_candidate.service:mai
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["aios_autonomous_release_candidate*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra --strict-config --strict-markers"
|
||||
testpaths = ["../../tests/autonomous_release"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Manifest-driven release validation for governed release preparation."""
|
||||
|
||||
from .manifest import (
|
||||
ManifestValidationError,
|
||||
build_approval_request,
|
||||
validate_release_request,
|
||||
validate_source_manifest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ManifestValidationError",
|
||||
"build_approval_request",
|
||||
"validate_release_request",
|
||||
"validate_source_manifest",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .manifest import (
|
||||
ManifestValidationError,
|
||||
build_approval_request,
|
||||
validate_release_request,
|
||||
validate_source_manifest,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
source = sub.add_parser("validate-source")
|
||||
source.add_argument("manifest", type=Path)
|
||||
source.add_argument("--base-oid", required=True)
|
||||
release = sub.add_parser("validate-release")
|
||||
release.add_argument("source_manifest", type=Path)
|
||||
release.add_argument("request", type=Path)
|
||||
release.add_argument("--main-oid")
|
||||
approval = sub.add_parser("approval-request")
|
||||
approval.add_argument("source_manifest", type=Path)
|
||||
approval.add_argument("--target-oid", required=True)
|
||||
approval.add_argument("--artifact-uri", required=True)
|
||||
approval.add_argument("--artifact-sha256", required=True)
|
||||
approval.add_argument("--artifact-size", required=True, type=int)
|
||||
approval.add_argument("--evidence-closure-oid", required=True)
|
||||
approval.add_argument("--rollback-metadata", required=True)
|
||||
approval.add_argument("--current-tag-oid", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.command == "validate-source":
|
||||
result = validate_source_manifest(args.manifest, args.base_oid)
|
||||
elif args.command == "validate-release":
|
||||
source_data = json.loads(args.source_manifest.read_text(encoding="utf-8"))
|
||||
source = validate_source_manifest(args.source_manifest, source_data["base_oid"])
|
||||
result = validate_release_request(source, args.request, args.main_oid)
|
||||
else:
|
||||
result = build_approval_request(
|
||||
source_manifest_path=args.source_manifest,
|
||||
target_oid=args.target_oid,
|
||||
artifact_uri=args.artifact_uri,
|
||||
artifact_sha256=args.artifact_sha256,
|
||||
artifact_size=args.artifact_size,
|
||||
evidence_closure_oid=args.evidence_closure_oid,
|
||||
rollback_metadata=args.rollback_metadata,
|
||||
current_tag_oid=args.current_tag_oid,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except (ManifestValidationError, OSError, ValueError, KeyError) as exc:
|
||||
code = exc.code if isinstance(exc, ManifestValidationError) else "VALIDATION_ERROR"
|
||||
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
SOURCE_KEYS = {
|
||||
"schema", "version", "repository", "base_ref", "base_oid",
|
||||
"binding_mode", "release_readiness", "production_deployed",
|
||||
"tag_created", "baseline_changed", "production_service",
|
||||
"production_port", "rollback_metadata", "controller_schema",
|
||||
"required_checks",
|
||||
}
|
||||
REQUEST_KEYS = {
|
||||
"schema", "version", "repository", "base_oid", "target_oid",
|
||||
"artifact_uri", "artifact_sha256", "artifact_size",
|
||||
"evidence_closure_oid", "approval_plan_id", "execute",
|
||||
"production_deployed", "production_service", "production_port",
|
||||
"rollback_metadata", "current_tag_oid", "source_manifest_sha256",
|
||||
}
|
||||
|
||||
|
||||
class ManifestValidationError(ValueError):
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
return sha256_bytes(path.read_bytes())
|
||||
|
||||
|
||||
def _json_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ManifestValidationError("INVALID_JSON", str(exc)) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ManifestValidationError("INVALID_JSON", "expected JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise ManifestValidationError("INVALID_OID", name)
|
||||
return value
|
||||
|
||||
|
||||
def _sha(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or not SHA_RE.fullmatch(value):
|
||||
raise ManifestValidationError("INVALID_SHA256", name)
|
||||
return value
|
||||
|
||||
|
||||
def validate_source_manifest(path: Path, expected_base_oid: str) -> dict[str, Any]:
|
||||
data = _json_object(path)
|
||||
if set(data) != SOURCE_KEYS:
|
||||
raise ManifestValidationError("SOURCE_SCHEMA_KEYS", "source manifest keys mismatch")
|
||||
if data["schema"] != "aios.release-source.v2":
|
||||
raise ManifestValidationError("SOURCE_SCHEMA", "unsupported source schema")
|
||||
if data["version"] != "V2R1C6" or data["repository"] != "aios/aios":
|
||||
raise ManifestValidationError("SOURCE_IDENTITY", "source identity mismatch")
|
||||
if data["base_ref"] != "refs/heads/main":
|
||||
raise ManifestValidationError("SOURCE_BASE_REF", "source base ref mismatch")
|
||||
_oid(data["base_oid"], "base_oid")
|
||||
if data["base_oid"] != expected_base_oid:
|
||||
raise ManifestValidationError("BASE_OID_MISMATCH", "authorized base OID mismatch")
|
||||
if data["binding_mode"] != "external_exact_oid":
|
||||
raise ManifestValidationError("BINDING_MODE", "source must use external exact OID binding")
|
||||
if data["release_readiness"] != "READY":
|
||||
raise ManifestValidationError("SOURCE_NOT_READY", "source is not release ready")
|
||||
for key in ("production_deployed", "tag_created", "baseline_changed"):
|
||||
if data[key] is not False:
|
||||
raise ManifestValidationError("PRODUCTION_FLAG_SET", key)
|
||||
if data["production_service"] != "aios-v2r1c6-production.service":
|
||||
raise ManifestValidationError("SERVICE_IDENTITY", "production service mismatch")
|
||||
if data["production_port"] != 18926:
|
||||
raise ManifestValidationError("SERVICE_PORT", "production port mismatch")
|
||||
expected_checks = {
|
||||
"source_manifest", "deterministic_artifact", "systemd_verify",
|
||||
"health_probe", "restart_recovery", "rollback"
|
||||
}
|
||||
if not isinstance(data["required_checks"], list) or set(data["required_checks"]) != expected_checks:
|
||||
raise ManifestValidationError("REQUIRED_CHECKS", "required checks mismatch")
|
||||
return data
|
||||
|
||||
|
||||
def _safe_member(name: str) -> bool:
|
||||
p = Path(name)
|
||||
return not p.is_absolute() and ".." not in p.parts and not name.startswith("/")
|
||||
|
||||
|
||||
def _verify_artifact(path: Path, expected_sha: str, expected_size: int) -> list[str]:
|
||||
if not path.is_file():
|
||||
raise ManifestValidationError("ARTIFACT_MISSING", str(path))
|
||||
if path.stat().st_size != expected_size or sha256_file(path) != expected_sha:
|
||||
raise ManifestValidationError("ARTIFACT_DIGEST", "artifact digest or size mismatch")
|
||||
try:
|
||||
with tarfile.open(path, mode="r:*") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
if not _safe_member(member.name) or member.issym() or member.islnk() or not (
|
||||
member.isfile() or member.isdir()
|
||||
):
|
||||
raise ManifestValidationError("ARTIFACT_MEMBER", member.name)
|
||||
names = {m.name for m in members}
|
||||
except (OSError, tarfile.TarError) as exc:
|
||||
raise ManifestValidationError("ARTIFACT_FORMAT", str(exc)) from exc
|
||||
required = {
|
||||
"payload/VERSION",
|
||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"payload/deploy/aios-v2/systemd/aios-v2r1c6-production.service",
|
||||
"payload/release-manifests/V2R1C6.json",
|
||||
"payload/services/aios-release-controller/aios_release_controller/manifest.py",
|
||||
"payload/BUILD-METADATA.json",
|
||||
"payload/ARTIFACT-MANIFEST.json",
|
||||
"payload/ROLLBACK-METADATA.json",
|
||||
}
|
||||
missing = sorted(required - names)
|
||||
if missing:
|
||||
raise ManifestValidationError("ARTIFACT_CONTENT", ",".join(missing))
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def validate_release_request(
|
||||
source_manifest: dict[str, Any],
|
||||
request_path: Path,
|
||||
expected_main_oid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = _json_object(request_path)
|
||||
if set(request) != REQUEST_KEYS:
|
||||
raise ManifestValidationError("REQUEST_SCHEMA_KEYS", "release request keys mismatch")
|
||||
if request["schema"] != "aios.release-request.v2":
|
||||
raise ManifestValidationError("REQUEST_SCHEMA", "unsupported release request schema")
|
||||
if request["version"] != source_manifest["version"] or request["repository"] != "aios/aios":
|
||||
raise ManifestValidationError("REQUEST_IDENTITY", "release request identity mismatch")
|
||||
if request["base_oid"] != source_manifest["base_oid"]:
|
||||
raise ManifestValidationError("REQUEST_BASE_OID", "request base differs from source")
|
||||
_oid(request["target_oid"], "target_oid")
|
||||
if expected_main_oid is not None and request["target_oid"] != expected_main_oid:
|
||||
raise ManifestValidationError("TARGET_OID_MISMATCH", "request target differs from main")
|
||||
_sha(request["artifact_sha256"], "artifact_sha256")
|
||||
if not isinstance(request["artifact_size"], int) or request["artifact_size"] <= 0:
|
||||
raise ManifestValidationError("ARTIFACT_SIZE", "invalid artifact size")
|
||||
_oid(request["evidence_closure_oid"], "evidence_closure_oid")
|
||||
if not isinstance(request["approval_plan_id"], str) or not request["approval_plan_id"].startswith("sha256:"):
|
||||
raise ManifestValidationError("APPROVAL_PLAN", "invalid approval plan")
|
||||
if request["execute"] is not False or request["production_deployed"] is not False:
|
||||
raise ManifestValidationError("EXECUTION_NOT_ALLOWED", "release request must be non-executing")
|
||||
if request["production_service"] != source_manifest["production_service"]:
|
||||
raise ManifestValidationError("SERVICE_IDENTITY", "request service mismatch")
|
||||
if request["production_port"] != source_manifest["production_port"]:
|
||||
raise ManifestValidationError("SERVICE_PORT", "request port mismatch")
|
||||
if request["current_tag_oid"] != "9e65bd59baea392294c0f253046d3ab116f63145":
|
||||
raise ManifestValidationError("PROTECTED_TAG", "V2R1C5 tag binding changed")
|
||||
_sha(request["source_manifest_sha256"], "source_manifest_sha256")
|
||||
return request
|
||||
|
||||
|
||||
def build_approval_request(
|
||||
*,
|
||||
source_manifest_path: Path,
|
||||
target_oid: str,
|
||||
artifact_uri: str,
|
||||
artifact_sha256: str,
|
||||
artifact_size: int,
|
||||
evidence_closure_oid: str,
|
||||
rollback_metadata: str,
|
||||
current_tag_oid: str,
|
||||
) -> dict[str, Any]:
|
||||
source = _json_object(source_manifest_path)
|
||||
_oid(target_oid, "target_oid")
|
||||
_sha(artifact_sha256, "artifact_sha256")
|
||||
_oid(evidence_closure_oid, "evidence_closure_oid")
|
||||
_oid(current_tag_oid, "current_tag_oid")
|
||||
request = {
|
||||
"schema": "aios.release-request.v2",
|
||||
"version": source["version"],
|
||||
"repository": source["repository"],
|
||||
"base_oid": source["base_oid"],
|
||||
"target_oid": target_oid,
|
||||
"artifact_uri": artifact_uri,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"artifact_size": artifact_size,
|
||||
"evidence_closure_oid": evidence_closure_oid,
|
||||
"execute": False,
|
||||
"production_deployed": False,
|
||||
"production_service": source["production_service"],
|
||||
"production_port": source["production_port"],
|
||||
"rollback_metadata": rollback_metadata,
|
||||
"current_tag_oid": current_tag_oid,
|
||||
"source_manifest_sha256": sha256_file(source_manifest_path),
|
||||
}
|
||||
request["approval_plan_id"] = "sha256:" + sha256_bytes(_canonical(request))
|
||||
return request
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_module(relative: str):
|
||||
path = Path(__file__).parents[2] / relative
|
||||
spec = importlib.util.spec_from_file_location("v2r1c6_privacy_tools", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("module_load_failed")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def privacy():
|
||||
return load_module("active-runtime/app/v2r1c6_privacy_tools.py")
|
||||
|
||||
|
||||
def test_candidate_privacy_module_has_no_raw_secret_literal(privacy):
|
||||
source = Path(privacy.__file__).read_text(encoding="utf-8")
|
||||
assert ("Authorization: " + "Bearer ") not in source
|
||||
assert "BEGIN OPENSSH PRIVATE KEY" not in source
|
||||
|
||||
|
||||
def test_redaction_is_deterministic(privacy):
|
||||
value = {"token": "hidden", "message": "Bearer hidden"}
|
||||
assert privacy.redact_value(value) == privacy.redact_value(value)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_module
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def privacy():
|
||||
return load_module("active-runtime/app/v2r1c6_privacy_tools.py")
|
||||
|
||||
|
||||
def test_redacts_bearer_and_key_value_secrets(privacy):
|
||||
value = "Authorization: " + "Bearer " + "abc.def; password=s3cr3t"
|
||||
result = privacy.redact_text(value)
|
||||
assert "abc.def" not in result
|
||||
assert "s3cr3t" not in result
|
||||
assert result.count("[REDACTED]") == 2
|
||||
|
||||
|
||||
def test_redacts_nested_secret_keys(privacy):
|
||||
result = privacy.redact_value(
|
||||
{"owner": "aiman", "token": "do-not-print", "nested": {"api_key": "x"}}
|
||||
)
|
||||
assert result == {"owner": "aiman", "token": "[REDACTED]", "nested": {"api_key": "[REDACTED]"}}
|
||||
|
||||
|
||||
def test_rejects_symlink_input(privacy, tmp_path):
|
||||
target = tmp_path / "target"
|
||||
target.write_text("payload", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(target)
|
||||
with pytest.raises(ValueError, match="regular_file_required"):
|
||||
privacy.sha256_file(link)
|
||||
|
||||
|
||||
def test_manifest_verification(privacy, tmp_path):
|
||||
payload = tmp_path / "payload.txt"
|
||||
payload.write_text("candidate", encoding="utf-8")
|
||||
digest = privacy.sha256_file(payload)
|
||||
manifest = tmp_path / "MANIFEST.sha256"
|
||||
manifest.write_text(f"{digest} payload.txt\n", encoding="utf-8")
|
||||
assert privacy.verify_manifest(tmp_path, manifest)["entries"] == 1
|
||||
|
||||
|
||||
def test_manifest_rejects_traversal(privacy, tmp_path):
|
||||
manifest = tmp_path / "MANIFEST.sha256"
|
||||
manifest.write_text("0" * 64 + " ../outside\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="unsafe_manifest_path"):
|
||||
privacy.verify_manifest(tmp_path, manifest)
|
||||
|
||||
|
||||
def test_metadata_is_non_production(privacy, tmp_path):
|
||||
metadata = {
|
||||
"version": "V2R1C6",
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}
|
||||
path = tmp_path / "release.json"
|
||||
path.write_text(json.dumps(metadata), encoding="utf-8")
|
||||
assert privacy.load_release_metadata(path)["version"] == "V2R1C6"
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
def test_version_and_candidate_binding():
|
||||
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "V2R1C6"
|
||||
metadata = json.loads((ROOT / "versions/V2R1C6.json").read_text(encoding="utf-8"))
|
||||
assert metadata["base_oid"] == "a33e2593967a180c89d44454350ccaeaf3f79b25"
|
||||
assert metadata["candidate_branch"] == "agent/v2r1c6-mainline-reconciliation-20260806"
|
||||
assert metadata["production_deployed"] is False
|
||||
|
||||
|
||||
def test_release_invariants_are_frozen():
|
||||
metadata = json.loads((ROOT / "versions/V2R1C6.json").read_text(encoding="utf-8"))
|
||||
assert metadata["tag_created"] is False
|
||||
assert metadata["baseline_changed"] is False
|
||||
rollback = json.loads((ROOT / "deploy/aios-v2/v2r1c6-rollback.json").read_text(encoding="utf-8"))
|
||||
assert rollback["previous_tag_oid"] == "9e65bd59baea392294c0f253046d3ab116f63145"
|
||||
assert rollback["previous_target_oid"] == "aa60e891547abd7e215af7cf2bf3f21fd23541da"
|
||||
|
||||
|
||||
def test_artifact_digest_is_bound():
|
||||
from conftest import load_module
|
||||
privacy = load_module("active-runtime/app/v2r1c6_privacy_tools.py")
|
||||
metadata = json.loads((ROOT / "versions/V2R1C6.json").read_text(encoding="utf-8"))
|
||||
assert privacy.sha256_file(ROOT / metadata["artifact_path"]) == metadata["artifact_sha256"]
|
||||
|
||||
|
||||
def test_systemd_candidate_is_loopback_and_write_disabled():
|
||||
service = (ROOT / "deploy/aios-v2/systemd/aios-v2r1c6-production.service").read_text(encoding="utf-8")
|
||||
assert "IPAddressAllow=localhost" in service
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED=false" in service
|
||||
assert "NoNewPrivileges=true" in service
|
||||
assert "ProtectSystem=strict" in service
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
from v2r1c6_privacy_tools import health_payload, serve # noqa: E402
|
||||
|
||||
|
||||
def test_health_is_engineering_only() -> None:
|
||||
result = health_payload()
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"version": "V2R1C6",
|
||||
"production_deployed": False,
|
||||
"gitops_writes_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_listener_rejects_non_loopback() -> None:
|
||||
with pytest.raises(ValueError, match="loopback"):
|
||||
serve("0.0.0.0", 18926)
|
||||
@@ -1,176 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.acceptance import (
|
||||
AcceptanceStatus,
|
||||
DormantAcceptanceGate,
|
||||
DormantAcceptanceInput,
|
||||
FinalDormantAcceptanceGate,
|
||||
FinalDormantAcceptanceInput,
|
||||
)
|
||||
from aios_autonomous_release_candidate.dormant import DormantReleaseGate
|
||||
|
||||
|
||||
OID_A = "a" * 40
|
||||
OID_B = "b" * 40
|
||||
OID_C = "c" * 40
|
||||
OID_D = "d" * 40
|
||||
OID_E = "e" * 40
|
||||
OID_F = "f" * 40
|
||||
SHA_A = "1" * 64
|
||||
SHA_B = "2" * 64
|
||||
SHA_C = "3" * 64
|
||||
SHA_D = "4" * 64
|
||||
SHA_E = "5" * 64
|
||||
|
||||
|
||||
def candidate_input(**overrides: object) -> DormantAcceptanceInput:
|
||||
data: dict[str, object] = {
|
||||
"version": "V2R1C4",
|
||||
"pull_request": 20,
|
||||
"candidate_oid": OID_A,
|
||||
"candidate_tree_oid": OID_B,
|
||||
"integration_oid": OID_C,
|
||||
"integration_tree_oid": OID_D,
|
||||
"artifact_sha256": SHA_A,
|
||||
"manifest_sha256": SHA_B,
|
||||
"package_closure_sha256": SHA_C,
|
||||
"evidence_closure_oid": OID_E,
|
||||
"rollback_artifact_sha256": SHA_D,
|
||||
"production_config_sha256": SHA_E,
|
||||
"test_evidence_sha256": SHA_A,
|
||||
"tests_passed": 10,
|
||||
"tests_failed": 0,
|
||||
"audited_skips": 1,
|
||||
"candidate_four_way_ref_closure": True,
|
||||
"integration_four_way_ref_closure": True,
|
||||
"candidate_reachable_from_integration": True,
|
||||
"worktree_clean": True,
|
||||
"gitops_writes_enabled": False,
|
||||
"task_plane_execution_enabled": False,
|
||||
"silent_release_execution_enabled": False,
|
||||
}
|
||||
data.update(overrides)
|
||||
return DormantAcceptanceInput(**data)
|
||||
|
||||
|
||||
def final_input(**overrides: object) -> FinalDormantAcceptanceInput:
|
||||
candidate = candidate_input()
|
||||
final_release = DormantReleaseGate().finalize(
|
||||
candidate.release_candidate(),
|
||||
target_oid=OID_D,
|
||||
target_tree_oid=OID_E,
|
||||
artifact_sha256=SHA_A,
|
||||
manifest_sha256=SHA_B,
|
||||
package_closure_sha256=SHA_C,
|
||||
evidence_closure_oid=OID_F,
|
||||
rollback_artifact_sha256=SHA_D,
|
||||
production_config_sha256=SHA_E,
|
||||
)
|
||||
data: dict[str, object] = {
|
||||
"candidate": candidate,
|
||||
"target_oid": OID_D,
|
||||
"target_tree_oid": OID_E,
|
||||
"artifact_sha256": SHA_A,
|
||||
"manifest_sha256": SHA_B,
|
||||
"package_closure_sha256": SHA_C,
|
||||
"evidence_closure_oid": OID_F,
|
||||
"rollback_artifact_sha256": SHA_D,
|
||||
"production_config_sha256": SHA_E,
|
||||
"approval_plan_id": "approval-v2r1c4-final",
|
||||
"approval_receipt_sha256": SHA_B,
|
||||
"approved_release_digest": final_release.digest(),
|
||||
"main_four_way_ref_closure": True,
|
||||
"target_is_main_head": True,
|
||||
"worktree_clean": True,
|
||||
"artifact_rebuilt_from_target": True,
|
||||
"rollback_verified": True,
|
||||
"runtime_boundary_verified": True,
|
||||
"owner_approval_verified": True,
|
||||
}
|
||||
data.update(overrides)
|
||||
return FinalDormantAcceptanceInput(**data)
|
||||
|
||||
|
||||
def test_candidate_acceptance_ready() -> None:
|
||||
result = DormantAcceptanceGate().evaluate(candidate_input())
|
||||
assert result.status is AcceptanceStatus.READY
|
||||
assert result.release_digest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides,blocking_code",
|
||||
[
|
||||
({"candidate_four_way_ref_closure": False}, "CANDIDATE_REF_CLOSURE_REQUIRED"),
|
||||
({"integration_four_way_ref_closure": False}, "INTEGRATION_REF_CLOSURE_REQUIRED"),
|
||||
({"candidate_reachable_from_integration": False}, "CANDIDATE_LINEAGE_REQUIRED"),
|
||||
({"worktree_clean": False}, "WORKTREE_NOT_CLEAN"),
|
||||
({"tests_failed": 1}, "TEST_FAILURES_PRESENT"),
|
||||
({"tests_passed": 0}, "TEST_EVIDENCE_REQUIRED"),
|
||||
({"task_plane_execution_enabled": True}, "DORMANT_FLAGS_REQUIRED"),
|
||||
({"native_openclaw_enabled": True}, "NATIVE_AGENT_ACTIVATION_FORBIDDEN"),
|
||||
({"production_side_effects": True}, "PRODUCTION_SIDE_EFFECT_FORBIDDEN"),
|
||||
],
|
||||
)
|
||||
def test_candidate_acceptance_fail_closed(
|
||||
overrides: dict[str, object], blocking_code: str
|
||||
) -> None:
|
||||
result = DormantAcceptanceGate().evaluate(candidate_input(**overrides))
|
||||
assert result.status is AcceptanceStatus.BLOCKED
|
||||
assert result.blocking_code == blocking_code
|
||||
|
||||
|
||||
def test_candidate_mapping_rejects_unknown_fields() -> None:
|
||||
payload = dict(candidate_input().payload())
|
||||
payload["unexpected"] = True
|
||||
with pytest.raises(ValueError, match="unknown acceptance fields"):
|
||||
DormantAcceptanceInput.from_mapping(payload)
|
||||
|
||||
|
||||
def test_final_acceptance_ready() -> None:
|
||||
result = FinalDormantAcceptanceGate().evaluate(final_input())
|
||||
assert result.status is AcceptanceStatus.READY
|
||||
assert result.release_digest == final_input().approved_release_digest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides,blocking_code",
|
||||
[
|
||||
({"main_four_way_ref_closure": False}, "MAIN_REF_CLOSURE_REQUIRED"),
|
||||
({"target_is_main_head": False}, "TARGET_NOT_MAIN_HEAD"),
|
||||
({"worktree_clean": False}, "WORKTREE_NOT_CLEAN"),
|
||||
({"artifact_rebuilt_from_target": False}, "ARTIFACT_REBUILD_REQUIRED"),
|
||||
({"rollback_verified": False}, "ROLLBACK_EVIDENCE_REQUIRED"),
|
||||
({"runtime_boundary_verified": False}, "RUNTIME_BOUNDARY_REQUIRED"),
|
||||
({"owner_approval_verified": False}, "OWNER_APPROVAL_REQUIRED"),
|
||||
({"approved_release_digest": "9" * 64}, "APPROVAL_SUBJECT_MISMATCH"),
|
||||
],
|
||||
)
|
||||
def test_final_acceptance_fail_closed(
|
||||
overrides: dict[str, object], blocking_code: str
|
||||
) -> None:
|
||||
result = FinalDormantAcceptanceGate().evaluate(final_input(**overrides))
|
||||
assert result.status is AcceptanceStatus.BLOCKED
|
||||
assert result.blocking_code == blocking_code
|
||||
|
||||
|
||||
def test_final_mapping_parses_nested_candidate() -> None:
|
||||
evidence = final_input()
|
||||
payload = dict(evidence.payload())
|
||||
parsed = FinalDormantAcceptanceInput.from_mapping(payload)
|
||||
assert parsed == evidence
|
||||
|
||||
|
||||
def test_final_rejects_integration_as_production_target() -> None:
|
||||
evidence = final_input()
|
||||
altered = replace(
|
||||
evidence,
|
||||
target_oid=evidence.candidate.integration_oid,
|
||||
approved_release_digest="9" * 64,
|
||||
)
|
||||
result = FinalDormantAcceptanceGate().evaluate(altered)
|
||||
assert result.status is AcceptanceStatus.BLOCKED
|
||||
assert result.blocking_code == "FINAL_TARGET_NOT_MAIN_MERGE"
|
||||
@@ -1,113 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_autonomous_release_candidate.dormant import (
|
||||
DeploymentMode,
|
||||
DormantReleaseCandidate,
|
||||
DormantReleaseGate,
|
||||
ExecutionFlags,
|
||||
)
|
||||
from aios_autonomous_release_candidate.errors import ReleaseBlocked
|
||||
|
||||
|
||||
OID_A = "a" * 40
|
||||
OID_B = "b" * 40
|
||||
OID_C = "c" * 40
|
||||
SHA_A = "1" * 64
|
||||
SHA_B = "2" * 64
|
||||
SHA_C = "3" * 64
|
||||
SHA_D = "4" * 64
|
||||
SHA_E = "5" * 64
|
||||
|
||||
|
||||
def build_candidate(**overrides):
|
||||
values = {
|
||||
"version": "V2R1C4",
|
||||
"pull_request": 20,
|
||||
"integration_oid": OID_A,
|
||||
"integration_tree_oid": OID_B,
|
||||
"artifact_sha256": SHA_A,
|
||||
"manifest_sha256": SHA_B,
|
||||
"package_closure_sha256": SHA_C,
|
||||
"evidence_closure_oid": OID_C,
|
||||
"rollback_artifact_sha256": SHA_D,
|
||||
"production_config_sha256": SHA_E,
|
||||
"execution_flags": ExecutionFlags(False, False, False),
|
||||
}
|
||||
values.update(overrides)
|
||||
return DormantReleaseCandidate(**values)
|
||||
|
||||
|
||||
def test_dormant_candidate_is_accepted():
|
||||
candidate = build_candidate()
|
||||
DormantReleaseGate().verify_candidate(candidate)
|
||||
assert candidate.payload()["production_target_oid"] == "UNRESOLVED"
|
||||
assert len(candidate.digest()) == 64
|
||||
|
||||
|
||||
def test_dormant_candidate_rejects_enabled_execution_flag():
|
||||
candidate = build_candidate(execution_flags=ExecutionFlags(False, True, False))
|
||||
with pytest.raises(ReleaseBlocked) as error:
|
||||
DormantReleaseGate().verify_candidate(candidate)
|
||||
assert error.value.code == "DORMANT_FLAGS_REQUIRED"
|
||||
|
||||
|
||||
def test_dormant_candidate_rejects_production_transport():
|
||||
candidate = build_candidate(protected_dispatch_transport="task-plane")
|
||||
with pytest.raises(ReleaseBlocked) as error:
|
||||
DormantReleaseGate().verify_candidate(candidate)
|
||||
assert error.value.code == "DORMANT_TRANSPORT_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides,expected_code",
|
||||
[
|
||||
({"native_openclaw_enabled": True}, "NATIVE_AGENT_ACTIVATION_FORBIDDEN"),
|
||||
({"native_codex_enabled": True}, "NATIVE_AGENT_ACTIVATION_FORBIDDEN"),
|
||||
({"automatic_release_enabled": True}, "AUTOMATIC_RELEASE_FORBIDDEN"),
|
||||
({"production_side_effects": True}, "PRODUCTION_SIDE_EFFECT_FORBIDDEN"),
|
||||
({"activation_deferred": False}, "ACTIVATION_MUST_BE_DEFERRED"),
|
||||
({"deployment_mode": DeploymentMode.ACTIVATED_PRODUCTION}, "DORMANT_MODE_REQUIRED"),
|
||||
],
|
||||
)
|
||||
def test_dormant_candidate_rejects_activation(overrides, expected_code):
|
||||
candidate = build_candidate(**overrides)
|
||||
with pytest.raises(ReleaseBlocked) as error:
|
||||
DormantReleaseGate().verify_candidate(candidate)
|
||||
assert error.value.code == expected_code
|
||||
|
||||
|
||||
def test_finalize_binds_exact_main_merge_target():
|
||||
candidate = build_candidate()
|
||||
final = DormantReleaseGate().finalize(
|
||||
candidate,
|
||||
target_oid="d" * 40,
|
||||
target_tree_oid="e" * 40,
|
||||
artifact_sha256=SHA_A,
|
||||
manifest_sha256=SHA_B,
|
||||
package_closure_sha256=SHA_C,
|
||||
evidence_closure_oid="f" * 40,
|
||||
rollback_artifact_sha256=SHA_D,
|
||||
production_config_sha256=SHA_E,
|
||||
)
|
||||
assert final.target_oid == "d" * 40
|
||||
assert final.payload()["production_target_oid"] == "d" * 40
|
||||
assert len(final.digest()) == 64
|
||||
|
||||
|
||||
def test_finalize_rejects_integration_as_production_target():
|
||||
candidate = build_candidate()
|
||||
with pytest.raises(ReleaseBlocked) as error:
|
||||
DormantReleaseGate().finalize(
|
||||
candidate,
|
||||
target_oid=candidate.integration_oid,
|
||||
target_tree_oid="e" * 40,
|
||||
artifact_sha256=SHA_A,
|
||||
manifest_sha256=SHA_B,
|
||||
package_closure_sha256=SHA_C,
|
||||
evidence_closure_oid="f" * 40,
|
||||
rollback_artifact_sha256=SHA_D,
|
||||
production_config_sha256=SHA_E,
|
||||
)
|
||||
assert error.value.code == "FINAL_TARGET_NOT_MAIN_MERGE"
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "services" / "aios-release-controller"))
|
||||
from aios_release_controller.manifest import ( # noqa: E402
|
||||
ManifestValidationError,
|
||||
build_approval_request,
|
||||
validate_source_manifest,
|
||||
)
|
||||
|
||||
BASE_OID = "28081c5522a869762ec4946846f68047d53d99f0"
|
||||
TAG_OID = "9e65bd59baea392294c0f253046d3ab116f63145"
|
||||
|
||||
|
||||
def test_source_manifest_is_ready_and_external_bound() -> None:
|
||||
data = validate_source_manifest(ROOT / "release-manifests/V2R1C6.json", BASE_OID)
|
||||
assert data["binding_mode"] == "external_exact_oid"
|
||||
assert data["release_readiness"] == "READY"
|
||||
assert data["production_deployed"] is False
|
||||
|
||||
|
||||
def test_unknown_source_schema_is_blocked(tmp_path: Path) -> None:
|
||||
source = json.loads((ROOT / "release-manifests/V2R1C6.json").read_text())
|
||||
source["schema"] = "unknown"
|
||||
path = tmp_path / "source.json"
|
||||
path.write_text(json.dumps(source))
|
||||
with pytest.raises(ManifestValidationError) as error:
|
||||
validate_source_manifest(path, BASE_OID)
|
||||
assert error.value.code == "SOURCE_SCHEMA"
|
||||
|
||||
|
||||
def test_approval_request_is_non_executing_and_newly_bound() -> None:
|
||||
request = build_approval_request(
|
||||
source_manifest_path=ROOT / "release-manifests/V2R1C6.json",
|
||||
target_oid="a" * 40,
|
||||
artifact_uri="staging://v2r1c6/artifact.tar.zst",
|
||||
artifact_sha256="b" * 64,
|
||||
artifact_size=123,
|
||||
evidence_closure_oid="c" * 40,
|
||||
rollback_metadata="payload/ROLLBACK-METADATA.json",
|
||||
current_tag_oid=TAG_OID,
|
||||
)
|
||||
assert request["execute"] is False
|
||||
assert request["production_deployed"] is False
|
||||
assert request["approval_plan_id"].startswith("sha256:")
|
||||
|
||||
|
||||
def test_rollback_is_fail_closed_without_approval() -> None:
|
||||
env = os.environ.copy()
|
||||
env.pop("AIOS_V2R1C6_ROLLBACK_APPROVED", None)
|
||||
env.pop("AIOS_V2R1C6_ROLLBACK_TARGET", None)
|
||||
result = subprocess.run(
|
||||
["sh", str(ROOT / "scripts/rollback_v2r1c6_release.sh")],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 42
|
||||
assert "BLOCKED" in result.stderr
|
||||
@@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from authorized_task_writer_provisioning import ( # noqa: E402
|
||||
AuthorizedTaskWriterProvisioner,
|
||||
OwnerContext,
|
||||
ProvisioningConfig,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
|
||||
TASK_ID = "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING"
|
||||
BRANCH = "agent/v2r1c6-mainline-reconciliation-20260805"
|
||||
REPOSITORY = "aios/aios"
|
||||
OWNER_REF = "chat-owner-authorization:v2r1c6:20260806"
|
||||
|
||||
|
||||
def run(*args: str, cwd: Path | None = None) -> str:
|
||||
return subprocess.check_output(args, cwd=cwd, text=True).strip()
|
||||
|
||||
|
||||
def executable(path: Path, content: str) -> Path:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o700)
|
||||
return path
|
||||
|
||||
|
||||
class BridgeCase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.git_dir = self.root / "authority.git"
|
||||
self.seed = self.root / "seed"
|
||||
subprocess.run(["git", "init", "--bare", str(self.git_dir)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(self.seed)], check=True, capture_output=True)
|
||||
run("git", "config", "user.email", "test@example.invalid", cwd=self.seed)
|
||||
run("git", "config", "user.name", "Bridge Test", cwd=self.seed)
|
||||
(self.seed / "README.md").write_text("base\n", encoding="utf-8")
|
||||
run("git", "add", "README.md", cwd=self.seed)
|
||||
run("git", "commit", "-m", "base", cwd=self.seed)
|
||||
run("git", "branch", "-M", "main", cwd=self.seed)
|
||||
run("git", "remote", "add", "origin", str(self.git_dir), cwd=self.seed)
|
||||
run("git", "push", "origin", "main", cwd=self.seed)
|
||||
self.base_oid = run("git", "rev-parse", "HEAD", cwd=self.seed)
|
||||
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
self.state = self.root / "state"
|
||||
self.worktrees = self.root / "worktrees"
|
||||
self.worktrees.mkdir()
|
||||
|
||||
common_help = '''#!/usr/bin/python3 -S
|
||||
import sys
|
||||
if '--help' in sys.argv:
|
||||
print('--grant-dir --result-json --self-test')
|
||||
raise SystemExit(0)
|
||||
if '--self-test' in sys.argv:
|
||||
print('PASS')
|
||||
raise SystemExit(0)
|
||||
'''
|
||||
self.signer = executable(
|
||||
self.bin / "signer",
|
||||
common_help
|
||||
+ '''
|
||||
import hashlib, json
|
||||
from pathlib import Path
|
||||
args = dict(zip(sys.argv[1::2], sys.argv[2::2]))
|
||||
grant_dir = Path(args['--grant-dir'])
|
||||
result_path = Path(args['--result-json'])
|
||||
plan = json.loads((grant_dir / 'approval-plan.json').read_text())
|
||||
request = json.loads((grant_dir / 'grant-request.json').read_text())
|
||||
signed = {'plan': plan, 'request': request, 'signature': 'runtime-secret-backed'}
|
||||
raw = (json.dumps(signed, sort_keys=True, separators=(',', ':')) + '\\n').encode()
|
||||
(grant_dir / 'signed-grant.json').write_bytes(raw)
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
result_path.write_text(json.dumps({
|
||||
'status': 'SIGNED',
|
||||
'approval_plan_id': plan['approval_plan_id'],
|
||||
'grant_id': 'grant-' + digest[:16],
|
||||
'grant_digest_sha256': digest,
|
||||
'signing_key_identity': 'runtime-secret-ref:test-owner-signer',
|
||||
'signature_present': True,
|
||||
}, sort_keys=True))
|
||||
''',
|
||||
)
|
||||
self.validator = executable(
|
||||
self.bin / "validator",
|
||||
common_help
|
||||
+ '''
|
||||
import hashlib, json
|
||||
from pathlib import Path
|
||||
args = dict(zip(sys.argv[1::2], sys.argv[2::2]))
|
||||
grant_dir = Path(args['--grant-dir'])
|
||||
result_path = Path(args['--result-json'])
|
||||
plan = json.loads((grant_dir / 'approval-plan.json').read_text())
|
||||
raw = (grant_dir / 'signed-grant.json').read_bytes()
|
||||
checks = {name: True for name in [
|
||||
'schema_valid', 'signature_valid', 'signer_trusted', 'approval_plan_active',
|
||||
'repository_match', 'base_ref_match', 'base_oid_match', 'target_branch_match',
|
||||
'one_time_use', 'allowed_operations_match', 'denied_operations_match',
|
||||
]}
|
||||
result_path.write_text(json.dumps({
|
||||
'status': 'PASS',
|
||||
'approval_plan_id': plan['approval_plan_id'],
|
||||
'grant_digest_sha256': hashlib.sha256(raw).hexdigest(),
|
||||
'checks': checks,
|
||||
}, sort_keys=True))
|
||||
''',
|
||||
)
|
||||
self.provisioner = executable(
|
||||
self.bin / "provisioner",
|
||||
common_help
|
||||
+ '''
|
||||
import json, os, subprocess
|
||||
from pathlib import Path
|
||||
args = dict(zip(sys.argv[1::2], sys.argv[2::2]))
|
||||
grant_dir = Path(args['--grant-dir'])
|
||||
result_path = Path(args['--result-json'])
|
||||
request = json.loads((grant_dir / 'grant-request.json').read_text())
|
||||
git_dir = Path(os.environ['FAKE_AUTHORITY_GIT_DIR'])
|
||||
worktrees = Path(os.environ['FAKE_WORKTREE_ROOT'])
|
||||
branch = request['target']['branch']
|
||||
base = request['base']['oid']
|
||||
subprocess.run(['git', '--git-dir', str(git_dir), 'branch', branch, base], check=True)
|
||||
workspace = worktrees / branch.replace('/', '-')
|
||||
subprocess.run(['git', '--git-dir', str(git_dir), 'worktree', 'add', str(workspace), branch], check=True, capture_output=True)
|
||||
negative = {name: True for name in [
|
||||
'main_write_denied', 'other_branch_write_denied', 'tag_create_denied',
|
||||
'tag_update_denied', 'baseline_write_denied', 'force_push_denied',
|
||||
'branch_delete_denied',
|
||||
]}
|
||||
scope = {
|
||||
'workspace_role': 'task-scoped-isolated-writer',
|
||||
'writes_enabled': True,
|
||||
'allowed_branch': branch,
|
||||
'allowed_base_oid': base,
|
||||
'allowed_routes': ['POST /api/commit-file'],
|
||||
'allow_main_write': False,
|
||||
'allow_tag_write': False,
|
||||
'allow_baseline_write': False,
|
||||
'allow_force_push': False,
|
||||
}
|
||||
result_path.write_text(json.dumps({
|
||||
'status': 'READY_FOR_CODE_AUTHORING',
|
||||
'branch': branch,
|
||||
'branch_oid': base,
|
||||
'base_oid': base,
|
||||
'workspace_path': str(workspace),
|
||||
'writer_scope': scope,
|
||||
'negative_tests': negative,
|
||||
'grant_consumed': True,
|
||||
}, sort_keys=True))
|
||||
''',
|
||||
)
|
||||
self.config = ProvisioningConfig(
|
||||
repository_identity=REPOSITORY,
|
||||
authority_git_dir=self.git_dir,
|
||||
state_root=self.state,
|
||||
signer=self.signer,
|
||||
validator=self.validator,
|
||||
provisioner=self.provisioner,
|
||||
)
|
||||
self.saved_env = dict(os.environ)
|
||||
os.environ["FAKE_AUTHORITY_GIT_DIR"] = str(self.git_dir)
|
||||
os.environ["FAKE_WORKTREE_ROOT"] = str(self.worktrees)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved_env)
|
||||
self.temp.cleanup()
|
||||
|
||||
def owner(self, role: str = "owner") -> OwnerContext:
|
||||
return OwnerContext(
|
||||
identity="owner@example.invalid",
|
||||
role=role,
|
||||
authorization_reference=OWNER_REF,
|
||||
)
|
||||
|
||||
def request(self, **updates):
|
||||
value = {
|
||||
"task_id": TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": self.base_oid,
|
||||
"target_branch": BRANCH,
|
||||
"owner_authorization_reference": OWNER_REF,
|
||||
"one_time_use": True,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch",
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite",
|
||||
],
|
||||
}
|
||||
value.update(updates)
|
||||
return value
|
||||
|
||||
def bridge(self):
|
||||
return AuthorizedTaskWriterProvisioner(self.config)
|
||||
|
||||
def test_success_creates_exact_branch_and_scoped_attestation(self):
|
||||
result = self.bridge().provision(self.request(), self.owner())
|
||||
self.assertEqual("READY_FOR_CODE_AUTHORING", result["status"])
|
||||
self.assertEqual(self.base_oid, result["branch_oid"])
|
||||
self.assertTrue(result["grant_consumed"])
|
||||
self.assertFalse(result["writer_scope"]["allow_main_write"])
|
||||
self.assertEqual(
|
||||
self.base_oid,
|
||||
run("git", "--git-dir", str(self.git_dir), "rev-parse", f"refs/heads/{BRANCH}"),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.base_oid,
|
||||
run("git", "--git-dir", str(self.git_dir), "rev-parse", "refs/heads/main"),
|
||||
)
|
||||
|
||||
def test_idempotent_retry_does_not_replay_provisioner(self):
|
||||
first = self.bridge().provision(self.request(), self.owner())
|
||||
second = self.bridge().provision(self.request(), self.owner())
|
||||
self.assertFalse(first["idempotent"])
|
||||
self.assertTrue(second["idempotent"])
|
||||
self.assertEqual(first["grant_id"], second["grant_id"])
|
||||
|
||||
def test_non_owner_is_denied_before_tool_execution(self):
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(self.request(), self.owner(role="agent"))
|
||||
self.assertEqual("OWNER_AUTHORIZATION_REQUIRED", captured.exception.code)
|
||||
self.assertFalse(self.state.exists())
|
||||
|
||||
def test_production_capability_is_rejected(self):
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(
|
||||
self.request(production_change_authorized=True), self.owner()
|
||||
)
|
||||
self.assertEqual("PRODUCTION_CAPABILITY_REJECTED", captured.exception.code)
|
||||
|
||||
def test_authoritative_main_drift_is_fail_closed(self):
|
||||
(self.seed / "README.md").write_text("drift\n", encoding="utf-8")
|
||||
run("git", "commit", "-am", "drift", cwd=self.seed)
|
||||
run("git", "push", "origin", "main", cwd=self.seed)
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(self.request(), self.owner())
|
||||
self.assertEqual("AUTHORIZED_BASE_OID_DRIFT", captured.exception.code)
|
||||
|
||||
def test_existing_unreceipted_branch_is_conflict(self):
|
||||
run(
|
||||
"git", "--git-dir", str(self.git_dir), "branch", BRANCH, self.base_oid
|
||||
)
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(self.request(), self.owner())
|
||||
self.assertEqual("EXISTING_BRANCH_OID_CONFLICT", captured.exception.code)
|
||||
|
||||
def test_overbroad_allowed_operations_are_rejected(self):
|
||||
allowed = self.request()["allowed_operations"] + ["tag_create"]
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(
|
||||
self.request(allowed_operations=allowed), self.owner()
|
||||
)
|
||||
self.assertEqual("ALLOWED_OPERATIONS_MISMATCH", captured.exception.code)
|
||||
|
||||
def test_tool_contract_must_expose_grant_dir_and_result_json(self):
|
||||
bad = executable(
|
||||
self.bin / "bad-signer",
|
||||
"#!/bin/sh\nif [ \"$1\" = --help ]; then echo --self-test; exit 0; fi\nexit 0\n",
|
||||
)
|
||||
config = ProvisioningConfig(
|
||||
repository_identity=REPOSITORY,
|
||||
authority_git_dir=self.git_dir,
|
||||
state_root=self.state,
|
||||
signer=bad,
|
||||
validator=self.validator,
|
||||
provisioner=self.provisioner,
|
||||
)
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
AuthorizedTaskWriterProvisioner(config).provision(
|
||||
self.request(), self.owner()
|
||||
)
|
||||
self.assertEqual("SIGNER_CONTRACT_UNSUPPORTED", captured.exception.code)
|
||||
|
||||
def test_negative_policy_failure_is_rejected(self):
|
||||
text = self.provisioner.read_text(encoding="utf-8")
|
||||
self.provisioner.write_text(
|
||||
text.replace(
|
||||
"scope = {\n",
|
||||
"negative['force_push_denied'] = False\nscope = {\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.provisioner.chmod(0o700)
|
||||
with self.assertRaises(ProvisioningError) as captured:
|
||||
self.bridge().provision(self.request(), self.owner())
|
||||
self.assertEqual("NEGATIVE_POLICY_TESTS_FAILED", captured.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from agent_pull_runner import AgentPullRunner, JsonStateStore, RunnerConfig, RunnerError
|
||||
from autonomy_notification_plane import DurableOutbox, NotificationPlane
|
||||
from governed_merge_gate import MergeGateDenied, validate_protected_merge
|
||||
from reconciliation_controller import ReconciliationController
|
||||
from recurring_task_scheduler import RecurringTaskError, RecurringTaskScheduler
|
||||
from release_state_machine import InvalidTransition, ReleaseState, ReleaseStateMachine
|
||||
|
||||
|
||||
class Transport:
|
||||
def __init__(self, task=None): self.task = task; self.calls = []
|
||||
def register_worker(self, worker_id, capabilities): self.calls.append("register"); return {"status": "registered"}
|
||||
def heartbeat(self, worker_id): self.calls.append("heartbeat"); return {"status": "alive"}
|
||||
def long_poll(self, worker_id, timeout_seconds): self.calls.append(("long_poll", timeout_seconds)); return self.task
|
||||
def poll(self, worker_id): self.calls.append("poll"); return self.task
|
||||
def claim(self, worker_id, task): self.calls.append("claim"); return {**task, "lease_id": "lease-1"}
|
||||
def renew(self, worker_id, task_id): self.calls.append("renew"); return {"status": "renewed", "task_id": task_id}
|
||||
def report_progress(self, worker_id, task_id, payload): self.calls.append("progress"); return {"status": "ok"}
|
||||
def report_result(self, worker_id, task_id, payload): self.calls.append("result"); return {"status": "ok", "task_id": task_id}
|
||||
def report_evidence(self, worker_id, task_id, payload): self.calls.append("evidence"); return {"status": "ok"}
|
||||
|
||||
|
||||
class RunnerTests(unittest.TestCase):
|
||||
def test_registration_heartbeat_and_long_poll(self):
|
||||
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1", fallback_seconds=60), t)
|
||||
self.assertEqual(r.register()["status"], "registered"); self.assertEqual(r.heartbeat()["status"], "alive")
|
||||
self.assertEqual(r.discover_once()["lease_id"], "lease-1"); self.assertIn(("long_poll", 30), t.calls)
|
||||
|
||||
def test_fallback_poll_after_long_poll_failure(self):
|
||||
class Broken(Transport):
|
||||
def long_poll(self, worker_id, timeout_seconds): raise TimeoutError
|
||||
t = Broken({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); self.assertIn("poll", t.calls)
|
||||
|
||||
def test_jitter_and_bounded_backoff(self):
|
||||
r = AgentPullRunner(RunnerConfig("w1", fallback_seconds=60, jitter_seconds=5, max_backoff_seconds=100), Transport(), random_fn=lambda: 1.0)
|
||||
self.assertEqual(r.discovery_delay(0), 65); self.assertEqual(r.discovery_delay(8), 100)
|
||||
|
||||
def test_claim_lease_renew_progress_result_and_evidence(self):
|
||||
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); r.renew("t1"); r.report_progress("t1", {"percent": 50}); r.report_evidence("t1", {"sha256": "a"}); r.report_result("t1", {"ok": True}); self.assertNotIn("t1", r.state.inflight)
|
||||
|
||||
def test_duplicate_terminal_is_suppressed(self):
|
||||
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); r.report_result("t1", {"ok": True}); before = list(t.calls); self.assertEqual(r.report_result("t1", {"ok": True})["status"], "duplicate_terminal_suppressed"); self.assertEqual(before, t.calls)
|
||||
|
||||
def test_restart_recovery_renews_inflight(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
store = JsonStateStore(Path(d) / "runner.json"); t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t, store); r.discover_once(); r2 = AgentPullRunner(RunnerConfig("w1"), t, store); r2.recover_after_restart(); self.assertIn("renew", t.calls)
|
||||
|
||||
def test_executor_is_argv_only_and_allowlisted(self):
|
||||
r = AgentPullRunner(RunnerConfig("w1", executable_allowlist=("/usr/local/bin",), working_directory_allowlist=("/tmp",)), Transport())
|
||||
with self.assertRaises(RunnerError): r.validate_execution(["echo", "x"], "/tmp", {})
|
||||
with self.assertRaises(RunnerError): r.validate_execution(["/bin/sh", "-c", "echo x"], "/tmp", {})
|
||||
|
||||
|
||||
class NotificationTests(unittest.TestCase):
|
||||
def test_durable_owner_and_gpt_outbox(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
outbox = DurableOutbox(Path(d) / "outbox.sqlite3"); plane = NotificationPlane(outbox); ids = plane.emit("task_started", {"task_id": "t1"}, event_id="e1"); self.assertEqual(len(ids), 2); self.assertEqual({e.recipient for e in outbox.due()}, {"owner", "gpt"}); outbox.close()
|
||||
|
||||
def test_delivery_retry_and_ack(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
outbox = DurableOutbox(Path(d) / "outbox.sqlite3")
|
||||
try:
|
||||
plane = NotificationPlane(outbox); plane.emit("failed", {}, event_id="e2"); attempts = [0]
|
||||
def send(event):
|
||||
attempts[0] += 1
|
||||
if attempts[0] == 1: raise RuntimeError("temporary")
|
||||
now = time.time(); plane.deliver_once(send, now=now); self.assertEqual(outbox.due(now=now), []); self.assertEqual(outbox.due(now=now + 3)[0].attempts, 1); plane.deliver_once(send, now=now + 3); self.assertEqual(outbox.due(now=now + 3), [])
|
||||
finally:
|
||||
outbox.close()
|
||||
|
||||
|
||||
class RecurringTests(unittest.TestCase):
|
||||
def test_next_run_count_and_max_runs(self):
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc); s = RecurringTaskScheduler(); task = s.schedule("t", 60, now=now, max_runs=2); self.assertEqual(len(s.due(now=now)), 1); s.mark_run("t", now=now); self.assertEqual(task.run_count, 1); self.assertEqual(s.due(now=now), []); s.mark_run("t", now=now + timedelta(seconds=60)); self.assertEqual(s.due(now=now + timedelta(seconds=120)), [])
|
||||
|
||||
def test_cancellation_and_idempotent_successor(self):
|
||||
s = RecurringTaskScheduler(); s.schedule("t", 10); s.cancel("t"); self.assertEqual(s.due(), []); a = s.create_successor_once("t", "next", interval_seconds=10); b = s.create_successor_once("t", "next", interval_seconds=10); self.assertIs(a, b)
|
||||
|
||||
def test_bounds(self):
|
||||
with self.assertRaises(RecurringTaskError): RecurringTaskScheduler().schedule("t", 0)
|
||||
|
||||
|
||||
class StateAndReconciliationTests(unittest.TestCase):
|
||||
def test_valid_state_sequence(self):
|
||||
m = ReleaseStateMachine();
|
||||
for state in (ReleaseState.TESTED, ReleaseState.PR_OPEN, ReleaseState.PR_APPROVED, ReleaseState.MERGED, ReleaseState.ARTIFACT_VERIFIED, ReleaseState.RELEASE_APPROVED, ReleaseState.TAG_VERIFIED): m.transition(state)
|
||||
self.assertEqual(m.state, ReleaseState.TAG_VERIFIED)
|
||||
|
||||
def test_invalid_shortcut_and_production_fail_closed(self):
|
||||
with self.assertRaises(InvalidTransition): ReleaseStateMachine().transition(ReleaseState.PRODUCTION_ACCEPTED)
|
||||
m = ReleaseStateMachine(ReleaseState.DEPLOYED)
|
||||
with self.assertRaises(InvalidTransition): m.transition(ReleaseState.PRODUCTION_ACCEPTED, {"tag_verified": True, "remote_tag_readback": True, "service_health": False})
|
||||
|
||||
def test_reconciliation_reports_all_drift_actions(self):
|
||||
report = ReconciliationController().reconcile({"authority_main": "a", "gitea_main": "b", "local_tag_oid": "t", "remote_tag_oid": "t", "remote_peeled_target": "x", "approved_target_oid": "y", "registry_version": "V2", "baseline_version": "V1", "approved_artifact_digest": "d", "deployed_artifact_digest": "d", "declared_production_state": True, "service_healthy": False})
|
||||
self.assertFalse(report.healthy); self.assertIn("main_drift", report.drift); self.assertIn("notify_owner", report.actions)
|
||||
|
||||
|
||||
class MergeGateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.pr = {"state": "open", "mergeable": True, "user": {"login": "codex"}}
|
||||
self.reviews = [{"id": 9, "state": "APPROVED", "user": {"login": "owner", "id": 2}}]
|
||||
self.checks = {name: "success" for name in ("autonomy-unit-tests", "secret-scan")}
|
||||
|
||||
def test_owner_review_and_checks_allow(self):
|
||||
decision = validate_protected_merge(self.pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0); self.assertTrue(decision.allowed)
|
||||
|
||||
def test_owner_review_required(self):
|
||||
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, [], self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
||||
|
||||
def test_unresolved_conversations_and_missing_checks_deny(self):
|
||||
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=1)
|
||||
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, self.reviews, {}, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
||||
|
||||
def test_self_approval_denied(self):
|
||||
pr = {**self.pr, "user": {"login": "owner"}}
|
||||
with self.assertRaises(MergeGateDenied): validate_protected_merge(pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Contract tests for branch-bound repair leases (16 tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from branch_repair_lease import (
|
||||
MAX_LEASE_SECONDS,
|
||||
TASK_ID,
|
||||
GitProbeProtocol,
|
||||
LeaseError,
|
||||
LeaseStore,
|
||||
RepairLeaseManager,
|
||||
normalize_path,
|
||||
path_is_protected,
|
||||
)
|
||||
|
||||
|
||||
class FakeGit(GitProbeProtocol):
|
||||
def __init__(self) -> None:
|
||||
self.heads = {"fix/lease": "a" * 40, "candidate": "a" * 40}
|
||||
self.parents: dict[str, str] = {}
|
||||
self.paths: dict[str, tuple[str, ...]] = {}
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
return self.heads[branch]
|
||||
|
||||
def parent(self, commit: str) -> str:
|
||||
return self.parents[commit]
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
current = newer
|
||||
seen: set[str] = set()
|
||||
while current not in seen:
|
||||
if current == older:
|
||||
return True
|
||||
seen.add(current)
|
||||
current = self.parents.get(current, "")
|
||||
if not current:
|
||||
return False
|
||||
return False
|
||||
|
||||
def changed_paths(self, commit: str) -> tuple[str, ...]:
|
||||
return self.paths[commit]
|
||||
|
||||
def commits_between(self, older: str, newer: str) -> tuple[str, ...]:
|
||||
result: list[str] = []
|
||||
current = newer
|
||||
while current != older:
|
||||
result.append(current)
|
||||
current = self.parents[current]
|
||||
return tuple(reversed(result))
|
||||
|
||||
|
||||
class LeaseTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.now = datetime(2026, 8, 6, 7, 0, tzinfo=timezone.utc)
|
||||
self.git = FakeGit()
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.manager = RepairLeaseManager(LeaseStore(Path(self.temp.name) / "leases"), self.git, clock=lambda: self.now)
|
||||
self.paths = ("active-runtime/app/branch_repair_lease.py",)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def issue(self):
|
||||
return self.manager.issue(
|
||||
task_id=TASK_ID,
|
||||
owner_identity="aiman",
|
||||
branch="fix/lease",
|
||||
description="repair",
|
||||
allowed_paths=self.paths,
|
||||
ttl_seconds=3600,
|
||||
)
|
||||
|
||||
def test_01_issue_persists_exact_head(self) -> None:
|
||||
lease = self.issue()
|
||||
self.assertEqual(lease.current_head_oid, "a" * 40)
|
||||
self.assertEqual(self.manager.store.load(lease.lease_id).state, "ACTIVE")
|
||||
|
||||
def test_02_main_branch_rejected(self) -> None:
|
||||
self.git.heads["main"] = "a" * 40
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.issue(task_id=TASK_ID, owner_identity="aiman", branch="main", description="x", allowed_paths=self.paths)
|
||||
|
||||
def test_03_ttl_maximum_enforced(self) -> None:
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.issue(task_id=TASK_ID, owner_identity="aiman", branch="fix/lease", description="x", allowed_paths=self.paths, ttl_seconds=MAX_LEASE_SECONDS + 1)
|
||||
|
||||
def test_04_attach_requires_exact_head(self) -> None:
|
||||
lease = self.issue()
|
||||
attached = self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
self.assertTrue(attached.attached)
|
||||
|
||||
def test_05_attach_stale_head_rejected(self) -> None:
|
||||
lease = self.issue()
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="b" * 40)
|
||||
|
||||
def test_06_commit_requires_attach(self) -> None:
|
||||
lease = self.issue()
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.authorize_commit(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40, paths=self.paths)
|
||||
|
||||
def test_07_prohibited_path_rejected(self) -> None:
|
||||
self.assertTrue(path_is_protected("VERSION"))
|
||||
lease = self.issue()
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.authorize_commit(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40, paths=("VERSION",))
|
||||
|
||||
def test_08_secret_content_rejected(self) -> None:
|
||||
lease = self.issue()
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.authorize_commit(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40, paths=self.paths, content={self.paths[0]: "tok" + "en=" + "x" * 24})
|
||||
|
||||
def test_09_first_commit_is_fast_forward(self) -> None:
|
||||
lease = self.issue()
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
b = "b" * 40
|
||||
self.git.parents[b] = "a" * 40
|
||||
self.git.paths[b] = self.paths
|
||||
self.git.heads["fix/lease"] = b
|
||||
updated = self.manager.record_commit(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40, new_head_oid=b, paths=self.paths)
|
||||
self.assertEqual(updated.current_head_oid, b)
|
||||
|
||||
def test_10_multiple_commits_allowed(self) -> None:
|
||||
self.test_09_first_commit_is_fast_forward()
|
||||
lease_id = next(Path(self.temp.name, "leases").glob("lease-*.json")).stem
|
||||
b = "b" * 40
|
||||
c = "c" * 40
|
||||
self.git.parents[b] = "a" * 40; self.git.parents[c] = b
|
||||
self.git.paths[b] = self.paths; self.git.paths[c] = self.paths
|
||||
self.git.heads["fix/lease"] = c
|
||||
updated = self.manager.record_commit(lease_id, owner_identity="aiman", expected_head_oid=b, new_head_oid=c, paths=self.paths)
|
||||
self.assertEqual(updated.revision, 3)
|
||||
|
||||
def test_11_stale_head_rejected(self) -> None:
|
||||
lease = self.issue()
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
self.git.heads["fix/lease"] = "b" * 40
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.authorize_commit(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40, paths=self.paths)
|
||||
|
||||
def test_12_recover_descendant(self) -> None:
|
||||
lease = self.issue()
|
||||
self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
b = "b" * 40; self.git.parents[b] = "a" * 40; self.git.paths[b] = self.paths; self.git.heads["fix/lease"] = b
|
||||
recovered = self.manager.recover(lease.lease_id, owner_identity="aiman")
|
||||
self.assertEqual(recovered.current_head_oid, b)
|
||||
|
||||
def test_13_recover_protected_change_fails_closed(self) -> None:
|
||||
lease = self.issue(); self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
b = "b" * 40; self.git.parents[b] = "a" * 40; self.git.paths[b] = ("VERSION",); self.git.heads["fix/lease"] = b
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.recover(lease.lease_id, owner_identity="aiman")
|
||||
|
||||
def test_14_restart_recovers_persisted_lease(self) -> None:
|
||||
lease = self.issue(); self.manager.attach_existing_branch(lease.lease_id, owner_identity="aiman", expected_head_oid="a" * 40)
|
||||
restarted = RepairLeaseManager(self.manager.store, self.git, clock=lambda: self.now)
|
||||
self.assertTrue(restarted.store.load(lease.lease_id).attached)
|
||||
|
||||
def test_15_renew_respects_absolute_cap(self) -> None:
|
||||
lease = self.issue()
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.renew(lease.lease_id, owner_identity="aiman", ttl_seconds=MAX_LEASE_SECONDS + 1)
|
||||
|
||||
def test_16_complete_and_revoke_are_terminal(self) -> None:
|
||||
completed = self.issue()
|
||||
self.assertEqual(self.manager.complete(completed.lease_id, owner_identity="aiman").state, "COMPLETED")
|
||||
with self.assertRaises(LeaseError):
|
||||
self.manager.recover(completed.lease_id, owner_identity="aiman")
|
||||
revoked = self.issue()
|
||||
self.assertEqual(self.manager.revoke(revoked.lease_id, owner_identity="aiman").state, "REVOKED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user