Compare commits
@@ -1,7 +1,7 @@
|
||||
e4a1f86ef92ec1d3ff724eb75cf9c01758c1cc997e1afadc1c8b561bce5b813a FULL_PACKAGE_MANIFEST.json
|
||||
c59c0288d4d999fbd79a2f275d4fb8c369029cb8bb721f95408308cc51eaa3a2 README.md
|
||||
1095953d5764d9c72f02e84b1d525e265d3a67a5bdb451ab9ef51a8e8b3032b8 VERSION
|
||||
ee6e4fc6bc98a590fbc3585b62707e2562115e5a3f722e69a7c464d966bdc458 active-runtime/app/aios_gitops_model_api.py
|
||||
b090062a92aeb14211bbfc1fcd43b5ee4f315d30054b437f3bcdac657210ee95 VERSION
|
||||
a664714bc1e13160cdd0dc32de460c65a83a3a95aa5cdad1833b1b650ab90e9d active-runtime/app/aios_gitops_model_api.py
|
||||
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_v5_webops_app.py
|
||||
0ffadf6ee1f297842bc44c6e753010fd2b7b02b1f5fb0d7e0676b7bec898db08 active-runtime/app/aios_webops_app.py
|
||||
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b active-runtime/migrations/001_webops_core_sqlite.sql
|
||||
@@ -13,7 +13,7 @@ ee6e4fc6bc98a590fbc3585b62707e2562115e5a3f722e69a7c464d966bdc458 adapters/gitop
|
||||
013813b58d7844f3860d6f9bb484f84f92487652b573976c474bb35c91df8ae8 adapters/openclaw/openclaw_gitops_commands.py
|
||||
7a34ccdfdcdc132fe5daf01cd1acd9d9b7adbd0429d575b65e0771962ccedc62 adapters/webops/gitops_model_menu_fragment.html
|
||||
86b0cb6ee3192565b080672e5305dabba1283486442e630c71244ebac4910916 config/digital_life.yaml
|
||||
a3872f24e4cd64288758846ecd0b374db4aa41111feec3b17c41438136d6d5d0 config/domains.env
|
||||
5a27d48db343dcb723bcc1d9d8222aa3f72094c57cc43640aebe6558963bd20f config/domains.env
|
||||
7a6f82cbddfc5c74bd37229e4f37fe4e73430ae585b4846abd1a80d852f2fed0 config/gitops_short_links.yaml
|
||||
4139d915bdd733171734d5d5d3411e6fc4f8f202c49af4321d706da176da690b config/model_registry.yaml
|
||||
8d6202d3da60366c96a208f5e5eed1f09fd36d122f4cabdc038c21ce2e72ea16 config/npm_custom_locations.yaml
|
||||
@@ -55,7 +55,7 @@ d8cea35eb6c1300d20804ef480312d26dbdc2ad3a2d26b9430c6ca4808155a95 fixes/patch_ru
|
||||
0eb12e0161371464605577707c84df413b555277933f9eb4488ab38032ff699b migrations/001_webops_core_sqlite.sql
|
||||
6d61da88e7d6b208e7eee7195b521dc47cb68587f91987d7a0dd1c8801ef168d migrations/002_seed_owner_admin_user.sql
|
||||
a2c0c7699e1eb46cfdeabfc905cb79b71ed14fb6bcf81ecc99ec47975cf05767 migrations/004_gitops_custom_llm_runtime.sql
|
||||
b77bce1ca78cede27de6594e25f6ce5bb0c4990c0953eeb79f7e2608d2515c32 package_verify_full.sh
|
||||
ca49f4165b50171968485ffa5535bc33d2158237ea55af10a3888a5fa0f7a535 package_verify_full.sh
|
||||
f65b7fba5d6bdac3070ff4309830e286cd6319a80ab2e55263e46022fed2bf7f scripts/apply_migrations.py
|
||||
bc0452d261def2366cdefd845e62d7177b42806f93ba78294ece29a8a369157e scripts/backup_before_fix.sh
|
||||
bdb439086c86b3b23f8240cb2bef0f757a6be242d21d13a79eb1e44434efbe2e scripts/create_patch.sh
|
||||
@@ -63,11 +63,11 @@ bdb439086c86b3b23f8240cb2bef0f757a6be242d21d13a79eb1e44434efbe2e scripts/create
|
||||
1fdeecf0315f3ab4ec461c3376128054df9ac48e2fe5a3f64a138cd7d710d44d scripts/generate_release_manifest.py
|
||||
80c6c10f259245681c42932f3f5f9b9a0fbd9cadce6a3f495d82cbdbc8618c73 scripts/healthcheck.sh
|
||||
3795cadfa6429a434c955913dbdcbf552181c88301a196b4b458f4f5dd206cd8 scripts/init_gitops_baseline.sh
|
||||
4d54a4ed6d35bbb6e9fe2924f3cfceeea9c3eed898017788f6c5ab8041394a47 scripts/install_v1r1c0_gitops_model_overlay.sh
|
||||
e76cd986d2387b76439e4a7fe503e48a2de8fc70fb52c45a13641c9d943f108f scripts/install_v1r1c0_gitops_model_overlay.sh
|
||||
0064539ac1e1bd32dc8062ecc307ad2bc8520895009583a72d46868d1cf44e3c scripts/install_webops_overlay.sh
|
||||
9fadafc4b4d098c006d159cb3b401eb19cf9ab9c5fa4b5cb109e946501c0b8e1 scripts/owner_wechat_notify_worker.py
|
||||
e0abd8ad44d4f088108f2046fd9d7bbd4465af50f79bd3b9fcbb72a8c61bee65 scripts/package_contract_check_v1r1c0.sh
|
||||
abef8df642c6d233af4a03c512876b02b326e19c924020b8dd8cc74236d18817 scripts/release_version.sh
|
||||
f537c58fc08685601f47fac5c0a3a2856e3ce9806c1918848c87a194a49c28a0 scripts/package_contract_check_v1r1c0.sh
|
||||
14ab40a7c342e21e8315def14d552dcff4b214d23f1f4be493e9d3fc2cee1a2f scripts/release_version.sh
|
||||
bbdb3f9992b203ddc2a6a83241052663de4f021d0705724bd9df05acaa99b022 scripts/rollback_last_delta.sh
|
||||
0814c26ecf6732cf2c24f5f414ad0128a159768cfbbeb21d557274064feb1188 scripts/rollback_version.sh
|
||||
da84a5cc61c1d54a7d1e96e4b0a7186e192edf3ec12d694fc9d7608c3b1086ef scripts/run_acceptance_with_autofix.sh
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Task-scoped continuous authorization controller for the AIOS Candidate writer."""
|
||||
from __future__ import annotations
|
||||
import base64, fcntl, hashlib, json, os, pwd, grp, re, socket, sqlite3, stat, subprocess, sys, tempfile, time, urllib.error, urllib.parse, urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
TASK='AIOS-AUTONOMOUS-RELEASE-FULL-IMPLEMENTATION-20260726'
|
||||
AUTH_ID='authz-aios-autonomous-release-20260726'
|
||||
BRANCH='patch/chatgpt-autonomous-completion-contract-20260726'
|
||||
BASE='875d41c3101093149414292067fd700dc214d4d3'
|
||||
REPO='aios/aios'
|
||||
WRITER_AUDIENCE='chatgpt-autonomous-release-writer'
|
||||
ACTOR='chatgpt-gitops-bot'
|
||||
WRITER_INSTANCE='writer-6e32f321-1240-46f8-9693-0b0631d9c77b'
|
||||
CHILD_OPERATION_MAX_USES=20
|
||||
ROOT=Path('/data/aios-gitops')
|
||||
WORKTREE=ROOT/'worktrees/chatgpt-autonomous-completion-contract-20260726'
|
||||
BARE=ROOT/'repo.git'
|
||||
TASK_ROOT=ROOT/'runtime/tasks'/TASK
|
||||
AUTH_DIR=Path('/etc/aios-gitops/task-authorizations')
|
||||
PARENT=AUTH_DIR/'authz-aios-autonomous-release-20260726.json'
|
||||
STATE_DIR=Path('/var/lib/aios-autonomous-release-authorization')
|
||||
STATE=STATE_DIR/'state.json'
|
||||
LOCK=STATE_DIR/'controller.lock'
|
||||
AUDIT=TASK_ROOT/'continuous-authorization.audit.jsonl'
|
||||
SOCKET_DIR=Path('/run/aios-autonomous-release-authorization')
|
||||
SOCKET_PATH=SOCKET_DIR/'controller.sock'
|
||||
PROJECTION=Path('/etc/aios-gitops/credentials/chatgpt-autonomous-release-writer')
|
||||
REGISTRY=Path('/var/lib/aios-chatgpt-autonomous-release-writer/token-registry.sqlite3')
|
||||
WRITER_LEDGER=Path('/var/lib/aios-chatgpt-autonomous-release-writer/operation-ledger.sqlite3')
|
||||
ASKPASS=Path('/usr/libexec/aios-autonomous-release-authorization/gitea_askpass.py')
|
||||
PATH_POLICY=Path('/etc/aios-gitops/policies-chatgpt-autonomous-release/autonomous-release-path-policy.json')
|
||||
ROUTE_BINDING=Path('/etc/aios-gitops/action-route-bindings/chatgpt-autonomous-release-20260726.json')
|
||||
OID_RE=re.compile(r'^[0-9a-f]{40}$')
|
||||
OP_RE=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$')
|
||||
TERMINAL={'COMPLETED','CANCELLED','FAILED_FINAL','REVOKED','REVOKED_IDLE_TIMEOUT','REVOKED_WALL_CLOCK','RECONCILIATION_REQUIRED'}
|
||||
|
||||
sys.path.insert(0,'/usr/libexec/aios-chatgpt-autonomous-release-writer-fullprofile-8ac2f90f')
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
def now() -> datetime: return datetime.now(timezone.utc)
|
||||
def stamp() -> str: return now().replace(microsecond=0).isoformat()
|
||||
def canonical(value: Any) -> bytes: return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')
|
||||
def fail(code: str, status: int=503, **details: Any) -> None: raise ControlError(code,status,details)
|
||||
class ControlError(RuntimeError):
|
||||
def __init__(self,code:str,status:int=503,details:dict[str,Any]|None=None): self.code,self.status,self.details=code,status,details or {}; super().__init__(code)
|
||||
|
||||
def owned_regular(path:Path, mode:int) -> os.stat_result:
|
||||
try: info=os.lstat(path)
|
||||
except FileNotFoundError: fail('authorization_config_missing')
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)&~mode: fail('authorization_config_permissions_rejected')
|
||||
return info
|
||||
|
||||
def load_json(path:Path,mode:int=0o600) -> dict[str,Any]:
|
||||
owned_regular(path,mode)
|
||||
try: value=json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception: fail('authorization_config_json_rejected')
|
||||
if not isinstance(value,dict): fail('authorization_config_schema_rejected')
|
||||
return value
|
||||
|
||||
def atomic_json(path:Path,value:dict[str,Any],mode:int=0o600) -> None:
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
raw=canonical(value)+b'\n'
|
||||
fd,tmp=tempfile.mkstemp(prefix='.new-',dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd,mode); os.write(fd,raw); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,path)
|
||||
os.chmod(path,mode)
|
||||
finally:
|
||||
try: os.close(fd)
|
||||
except OSError: pass
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
def append_audit(event:dict[str,Any]) -> None:
|
||||
TASK_ROOT.mkdir(parents=True,exist_ok=True)
|
||||
flags=os.O_WRONLY|os.O_CREAT|os.O_APPEND|getattr(os,'O_NOFOLLOW',0)
|
||||
fd=os.open(AUDIT,flags,0o600)
|
||||
try: os.write(fd,canonical({'at':stamp(),'task_id':TASK,'secrets_included':False,**event})+b'\n'); os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
|
||||
def parent() -> dict[str,Any]:
|
||||
p=load_json(PARENT)
|
||||
required={'schema_version','development_authorization_id','authorization_type','task_id','actor_identity','audience','repository','exact_branch','approved_base_oid','allowed_path_roots','allowed_exact_paths','allowed_operations','created_at','valid_until','maximum_wall_clock_seconds','idle_timeout_seconds','manual_reapproval_per_commit','maximum_concurrent_writes','path_policy_sha256','writer_instance_id'}
|
||||
if set(p)!=required or p.get('schema_version')!=1: fail('parent_authorization_schema_rejected')
|
||||
fixed={'development_authorization_id':AUTH_ID,'authorization_type':'TASK_SCOPED_CONTINUOUS_DEVELOPMENT','task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'exact_branch':BRANCH,'approved_base_oid':BASE,'manual_reapproval_per_commit':False,'maximum_concurrent_writes':1,'writer_instance_id':WRITER_INSTANCE}
|
||||
if any(p.get(k)!=v for k,v in fixed.items()): fail('parent_authorization_binding_rejected')
|
||||
if p.get('maximum_wall_clock_seconds')!=86400 or p.get('idle_timeout_seconds')!=2700: fail('parent_authorization_lifetime_rejected')
|
||||
if not isinstance(p.get('allowed_path_roots'),list) or not isinstance(p.get('allowed_exact_paths'),list) or not isinstance(p.get('allowed_operations'),list): fail('parent_authorization_scope_rejected')
|
||||
raw=PATH_POLICY.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=p.get('path_policy_sha256'): fail('parent_authorization_path_policy_changed')
|
||||
return p
|
||||
|
||||
def parse_time(v:Any) -> datetime:
|
||||
try:
|
||||
d=datetime.fromisoformat(str(v).replace('Z','+00:00'))
|
||||
return d.astimezone(timezone.utc)
|
||||
except Exception: fail('authorization_time_rejected')
|
||||
|
||||
def components(path:str)->tuple[str,...]:
|
||||
if not isinstance(path,str) or not path or path!=path.strip() or '\\' in path or '\x00' in path: fail('authorization_path_rejected',403)
|
||||
pp=PurePosixPath(path); wp=PureWindowsPath(path)
|
||||
parts=tuple(path.split('/'))
|
||||
if pp.is_absolute() or wp.is_absolute() or bool(wp.drive) or any(x in {'','.','..'} for x in parts): fail('authorization_path_rejected',403)
|
||||
return parts
|
||||
|
||||
def path_allowed(p:dict[str,Any],path:str)->bool:
|
||||
item=components(path)
|
||||
for root in p['allowed_path_roots']:
|
||||
r=components(str(root).rstrip('/'))
|
||||
if item[:len(r)]==r: return True
|
||||
return any(item==components(x) for x in p['allowed_exact_paths'])
|
||||
|
||||
def git(*args:str,cwd:Path|None=None,env:dict[str,str]|None=None)->str:
|
||||
e=os.environ.copy(); e.update(env or {}); e['GIT_CONFIG_COUNT']='1'; e['GIT_CONFIG_KEY_0']='safe.directory'; e['GIT_CONFIG_VALUE_0']=str(WORKTREE)
|
||||
r=subprocess.run(['git',*args],cwd=cwd,text=True,capture_output=True,env=e)
|
||||
if r.returncode: fail('authorization_git_probe_failed',503)
|
||||
return r.stdout.strip()
|
||||
|
||||
def credential_lines()->list[str]:
|
||||
cp=Path(os.environ.get('CREDENTIALS_DIRECTORY',''))/'gitea_git_http'
|
||||
try:
|
||||
info=os.lstat(cp); lines=cp.read_text(encoding='utf-8').splitlines()
|
||||
except OSError: fail('authorization_gitea_credential_unavailable')
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or len(lines)!=2 or lines[0]!='gitops-bot' or not lines[1]: fail('authorization_gitea_credential_rejected')
|
||||
return lines
|
||||
|
||||
def remote_oid()->str:
|
||||
credential_lines()
|
||||
e={'GIT_ASKPASS':str(ASKPASS),'GIT_TERMINAL_PROMPT':'0'}
|
||||
out=git('ls-remote','--heads','http://127.0.0.1:3000/aios/aios.git',f'refs/heads/{BRANCH}',cwd=WORKTREE,env=e)
|
||||
fields=out.split()
|
||||
if len(fields)!=2 or not OID_RE.fullmatch(fields[0]): fail('authorization_git_ls_remote_rejected')
|
||||
return fields[0]
|
||||
|
||||
def api_oid()->str:
|
||||
user,password=credential_lines(); branch=urllib.parse.quote(BRANCH,safe='')
|
||||
encoded=base64.b64encode((user+':'+password).encode()).decode()
|
||||
request=urllib.request.Request('http://127.0.0.1:3000/api/v1/repos/aios/aios/branches/'+branch,headers={'Authorization':'Basic '+encoded,'Host':'g.cnnetmkt.top'})
|
||||
try:
|
||||
with urllib.request.urlopen(request,timeout=10) as response: value=json.loads(response.read())
|
||||
except Exception: fail('authorization_gitea_api_unavailable')
|
||||
oid=str(value.get('commit',{}).get('id',''))
|
||||
if not OID_RE.fullmatch(oid): fail('authorization_gitea_api_oid_rejected')
|
||||
return oid
|
||||
|
||||
def four_way(expected:str)->None:
|
||||
local=git('rev-parse','HEAD',cwd=WORKTREE)
|
||||
bare=git('--git-dir',str(BARE),'rev-parse',f'refs/heads/{BRANCH}')
|
||||
remote=remote_oid(); api=api_oid()
|
||||
if any(x!=expected for x in (local,bare,remote,api)): fail('RECONCILIATION_REQUIRED',409,current_head_oid=local)
|
||||
if git('status','--porcelain',cwd=WORKTREE)!='': fail('authorization_worktree_dirty',409)
|
||||
|
||||
def state()->dict[str,Any]:
|
||||
s=load_json(STATE)
|
||||
required={'schema_version','development_authorization_id','task_id','status','current_head_oid','last_activity_at','writer_child','test_child','inflight','updated_at'}
|
||||
if set(s)!=required or s.get('schema_version')!=1 or s.get('development_authorization_id')!=AUTH_ID or s.get('task_id')!=TASK or not OID_RE.fullmatch(str(s.get('current_head_oid',''))): fail('authorization_state_schema_rejected')
|
||||
return s
|
||||
|
||||
def save_state(s:dict[str,Any])->None: s['updated_at']=stamp(); atomic_json(STATE,s)
|
||||
|
||||
def lock():
|
||||
STATE_DIR.mkdir(parents=True,exist_ok=True); os.chmod(STATE_DIR,0o700)
|
||||
f=open(LOCK,'a+b'); os.chmod(LOCK,0o600); fcntl.flock(f.fileno(),fcntl.LOCK_EX); return f
|
||||
|
||||
def route_binding_valid(p:dict[str,Any])->None:
|
||||
b=load_json(ROUTE_BINDING,0o640)
|
||||
identities=b.get('action_identities')
|
||||
if b.get('task_id')!=TASK or b.get('writer_instance_id')!=WRITER_INSTANCE or b.get('repository')!=REPO or b.get('branch')!=BRANCH or b.get('approved_base_oid')!=BASE or b.get('path_policy_sha256')!=p.get('path_policy_sha256') or not isinstance(identities,list) or len(identities)!=1 or identities[0].get('account')!=ACTOR:
|
||||
fail('authorization_route_binding_changed',409)
|
||||
|
||||
def task_live(p:dict[str,Any],s:dict[str,Any],*,touch:bool)->None:
|
||||
route_binding_valid(p)
|
||||
if s['status'] in TERMINAL: fail('authorization_task_terminal',409)
|
||||
if now()>=parse_time(p['valid_until']):
|
||||
s['status']='REVOKED_WALL_CLOCK'; save_state(s); cleanup(s,'wall_clock'); fail('authorization_task_terminal',409)
|
||||
if (now()-parse_time(s['last_activity_at'])).total_seconds()>p['idle_timeout_seconds']:
|
||||
s['status']='REVOKED_IDLE_TIMEOUT'; save_state(s); cleanup(s,'idle_timeout'); fail('authorization_task_terminal',409)
|
||||
if touch: s['last_activity_at']=stamp(); save_state(s)
|
||||
|
||||
def projection_token()->str|None:
|
||||
if not PROJECTION.exists(): return None
|
||||
try:
|
||||
info=os.lstat(PROJECTION)
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)!=0o600: fail('authorization_projection_rejected')
|
||||
token=PROJECTION.read_text(encoding='utf-8').strip()
|
||||
return token if len(token.encode())>=32 else None
|
||||
except OSError: fail('authorization_projection_rejected')
|
||||
|
||||
def write_projection(token:str)->None:
|
||||
PROJECTION.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd,tmp=tempfile.mkstemp(prefix='.writer-',dir=PROJECTION.parent)
|
||||
try:
|
||||
os.fchmod(fd,0o600); os.write(fd,(token+'\n').encode()); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,PROJECTION); os.chmod(PROJECTION,0o600)
|
||||
finally:
|
||||
try: os.close(fd)
|
||||
except OSError: pass
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
def post_revoke_401(token:str)->None:
|
||||
req=urllib.request.Request('http://127.0.0.1:19893/api/runtime-status',headers={'Authorization':'Bearer '+token})
|
||||
try: urllib.request.urlopen(req,timeout=5)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code==401:return
|
||||
except Exception: pass
|
||||
fail('authorization_old_credential_revocation_unverified')
|
||||
|
||||
def rotate_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
||||
old=projection_token(); registry=TokenRegistry(REGISTRY)
|
||||
old_record=registry.find(old) if old else None
|
||||
if old_record and (old_record.get('task_id')!=TASK or old_record.get('development_authorization_id')!=AUTH_ID):
|
||||
if parse_time(old_record.get('expires_at'))>=now(): fail('authorization_existing_projection_binding_rejected')
|
||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_historical_child_revoke_failed')
|
||||
post_revoke_401(old)
|
||||
append_audit({'event':'historical_expired_child_revoked','old_credential_http_status':401})
|
||||
old_record=None
|
||||
issued=now().replace(microsecond=0); expires=issued+timedelta(minutes=20)
|
||||
prefixes=[str(x).rstrip('/')+'/' for x in p['allowed_path_roots']]
|
||||
grant={'token_id':'continuous-child-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'credential_id':'credential-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'development_authorization_id':AUTH_ID,'audience':WRITER_AUDIENCE,'task_id':TASK,'actor_identity':ACTOR,'writer_instance_id':WRITER_INSTANCE,'repository_identity':REPO,'source_cidrs':['127.0.0.1/32'],'allowed_branch':BRANCH,'current_expected_head_oid':s['current_head_oid'],'approved_base_oid':BASE,'allowed_operations':p['allowed_operations'],'allowed_paths':p['allowed_exact_paths'],'allowed_path_prefixes':prefixes,'not_before':issued.isoformat(),'expires_at':expires.isoformat(),'renewable':False,'max_uses':{operation:CHILD_OPERATION_MAX_USES for operation in p['allowed_operations']},'issued_by':'task_authorization_controller'}
|
||||
token,record=registry.issue(grant)
|
||||
try:
|
||||
write_projection(token)
|
||||
if old_record:
|
||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_old_child_revoke_failed')
|
||||
post_revoke_401(old)
|
||||
except Exception:
|
||||
registry.revoke(str(record['token_id']))
|
||||
try: PROJECTION.unlink()
|
||||
except FileNotFoundError: pass
|
||||
raise
|
||||
s['writer_child']={'credential_id':record['credential_id'],'issued_at':record['not_before'],'expires_at':record['expires_at'],'current_expected_head_oid':s['current_head_oid'],'rotation_reason':reason}
|
||||
save_state(s); append_audit({'event':'child_grant_rotated','reason':reason,'current_head_oid':s['current_head_oid'],'old_grant_revoked':bool(old_record),'old_credential_http_status':401 if old_record else None})
|
||||
return token
|
||||
|
||||
def child_operation_quota_exhausted(rec:dict[str,Any],registry:TokenRegistry)->bool:
|
||||
token_id=str(rec.get('token_id',''))
|
||||
allowed=rec.get('allowed_operations')
|
||||
limits=rec.get('max_uses')
|
||||
if not token_id or not isinstance(allowed,list) or not isinstance(limits,dict): fail('authorization_child_quota_schema_rejected')
|
||||
for operation in allowed:
|
||||
maximum=limits.get(operation)
|
||||
if not isinstance(operation,str) or not isinstance(maximum,int) or maximum<1: fail('authorization_child_quota_schema_rejected')
|
||||
if registry.usage(token_id,operation)>=maximum: return True
|
||||
return False
|
||||
|
||||
def ensure_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
||||
token=projection_token(); meta=s.get('writer_child') or {}
|
||||
expired=True
|
||||
try: expired=(parse_time(meta.get('expires_at'))-now()).total_seconds()<=300
|
||||
except Exception: pass
|
||||
if not token or expired or meta.get('current_expected_head_oid')!=s['current_head_oid']:
|
||||
return rotate_child(p,s,reason)
|
||||
registry=TokenRegistry(REGISTRY)
|
||||
rec=registry.find(token)
|
||||
if not rec or rec.get('revoked') or rec.get('development_authorization_id')!=AUTH_ID or rec.get('current_expected_head_oid')!=s['current_head_oid']:
|
||||
return rotate_child(p,s,reason)
|
||||
if child_operation_quota_exhausted(rec,registry):
|
||||
return rotate_child(p,s,'operation_quota_exhausted')
|
||||
return token
|
||||
|
||||
def ledger_verified(operation_id:str,commit_oid:str)->None:
|
||||
with sqlite3.connect(WRITER_LEDGER) as db:
|
||||
row=db.execute('select operation_id,branch,commit_oid,state,verification_status from writer_operations where operation_id=?',(operation_id,)).fetchone()
|
||||
if not row or row[1]!=BRANCH or row[2]!=commit_oid or row[3]!='COMPLETED' or row[4]!='bare+gitea': fail('authorization_writer_ledger_rejected',409)
|
||||
|
||||
def reconcile(p:dict[str,Any],s:dict[str,Any],previous:str,new:str,operation_id:str)->dict[str,Any]:
|
||||
inflight=s.get('inflight')
|
||||
if not isinstance(inflight,dict) or inflight.get('previous_head_oid')!=previous or inflight.get('operation_id')!=operation_id: fail('authorization_inflight_rejected',409)
|
||||
if previous!=s['current_head_oid'] or not OID_RE.fullmatch(new): fail('authorization_head_transition_rejected',409)
|
||||
ledger_verified(operation_id,new); four_way(new)
|
||||
s['current_head_oid']=new; s['inflight']=None; s['last_activity_at']=stamp(); save_state(s)
|
||||
ensure_child(p,s,'head_advance')
|
||||
append_audit({'event':'authorization_head_advanced','previous_head_oid':previous,'new_commit_oid':new,'four_way_ref_status':True,'worktree_clean':True})
|
||||
return {'authorization_current_head_oid':new,'automatic_head_advance':True,'four_way_ref_status':True}
|
||||
|
||||
def prepare(p:dict[str,Any],s:dict[str,Any],data:dict[str,Any])->dict[str,Any]:
|
||||
required={'expected_current_head_oid','operation_id','idempotency_key','exact_path','content_digest','commit_message_digest','branch','path','message'}
|
||||
if set(data)!=required: fail('authorization_request_schema_rejected',403)
|
||||
if data['branch']!=BRANCH or data['path']!=data['exact_path'] or not path_allowed(p,str(data['path'])): fail('authorization_path_rejected',403)
|
||||
if data['expected_current_head_oid']!=s['current_head_oid'] or not OID_RE.fullmatch(str(data['expected_current_head_oid'])): fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
||||
if not OP_RE.fullmatch(str(data['operation_id'])) or not OP_RE.fullmatch(str(data['idempotency_key'])): fail('authorization_operation_id_rejected',403)
|
||||
if not all(isinstance(data[k],str) and re.fullmatch(r'[0-9a-f]{64}',data[k]) for k in ('content_digest','commit_message_digest')): fail('authorization_digest_rejected',403)
|
||||
if hashlib.sha256(data['message'].encode()).hexdigest()!=data['commit_message_digest']: fail('authorization_commit_message_digest_rejected',403)
|
||||
inflight=s.get('inflight')
|
||||
if inflight:
|
||||
if inflight.get('operation_id')==data['operation_id'] and inflight.get('idempotency_key')==data['idempotency_key']: return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
||||
fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
||||
four_way(s['current_head_oid'])
|
||||
s['inflight']={'operation_id':data['operation_id'],'idempotency_key':data['idempotency_key'],'previous_head_oid':s['current_head_oid'],'path':data['path'],'created_at':stamp()}; s['last_activity_at']=stamp(); save_state(s)
|
||||
append_audit({'event':'write_prepared','operation_id':data['operation_id'],'path':data['path'],'expected_head_oid':s['current_head_oid']})
|
||||
return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
||||
|
||||
def test_authorize(p:dict[str,Any],s:dict[str,Any],operation:str)->dict[str,Any]:
|
||||
allowed={'pytest_dormant','pytest_autonomous_release','compileall_autonomous_release','git_diff_check'}
|
||||
if operation not in allowed: fail('candidate_test_executor_operation_rejected',403)
|
||||
four_way(s['current_head_oid'])
|
||||
child=s.get('test_child') or {}; refresh=True
|
||||
try: refresh=(parse_time(child.get('expires_at'))-now()).total_seconds()<=300
|
||||
except Exception: pass
|
||||
if refresh:
|
||||
issued=now().replace(microsecond=0); child={'test_authorization_id':'test-authz-aios-autonomous-release-20260726','grant_id':'test-child-'+issued.strftime('%Y%m%dT%H%M%SZ'),'issued_at':issued.isoformat(),'expires_at':(issued+timedelta(minutes=20)).isoformat()}; s['test_child']=child
|
||||
s['last_activity_at']=stamp(); save_state(s); append_audit({'event':'test_authorized','operation':operation})
|
||||
return {'test_authorization_id':child['test_authorization_id'],'operation':operation,'expires_at':child['expires_at']}
|
||||
|
||||
def cleanup(s:dict[str,Any],reason:str)->None:
|
||||
old=projection_token()
|
||||
if old:
|
||||
reg=TokenRegistry(REGISTRY); rec=reg.find(old)
|
||||
if rec: reg.revoke(str(rec['token_id']))
|
||||
try: PROJECTION.unlink()
|
||||
except FileNotFoundError: pass
|
||||
try: post_revoke_401(old)
|
||||
except ControlError: pass
|
||||
for unit in ('aios-chatgpt-autonomous-release-route.service','aios-chatgpt-autonomous-release-writer.service'):
|
||||
subprocess.run(['/usr/bin/systemctl','stop',unit],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
||||
subprocess.run(['/usr/bin/systemctl','stop','aios-autonomous-release-dormant-test-executor@*.service'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
||||
try: ROUTE_BINDING.unlink()
|
||||
except FileNotFoundError: pass
|
||||
try: SOCKET_PATH.unlink()
|
||||
except FileNotFoundError: pass
|
||||
append_audit({'event':'authorization_cleanup','reason':reason,'projection_removed':not PROJECTION.exists(),'route_overlay_removed':not ROUTE_BINDING.exists(),'task_services_stop_requested':True})
|
||||
|
||||
def request(data:dict[str,Any])->dict[str,Any]:
|
||||
op=data.get('op'); f=lock()
|
||||
try:
|
||||
p=parent(); s=state(); task_live(p,s,touch=False)
|
||||
if op=='describe_authorization':
|
||||
four_way(s['current_head_oid'])
|
||||
return {'development_authorization_id':AUTH_ID,'task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'branch':BRANCH,'approved_base_oid':BASE,'authorization_status':s['status'],'current_head_oid':s['current_head_oid']}
|
||||
if op=='get_writer_token':
|
||||
four_way(s['current_head_oid']); token=ensure_child(p,s,'scheduled_or_requested_rotation'); return {'token':token,'current_head_oid':s['current_head_oid']}
|
||||
if op=='prepare_write':
|
||||
ensure_child(p,s,'pre_write_quota_check')
|
||||
return prepare(p,s,data.get('request') if isinstance(data.get('request'),dict) else {})
|
||||
if op=='reconcile_commit':
|
||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; return reconcile(p,s,str(r.get('previous_head_oid','')),str(r.get('new_commit_oid','')),str(r.get('operation_id','')))
|
||||
if op=='fail_write':
|
||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; inflight=s.get('inflight')
|
||||
if isinstance(inflight,dict) and inflight.get('operation_id')==r.get('operation_id'): s['inflight']=None; save_state(s)
|
||||
return {'cleared':True}
|
||||
if op=='authorize_test': return test_authorize(p,s,str(data.get('operation','')))
|
||||
fail('authorization_operation_rejected',403)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
||||
|
||||
def peer_allowed(conn:socket.socket)->bool:
|
||||
try:
|
||||
import struct
|
||||
_pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
||||
return uid==pwd.getpwnam('aiman').pw_uid and gid==grp.getgrnam('gitops').gr_gid
|
||||
except Exception: return False
|
||||
|
||||
def serve()->None:
|
||||
SOCKET_DIR.mkdir(parents=True,exist_ok=True); os.chown(SOCKET_DIR,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_DIR,0o750)
|
||||
try: SOCKET_PATH.unlink()
|
||||
except FileNotFoundError: pass
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
||||
server.bind(str(SOCKET_PATH)); os.chown(SOCKET_PATH,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_PATH,0o660); server.listen(32); server.settimeout(1.0)
|
||||
last=0.0
|
||||
while True:
|
||||
if time.monotonic()-last>30:
|
||||
last=time.monotonic()
|
||||
try:
|
||||
f=lock(); p=parent(); s=state(); task_live(p,s,touch=False); four_way(s['current_head_oid']); ensure_child(p,s,'scheduled_rotation'); fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
||||
except ControlError as e:
|
||||
append_audit({'event':'scheduled_check_blocked','error':e.code})
|
||||
if e.code=='authorization_task_terminal': return
|
||||
except Exception: append_audit({'event':'scheduled_check_blocked','error':'authorization_controller_internal_error'})
|
||||
try: conn,_=server.accept()
|
||||
except socket.timeout: continue
|
||||
with conn:
|
||||
if not peer_allowed(conn): conn.sendall(canonical({'ok':False,'status':403,'error':'authorization_peer_rejected'})+b'\n'); continue
|
||||
try:
|
||||
raw=conn.recv(131072); data=json.loads(raw.decode()); result=request(data); conn.sendall(canonical({'ok':True,'result':result})+b'\n')
|
||||
except ControlError as e: conn.sendall(canonical({'ok':False,'status':e.status,'error':e.code,**e.details})+b'\n')
|
||||
except Exception as exc:
|
||||
append_audit({'event':'controller_internal_error','error_type':type(exc).__name__})
|
||||
conn.sendall(canonical({'ok':False,'status':503,'error':'authorization_controller_internal_error'})+b'\n')
|
||||
if __name__=='__main__': serve()
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Task-bound bridge from the legacy ChatGPT Action to an isolated Writer V2."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import socket
|
||||
import stat
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
CONFIG_PATH = Path(os.environ["AIOS_ACTION_ROUTE_BINDING"])
|
||||
POLICY_PATH = Path(os.environ["AIOS_ACTION_ROUTE_PATH_POLICY"])
|
||||
KNOWN_IDENTITIES_PATH = Path(os.environ["AIOS_ACTION_ROUTE_KNOWN_IDENTITIES"])
|
||||
STATE_PATH = Path(os.environ["AIOS_ACTION_ROUTE_STATE"])
|
||||
AUDIT_PATH = Path(os.environ["AIOS_ACTION_ROUTE_AUDIT"])
|
||||
HOST = os.environ.get("AIOS_ACTION_ROUTE_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("AIOS_ACTION_ROUTE_PORT", "19894"))
|
||||
EXPECTED_TASK_ID = os.environ["AIOS_ACTION_ROUTE_TASK_ID"]
|
||||
EXPECTED_ROUTE_HEADER = os.environ["AIOS_ACTION_ROUTE_HEADER"]
|
||||
AUTHORIZATION_SOCKET = Path(os.environ["AIOS_CONTINUOUS_AUTHORIZATION_SOCKET"])
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
SECRET_RE = re.compile(
|
||||
r"(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)"
|
||||
r"\s*[:=]\s*[\"']?([A-Za-z0-9_./+=:-]{16,})"
|
||||
)
|
||||
SECRET_VALUE_RE = re.compile(
|
||||
r"(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,}|"
|
||||
r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----)"
|
||||
)
|
||||
ALLOWED_REQUEST_FIELDS = frozenset({"base","branch","content","create_branch","message","path","task_id","development_authorization_id","actor_identity","audience","approved_base_oid","expected_current_head_oid","operation_id","idempotency_key","exact_path","content_digest","commit_message_digest"})
|
||||
|
||||
|
||||
class RouteError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400, **details: Any):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = details
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def load_root_managed_json(
|
||||
path: Path, *, expected_mode: int = 0o640
|
||||
) -> tuple[dict[str, Any], bytes]:
|
||||
info = os.lstat(path)
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||
raise RouteError("writer_route_config_type_rejected", 503)
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & ~expected_mode:
|
||||
raise RouteError("writer_route_config_permissions_rejected", 503)
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("writer_route_config_json_rejected", 503) from None
|
||||
if not isinstance(value, dict):
|
||||
raise RouteError("writer_route_config_object_required", 503)
|
||||
return value, raw
|
||||
|
||||
|
||||
def load_binding() -> dict[str, Any]:
|
||||
binding, _ = load_root_managed_json(CONFIG_PATH)
|
||||
required = {
|
||||
"schema_version",
|
||||
"task_id",
|
||||
"writer_instance_id",
|
||||
"repository",
|
||||
"branch",
|
||||
"approved_base_oid",
|
||||
"writer_url",
|
||||
"path_policy_sha256",
|
||||
"action_identities",
|
||||
}
|
||||
if set(binding) != required or binding.get("schema_version") != 1:
|
||||
raise RouteError("writer_route_binding_schema_rejected", 503)
|
||||
if binding["task_id"] != EXPECTED_TASK_ID:
|
||||
raise RouteError("writer_route_task_binding_rejected", 503)
|
||||
if binding["repository"] != "aios/aios":
|
||||
raise RouteError("writer_route_repository_rejected", 503)
|
||||
if not OID_RE.fullmatch(str(binding["approved_base_oid"])):
|
||||
raise RouteError("writer_route_base_oid_rejected", 503)
|
||||
if binding["writer_url"] != "http://127.0.0.1:19893":
|
||||
raise RouteError("writer_route_upstream_rejected", 503)
|
||||
identities = binding.get("action_identities")
|
||||
if not isinstance(identities, list) or len(identities) != 1:
|
||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
||||
identity = identities[0]
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or set(identity) != {"account", "token_sha256"}
|
||||
or identity.get("account") != "chatgpt-gitops-bot"
|
||||
or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", str(identity.get("token_sha256", ""))
|
||||
)
|
||||
):
|
||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
||||
return binding
|
||||
|
||||
|
||||
def allowed_path_scope(binding: dict[str, Any]) -> tuple[set[str], tuple[str, ...]]:
|
||||
policy, raw = load_root_managed_json(POLICY_PATH)
|
||||
actual = hashlib.sha256(raw).hexdigest()
|
||||
if actual != binding["path_policy_sha256"]:
|
||||
raise RouteError("writer_route_path_policy_digest_mismatch", 503)
|
||||
if policy.get("allowed_branch") != binding["branch"]:
|
||||
raise RouteError("writer_route_path_policy_branch_mismatch", 503)
|
||||
operation = (policy.get("allowed_operations") or {}).get("commit_file.atomic")
|
||||
paths = operation.get("exact_paths") if isinstance(operation, dict) else None
|
||||
prefixes = operation.get("prefix_paths") if isinstance(operation, dict) else None
|
||||
if not isinstance(paths, list) or not paths or not all(
|
||||
isinstance(x, str) for x in paths
|
||||
):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if not isinstance(prefixes, list) or not prefixes or not all(
|
||||
isinstance(x, str) and x.endswith("/") for x in prefixes
|
||||
):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if any(not valid_relative_path(x[:-1]) for x in prefixes):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if any(not valid_relative_path(x) for x in paths):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
return set(paths), tuple(prefixes)
|
||||
|
||||
|
||||
def valid_relative_path(path: str) -> bool:
|
||||
if not isinstance(path, str) or not path or path != path.strip():
|
||||
return False
|
||||
if "\x00" in path or "\\" in path:
|
||||
return False
|
||||
try:
|
||||
decoded = urllib.parse.unquote(path, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if decoded != path:
|
||||
return False
|
||||
posix = PurePosixPath(path)
|
||||
windows = PureWindowsPath(path)
|
||||
return not (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in path.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def path_allowed(binding: dict[str, Any], path: str) -> bool:
|
||||
if not valid_relative_path(path):
|
||||
return False
|
||||
exact_paths, prefixes = allowed_path_scope(binding)
|
||||
return path in exact_paths or any(path.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
|
||||
def bearer(headers: Any) -> str:
|
||||
value = headers.get("Authorization", "")
|
||||
if not value.startswith("Bearer "):
|
||||
raise RouteError("unauthorized", 401)
|
||||
token = value[7:].strip()
|
||||
if not token:
|
||||
raise RouteError("unauthorized", 401)
|
||||
return token
|
||||
|
||||
|
||||
def authorize_action(headers: Any, binding: dict[str, Any]) -> str:
|
||||
digest = hashlib.sha256(bearer(headers).encode("utf-8")).hexdigest()
|
||||
identity = binding["action_identities"][0]
|
||||
if not hmac.compare_digest(identity["token_sha256"], digest):
|
||||
known, _ = load_root_managed_json(KNOWN_IDENTITIES_PATH)
|
||||
identities = known.get("identities")
|
||||
if known.get("schema_version") != 1 or not isinstance(identities, list):
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
observed = "unregistered"
|
||||
allowed_accounts = {
|
||||
"chatgpt-gitops-bot",
|
||||
"gitops-write-bot",
|
||||
"codex-gitops-bot",
|
||||
}
|
||||
for candidate in identities:
|
||||
if not isinstance(candidate, dict) or set(candidate) != {
|
||||
"account",
|
||||
"token_sha256",
|
||||
}:
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
account = str(candidate.get("account", ""))
|
||||
token_sha256 = str(candidate.get("token_sha256", ""))
|
||||
if account not in allowed_accounts or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", token_sha256
|
||||
):
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
if hmac.compare_digest(token_sha256, digest):
|
||||
observed = account
|
||||
raise RouteError(
|
||||
"writer_route_action_identity_rejected",
|
||||
403,
|
||||
accepted_action_identity=str(identity["account"]),
|
||||
observed_action_identity=observed,
|
||||
identity_alias_accepted=False,
|
||||
)
|
||||
return str(identity["account"])
|
||||
|
||||
|
||||
def authorization_call(operation: str, **payload: Any) -> dict[str, Any]:
|
||||
request = {"op": operation, **payload}
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.settimeout(15)
|
||||
client.connect(str(AUTHORIZATION_SOCKET))
|
||||
client.sendall(canonical_json(request))
|
||||
raw = client.recv(131072)
|
||||
response = json.loads(raw.decode("utf-8"))
|
||||
except (OSError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("authorization_controller_unavailable", 503) from None
|
||||
if not isinstance(response, dict) or not response.get("ok"):
|
||||
if not isinstance(response, dict):
|
||||
raise RouteError("authorization_controller_response_rejected", 503)
|
||||
details = {k: v for k, v in response.items() if k not in {"ok", "status", "error"}}
|
||||
raise RouteError(str(response.get("error") or "authorization_controller_rejected"), int(response.get("status") or 503), **details)
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise RouteError("authorization_controller_response_rejected", 503)
|
||||
return result
|
||||
|
||||
|
||||
def writer_token() -> str:
|
||||
value = authorization_call("get_writer_token").get("token")
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
|
||||
raise RouteError("writer_route_credential_rejected", 503)
|
||||
return value
|
||||
|
||||
|
||||
def writer_call(
|
||||
binding: dict[str, Any],
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
data = canonical_json(payload) if payload is not None else None
|
||||
headers = {"Authorization": "Bearer " + writer_token()}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
binding["writer_url"] + path, data=data, method=method, headers=headers
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=90) as response:
|
||||
body = json.loads(response.read())
|
||||
return response.status, body
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read())
|
||||
except Exception:
|
||||
body = {"error": "writer_route_upstream_non_json_error"}
|
||||
return exc.code, body
|
||||
except (OSError, TimeoutError):
|
||||
raise RouteError("writer_route_upstream_unavailable", 503) from None
|
||||
|
||||
|
||||
def validate_request(data: Any, binding: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise RouteError("writer_route_request_object_required")
|
||||
if not {"branch", "path", "content"}.issubset(data):
|
||||
raise RouteError("writer_route_request_incomplete")
|
||||
if not set(data).issubset(ALLOWED_REQUEST_FIELDS):
|
||||
raise RouteError("writer_route_request_fields_rejected")
|
||||
if data["branch"] != binding["branch"]:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if data.get("create_branch") not in (None, False):
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if data.get("base", "main") not in {"main", binding["approved_base_oid"]}:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
path = data["path"]
|
||||
if not isinstance(path, str) or not path_allowed(binding, path):
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
content = data["content"]
|
||||
if not isinstance(content, str) or "\x00" in content or "\r" in content:
|
||||
raise RouteError("writer_route_content_rejected")
|
||||
if len(content.encode("utf-8")) > 1_048_576:
|
||||
raise RouteError("writer_route_content_too_large", 413)
|
||||
if SECRET_RE.search(content) or SECRET_VALUE_RE.search(content):
|
||||
raise RouteError("writer_route_secret_content_rejected", 403)
|
||||
message = data.get("message") or f"chore(chatgpt): update {path}"
|
||||
if not isinstance(message, str) or not 1 <= len(message) <= 200 or "\x00" in message or "\n" in message or "\r" in message:
|
||||
raise RouteError("writer_route_message_rejected")
|
||||
return {"branch": binding["branch"], "path": path, "content": content, "message": message}
|
||||
|
||||
|
||||
def complete_request(data: dict[str, Any], normalized: dict[str, Any], binding: dict[str, Any], account: str) -> dict[str, Any]:
|
||||
authorization = authorization_call("describe_authorization")
|
||||
expected = {
|
||||
"task_id": binding["task_id"],
|
||||
"development_authorization_id": authorization.get("development_authorization_id"),
|
||||
"actor_identity": account,
|
||||
"audience": authorization.get("audience"),
|
||||
"branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
}
|
||||
required = {
|
||||
"task_id": EXPECTED_TASK_ID,
|
||||
"development_authorization_id": "authz-aios-autonomous-release-20260726",
|
||||
"actor_identity": "chatgpt-gitops-bot",
|
||||
"audience": "chatgpt-autonomous-release-writer",
|
||||
"branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
}
|
||||
if expected != required or authorization.get("authorization_status") != "ACTIVE" or authorization.get("current_head_oid") is None:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
for key, value in required.items():
|
||||
if key in data and data[key] != value:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "exact_path" in data and data["exact_path"] != normalized["path"]:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
current_head = str(authorization["current_head_oid"])
|
||||
if not OID_RE.fullmatch(current_head):
|
||||
raise RouteError("authorization_current_head_rejected", 503)
|
||||
if "expected_current_head_oid" in data and data["expected_current_head_oid"] != current_head:
|
||||
raise RouteError("BRANCH_HEAD_CHANGED", 409, current_head_oid=current_head)
|
||||
content_digest = hashlib.sha256(normalized["content"].encode("utf-8")).hexdigest()
|
||||
message_digest = hashlib.sha256(normalized["message"].encode("utf-8")).hexdigest()
|
||||
if "content_digest" in data and data["content_digest"] != content_digest:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "commit_message_digest" in data and data["commit_message_digest"] != message_digest:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
seed = {**required, "expected_current_head_oid": current_head, "path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest}
|
||||
request_digest = hashlib.sha256(canonical_json(seed)).hexdigest()
|
||||
idempotency_key = "action-" + request_digest
|
||||
operation_id = hashlib.sha256((EXPECTED_TASK_ID + "\0" + idempotency_key).encode("utf-8")).hexdigest()
|
||||
if "idempotency_key" in data and data["idempotency_key"] != idempotency_key:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "operation_id" in data and data["operation_id"] != operation_id:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
return {**normalized, **required, "expected_current_head_oid": current_head, "operation_id": operation_id, "idempotency_key": idempotency_key, "exact_path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest, "request_digest": request_digest}
|
||||
|
||||
|
||||
def current_writer_head(binding: dict[str, Any]) -> str:
|
||||
status, body = writer_call(binding, "GET", "/api/runtime-status")
|
||||
if status != 200:
|
||||
raise RouteError(
|
||||
"writer_route_runtime_status_failed", 503, upstream_status=status
|
||||
)
|
||||
required = {
|
||||
"service": "chatgpt-autonomous-release-writer",
|
||||
"writes_enabled": True,
|
||||
"allowed_branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
"path_policy_sha256": binding["path_policy_sha256"],
|
||||
}
|
||||
if any(body.get(key) != value for key, value in required.items()):
|
||||
raise RouteError("writer_route_runtime_binding_mismatch", 503)
|
||||
head = str(body.get("workspace_head_oid", ""))
|
||||
if not OID_RE.fullmatch(head):
|
||||
raise RouteError("writer_route_runtime_head_rejected", 503)
|
||||
return head
|
||||
|
||||
|
||||
def operation_binding(
|
||||
request_digest: str, binding: dict[str, Any]
|
||||
) -> tuple[str, str]:
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS action_operations (
|
||||
request_digest TEXT PRIMARY KEY,
|
||||
expected_head_oid TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
||||
"WHERE request_digest=?",
|
||||
(request_digest,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0]), str(row[1])
|
||||
head = current_writer_head(binding)
|
||||
key = "action-" + request_digest
|
||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO action_operations"
|
||||
"(request_digest,expected_head_oid,idempotency_key,created_at) "
|
||||
"VALUES(?,?,?,?)",
|
||||
(request_digest, head, key, utc_now()),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
||||
"WHERE request_digest=?",
|
||||
(request_digest,),
|
||||
).fetchone()
|
||||
db.execute("COMMIT")
|
||||
return str(row[0]), str(row[1])
|
||||
|
||||
|
||||
def append_audit(event: dict[str, Any]) -> None:
|
||||
AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(AUDIT_PATH, flags, 0o600)
|
||||
try:
|
||||
os.write(fd, canonical_json(event) + b"\n")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSActionTaskRouter/1"
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, status: int, value: dict[str, Any]) -> None:
|
||||
body = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/health":
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
try:
|
||||
binding = load_binding()
|
||||
self.send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-action-task-router",
|
||||
"task_id": binding["task_id"],
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"branch": binding["branch"],
|
||||
},
|
||||
)
|
||||
except RouteError as exc:
|
||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/api/file":
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
if self.client_address[0] not in {"127.0.0.1", "::1"}:
|
||||
return self.send_json(403, {"error": "writer_route_source_rejected"})
|
||||
if self.headers.get("X-AIOS-Route-Task") != EXPECTED_ROUTE_HEADER:
|
||||
return self.send_json(403, {"error": "writer_route_header_rejected"})
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < length <= 1_100_000:
|
||||
raise RouteError("writer_route_content_length_rejected", 413)
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("writer_route_request_json_rejected") from None
|
||||
binding = load_binding()
|
||||
account = authorize_action(self.headers, binding)
|
||||
normalized = validate_request(data, binding)
|
||||
normalized = complete_request(data, normalized, binding, account)
|
||||
prepared = authorization_call("prepare_write", request={key: normalized[key] for key in ("expected_current_head_oid", "operation_id", "idempotency_key", "exact_path", "content_digest", "commit_message_digest", "branch", "path", "message")})
|
||||
expected_head = str(prepared["expected_head_oid"])
|
||||
writer_payload = {"branch": normalized["branch"], "path": normalized["path"], "content": normalized["content"], "message": normalized["message"], "expected_head_oid": expected_head, "idempotency_key": str(prepared["idempotency_key"])}
|
||||
request_digest = hashlib.sha256(canonical_json({"task_id": binding["task_id"], "operation_id": normalized["operation_id"], "idempotency_key": normalized["idempotency_key"], "content_digest": normalized["content_digest"], "commit_message_digest": normalized["commit_message_digest"]})).hexdigest()
|
||||
status, result = writer_call(
|
||||
binding, "POST", "/api/commit-file", writer_payload
|
||||
)
|
||||
audit = {
|
||||
"at": utc_now(),
|
||||
"task_id": binding["task_id"],
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"account": account,
|
||||
"branch": normalized["branch"],
|
||||
"path": normalized["path"],
|
||||
"request_digest": request_digest,
|
||||
"expected_head_oid": expected_head,
|
||||
"status": status,
|
||||
"operation_id": result.get("operation_id"),
|
||||
"commit_oid": result.get("commit_oid"),
|
||||
"error": result.get("error"),
|
||||
"secrets_included": False,
|
||||
}
|
||||
append_audit(audit)
|
||||
if status != 200:
|
||||
try:
|
||||
authorization_call("fail_write", result={"operation_id": normalized["operation_id"]})
|
||||
except RouteError:
|
||||
pass
|
||||
return self.send_json(status, result)
|
||||
advanced = authorization_call("reconcile_commit", result={"previous_head_oid": expected_head, "new_commit_oid": result.get("commit_oid"), "operation_id": result.get("operation_id")})
|
||||
return self.send_json(
|
||||
200,
|
||||
{
|
||||
"status": "committed",
|
||||
"branch": normalized["branch"],
|
||||
"path": normalized["path"],
|
||||
"commit": {
|
||||
"status": "committed",
|
||||
"branch": normalized["branch"],
|
||||
"rev": result.get("commit_oid"),
|
||||
"previous_head_oid": expected_head,
|
||||
"new_tree_oid": result.get("new_tree_oid"),
|
||||
"push_status": result.get("push_status"),
|
||||
"four_way_ref_status": advanced.get("four_way_ref_status") is True,
|
||||
"sync": {"status": "verified", "target": "bare+git-ls-remote+gitea-api"},
|
||||
},
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"operation_id": result.get("operation_id"),
|
||||
"idempotent": result.get("idempotent"),
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
except RouteError as exc:
|
||||
if exc.code == "writer_route_action_identity_rejected":
|
||||
try:
|
||||
append_audit(
|
||||
{
|
||||
"at": utc_now(),
|
||||
"task_id": EXPECTED_TASK_ID,
|
||||
"event": "action_identity_rejected",
|
||||
"accepted_action_identity": exc.details.get(
|
||||
"accepted_action_identity"
|
||||
),
|
||||
"observed_action_identity": exc.details.get(
|
||||
"observed_action_identity"
|
||||
),
|
||||
"identity_alias_accepted": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
||||
except Exception:
|
||||
self.send_json(500, {"error": "writer_route_internal_error"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_binding()
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Task-bound Integration Refresh control-plane primitives."""
|
||||
|
||||
from .integration_merge_writer import IntegrationMergeWriter, MergeError, MergeRequest
|
||||
from .integration_refresh import (
|
||||
AppendOnlyAudit,
|
||||
Binding,
|
||||
GiteaBranchReadProjection,
|
||||
GrantStore,
|
||||
IntegrationRefreshProvisioner,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppendOnlyAudit",
|
||||
"Binding",
|
||||
"GiteaBranchReadProjection",
|
||||
"GrantStore",
|
||||
"IntegrationMergeWriter",
|
||||
"IntegrationRefreshProvisioner",
|
||||
"MergeError",
|
||||
"MergeRequest",
|
||||
"ProvisioningError",
|
||||
]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Constrained integration-only merge writer with rollback and reconciliation states."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
class MergeError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409):
|
||||
super().__init__(code); self.code, self.status = code, status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergeRequest:
|
||||
integration_branch: str
|
||||
source_branch: str
|
||||
approved_source_oid: str
|
||||
|
||||
|
||||
class IntegrationMergeWriter:
|
||||
def __init__(self, workspace: Path | str, remote: str, projection):
|
||||
self.workspace, self.remote, self.projection = Path(workspace), remote, projection
|
||||
|
||||
def _git(self, *args: str, check=True) -> str:
|
||||
p = subprocess.run(["git", *args], cwd=self.workspace, text=True, capture_output=True)
|
||||
if check and p.returncode:
|
||||
raise MergeError("git_operation_failed", 500)
|
||||
return p.stdout.strip()
|
||||
|
||||
def merge(self, request: MergeRequest) -> dict:
|
||||
def bad(value: str) -> bool:
|
||||
return (
|
||||
value == "main"
|
||||
or value.startswith("main/")
|
||||
or value == "governance"
|
||||
or value.startswith("governance/")
|
||||
or value == "approvals"
|
||||
or value.startswith("refs/")
|
||||
or value.startswith("tags/")
|
||||
)
|
||||
if (bad(request.integration_branch) or bad(request.source_branch) or
|
||||
not request.integration_branch.startswith("integration/") or
|
||||
re.fullmatch(r"[0-9a-fA-F]{40}", request.approved_source_oid) is None):
|
||||
raise MergeError("integration_merge_scope_rejected", 403)
|
||||
for branch in (request.integration_branch, request.source_branch):
|
||||
if subprocess.run(["git", "check-ref-format", "--branch", branch], cwd=self.workspace,
|
||||
text=True, capture_output=True).returncode:
|
||||
raise MergeError("integration_merge_scope_rejected", 403)
|
||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != request.integration_branch:
|
||||
raise MergeError("integration_branch_checkout_required")
|
||||
before = self._git("rev-parse", "HEAD")
|
||||
remote_before = self.projection(request.integration_branch)
|
||||
if remote_before != before:
|
||||
raise MergeError("integration_remote_advanced")
|
||||
source = self._git("rev-parse", "--verify", f"refs/heads/{request.source_branch}^{{commit}}")
|
||||
if source != request.approved_source_oid:
|
||||
raise MergeError("integration_source_not_approved", 403)
|
||||
attempt = subprocess.run(["git", "merge", "--no-ff", "--no-commit", request.source_branch], cwd=self.workspace, text=True, capture_output=True)
|
||||
if attempt.returncode:
|
||||
subprocess.run(["git", "merge", "--abort"], cwd=self.workspace, text=True, capture_output=True)
|
||||
raise MergeError("integration_merge_conflict")
|
||||
try:
|
||||
self._git("commit", "-m", "merge(integration): approved source")
|
||||
merged = self._git("rev-parse", "HEAD")
|
||||
parents = self._git("show", "-s", "--format=%P", "HEAD").split()
|
||||
if parents != [before, source]:
|
||||
raise MergeError("integration_parent_invariant_failed", 500)
|
||||
pushed = subprocess.run(["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{request.integration_branch}"], cwd=self.workspace, text=True, capture_output=True)
|
||||
if pushed.returncode:
|
||||
try:
|
||||
observed = self.projection(request.integration_branch)
|
||||
except Exception:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
if observed == before:
|
||||
self._git("reset", "--hard", before)
|
||||
return {"state": "PUSH_FAILED_RESTORED", "pre_merge_oid": before}
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
try:
|
||||
observed = self.projection(request.integration_branch)
|
||||
except Exception:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
if observed != merged:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
return {"state": "COMPLETED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
except MergeError:
|
||||
if self._git("rev-parse", "HEAD") != before:
|
||||
self._git("reset", "--hard", before)
|
||||
raise
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Fail-closed, task-bound integration refresh provisioning primitives.
|
||||
|
||||
This module deliberately has no HTTP or credential-provider dependency. The
|
||||
caller supplies a branch-read projection and an apply callback, making the
|
||||
authorization and recovery boundary independently testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import argparse
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 403):
|
||||
super().__init__(code)
|
||||
self.code, self.status = code, status
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _digest(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical(value: dict) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _is_oid(value: object) -> bool:
|
||||
return isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{40}", value) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
actor_identity: str
|
||||
audience: str
|
||||
paths: tuple[str, ...]
|
||||
quota: int
|
||||
|
||||
|
||||
class AppendOnlyAudit:
|
||||
"""Append only redacted JSONL audit, durable before a result is returned."""
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
with self._lock:
|
||||
safe = {key: value for key, value in event.items() if key not in {"token", "credential", "authorization"}}
|
||||
previous = "0" * 64
|
||||
if self.path.exists():
|
||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||
if lines:
|
||||
previous = json.loads(lines[-1])["event_hash"]
|
||||
safe["previous_event_hash"] = previous
|
||||
safe["event_hash"] = _canonical(safe)
|
||||
encoded = (json.dumps(safe, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
fd = os.open(self.path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
os.write(fd, encoded)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class GrantStore:
|
||||
"""Digest-only short grant registry; SQLite transactions serialize quota use."""
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._db() as db:
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS grants(
|
||||
token_digest TEXT PRIMARY KEY, binding_json TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, quota INTEGER NOT NULL, uses INTEGER NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0)""")
|
||||
|
||||
@contextmanager
|
||||
def _db(self):
|
||||
db = sqlite3.connect(self.path, timeout=15, isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def issue(self, binding: Binding, *, ttl_seconds: int = 300) -> str:
|
||||
if ttl_seconds < 1 or ttl_seconds > 1800 or binding.quota < 1 or not binding.paths:
|
||||
raise ValueError("invalid_short_grant")
|
||||
token = secrets.token_urlsafe(32)
|
||||
payload = json.dumps(binding.__dict__ | {"paths": list(binding.paths)}, sort_keys=True)
|
||||
with self._db() as db:
|
||||
db.execute("INSERT INTO grants VALUES(?,?,?,?,?,0)", (_digest(token), payload,
|
||||
(_now() + timedelta(seconds=ttl_seconds)).isoformat(), binding.quota, 0))
|
||||
return token
|
||||
|
||||
def rotate(self, old_token: str, binding: Binding, *, revocation_probe, ttl_seconds: int = 300) -> str:
|
||||
self.revoke(old_token)
|
||||
try: status = revocation_probe(old_token)
|
||||
except TimeoutError as exc: raise ProvisioningError("revocation_probe_timeout", 503) from exc
|
||||
if status != 401: raise ProvisioningError("revocation_probe_rejected", 503)
|
||||
return self.issue(binding, ttl_seconds=ttl_seconds)
|
||||
|
||||
def revoke(self, token: str) -> None:
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute("UPDATE grants SET revoked=1 WHERE token_digest=?", (_digest(token),))
|
||||
db.execute("COMMIT")
|
||||
|
||||
def consume(self, token: str, binding: Binding, path: str) -> None:
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute("SELECT * FROM grants WHERE token_digest=?", (_digest(token),)).fetchone()
|
||||
if not row or row["revoked"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_rejected", 401)
|
||||
if _now() >= datetime.fromisoformat(row["expires_at"]):
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_expired", 401)
|
||||
stored = json.loads(row["binding_json"])
|
||||
requested = binding.__dict__ | {"paths": list(binding.paths)}
|
||||
if stored != requested or path not in stored["paths"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_binding_rejected")
|
||||
if row["uses"] >= row["quota"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_quota_exhausted")
|
||||
db.execute("UPDATE grants SET uses=uses+1 WHERE token_digest=?", (_digest(token),))
|
||||
db.execute("COMMIT")
|
||||
|
||||
|
||||
class GiteaBranchReadProjection:
|
||||
"""Normalizes a trusted task-scoped Gitea reader; every ambiguity fails closed."""
|
||||
def __init__(self, reader: Callable[[str, str], dict]):
|
||||
self.reader = reader
|
||||
|
||||
def head(self, repository: str, branch: str) -> str:
|
||||
try:
|
||||
reply = self.reader(repository, branch)
|
||||
except TimeoutError as exc:
|
||||
raise ProvisioningError("gitea_projection_timeout", 503) from exc
|
||||
except Exception as exc:
|
||||
raise ProvisioningError("gitea_projection_unavailable", 503) from exc
|
||||
if not isinstance(reply, dict) or reply.get("status") in {401, 403}:
|
||||
raise ProvisioningError("gitea_projection_forbidden", 503)
|
||||
oid = reply.get("oid")
|
||||
if reply.get("status") != 200 or not _is_oid(oid):
|
||||
raise ProvisioningError("gitea_projection_invalid", 503)
|
||||
return oid.lower()
|
||||
|
||||
|
||||
class IntegrationRefreshProvisioner:
|
||||
_locks: dict[tuple[str, str], threading.Lock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
def __init__(self, *, grants: GrantStore, projection: GiteaBranchReadProjection,
|
||||
ledger_path: Path | str, audit: AppendOnlyAudit, approved_binding: Binding):
|
||||
self.grants, self.projection, self.audit, self.approved_binding = grants, projection, audit, approved_binding
|
||||
self.ledger_path = Path(ledger_path); self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._ledger() as db:
|
||||
db.execute("CREATE TABLE IF NOT EXISTS refreshes(operation_id TEXT PRIMARY KEY, digest TEXT NOT NULL, state TEXT NOT NULL, result_oid TEXT)")
|
||||
|
||||
@contextmanager
|
||||
def _ledger(self):
|
||||
db = sqlite3.connect(self.ledger_path)
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def refresh(self, *, token: str, binding: Binding, path: str, operation_id: str,
|
||||
target_head_oid: str, apply: Callable[[], str]) -> dict:
|
||||
if not operation_id or not _is_oid(target_head_oid):
|
||||
raise ProvisioningError("refresh_request_rejected")
|
||||
if binding != self.approved_binding:
|
||||
raise ProvisioningError("grant_binding_rejected")
|
||||
request = {"binding": binding.__dict__ | {"paths": list(binding.paths)}, "path": path, "target": target_head_oid}
|
||||
digest = _canonical(request)
|
||||
with self._locks_guard:
|
||||
lock = self._locks.setdefault((binding.repository, binding.branch), threading.Lock())
|
||||
with lock, self._ledger() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute("SELECT * FROM refreshes WHERE operation_id=?", (operation_id,)).fetchone()
|
||||
new_operation = row is None
|
||||
if row:
|
||||
if row[1] != digest:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("operation_id_reuse_rejected", 409)
|
||||
if row[2] == "COMPLETED":
|
||||
db.execute("COMMIT")
|
||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
||||
if row[2] not in {"PENDING", "RECOVERABLE"}:
|
||||
db.execute("COMMIT")
|
||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
||||
else:
|
||||
db.execute("INSERT INTO refreshes VALUES(?,?,?,NULL)", (operation_id, digest, "PENDING"))
|
||||
db.execute("COMMIT")
|
||||
if new_operation:
|
||||
self.grants.consume(token, binding, path)
|
||||
current = self.projection.head(binding.repository, binding.branch)
|
||||
if current != target_head_oid.lower():
|
||||
self._state(operation_id, "STALE_TARGET")
|
||||
raise ProvisioningError("stale_target_head", 409)
|
||||
try:
|
||||
oid = apply()
|
||||
if not _is_oid(oid):
|
||||
raise ProvisioningError("apply_result_invalid", 500)
|
||||
# Detect a competing remote advancement before claiming completion.
|
||||
observed = self.projection.head(binding.repository, binding.branch)
|
||||
if observed != oid.lower():
|
||||
self._state(operation_id, "RECONCILIATION_REQUIRED")
|
||||
raise ProvisioningError("remote_concurrent_advance", 409)
|
||||
self._state(operation_id, "COMPLETED", oid)
|
||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "COMPLETED"})
|
||||
return {"state": "COMPLETED", "operation_id": operation_id, "oid": oid, "idempotent": False}
|
||||
except ProvisioningError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._state(operation_id, "RECOVERABLE")
|
||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "RECOVERABLE"})
|
||||
raise ProvisioningError("refresh_apply_failed", 503) from exc
|
||||
|
||||
def _state(self, operation_id: str, state: str, oid: str | None = None) -> None:
|
||||
with self._ledger() as db:
|
||||
db.execute("UPDATE refreshes SET state=?, result_oid=COALESCE(?,result_oid) WHERE operation_id=?", (state, oid, operation_id))
|
||||
|
||||
|
||||
def _sandbox_preflight(state_dir: Path) -> int:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED") != "false":
|
||||
raise ProvisioningError("global_gitops_writes_must_remain_disabled", 500)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
audit = AppendOnlyAudit(state_dir / "preflight-audit.jsonl")
|
||||
audit.append({"event": "SANDBOX_PREFLIGHT", "writes_enabled": False})
|
||||
print(json.dumps({"status": "READY", "mode": "sandbox", "writes_enabled": False}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--sandbox-preflight", action="store_true", required=True)
|
||||
parser.add_argument("--state-dir", type=Path, required=True)
|
||||
options = parser.parse_args()
|
||||
raise SystemExit(_sandbox_preflight(options.state_dir))
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"actor_identity": "codex-chartgpt-bridge",
|
||||
"allowed_branch_patterns": [
|
||||
"patch/**",
|
||||
"feature/**",
|
||||
"fix/**",
|
||||
"release-candidate/**"
|
||||
],
|
||||
"allowed_operations": [
|
||||
"commit_file.atomic"
|
||||
],
|
||||
"allowed_path_patterns": [
|
||||
"active-runtime/**",
|
||||
"services/**",
|
||||
"deploy/**",
|
||||
"tests/**",
|
||||
"docs/**",
|
||||
"versions/**",
|
||||
"release/**"
|
||||
],
|
||||
"denied_branch_patterns": [
|
||||
"main",
|
||||
"master",
|
||||
"governance/approvals",
|
||||
"integration/**",
|
||||
"production/**"
|
||||
],
|
||||
"denied_operations": [
|
||||
"force_push",
|
||||
"history_rewrite",
|
||||
"branch_delete",
|
||||
"tag.write",
|
||||
"registry.write",
|
||||
"baseline.write",
|
||||
"production.deploy",
|
||||
"approval.submit",
|
||||
"secret.read"
|
||||
],
|
||||
"expires_at": "2027-07-29T00:00:00Z",
|
||||
"policy_id": "STANDING-GOVERNED-DEVELOPMENT-WRITER-V1",
|
||||
"repository": "aios/aios",
|
||||
"writer_audience": "aios-governed-development-writer"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"actor_identity": "codex-chartgpt-bridge",
|
||||
"allowed_operations": [
|
||||
"commit_file.atomic"
|
||||
],
|
||||
"allowed_paths": [
|
||||
"services/**",
|
||||
"deploy/**",
|
||||
"tests/**",
|
||||
"docs/**",
|
||||
"release/**"
|
||||
],
|
||||
"branch": "patch/v2r1c4-release-completion-20260729",
|
||||
"expected_base_oid": "<40_HEX_OID>",
|
||||
"expected_policy_sha256": "<64_HEX_SHA256>",
|
||||
"expires_at": "2026-08-05T00:00:00Z",
|
||||
"grant_id": "grant-REPLACE",
|
||||
"max_operations": 200,
|
||||
"max_total_bytes": 67108864,
|
||||
"repository": "aios/aios",
|
||||
"task_id": "V2R1C4-RELEASE-COMPLETION",
|
||||
"writer_audience": "aios-governed-development-writer"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"authoritative_git_dir": "/data/aios-gitops/repo.git",
|
||||
"poll_seconds": 5,
|
||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/mirror/receipts",
|
||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/mirror/requests",
|
||||
"source_git_dir": "/data/aios-gitops/runtime/gitea-bin/data/gitea-repositories/aios/aios.git"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"socket_path": "/run/aios-gitops/autonomous-control-plane/publisher.sock",
|
||||
"allowed_peer_uids": [
|
||||
0
|
||||
],
|
||||
"repository": "aios/aios",
|
||||
"base_branch": "governance/approvals",
|
||||
"head_prefix": "governance/request/",
|
||||
"owner_reviewer": "owner",
|
||||
"gitea_base_url": "http://127.0.0.1:3000",
|
||||
"remote_url": "http://127.0.0.1:3000/aios/aios.git",
|
||||
"credential_name": "gitea_http",
|
||||
"allowed_git_dir_roots": [
|
||||
"/data/aios-gitops/worktrees",
|
||||
"/data/aios-gitops/repo.git"
|
||||
],
|
||||
"operation_ledger": "/var/lib/aios-gitops/autonomous-control-plane/publisher/operations.sqlite",
|
||||
"audit_path": "/var/lib/aios-gitops/autonomous-control-plane/publisher/audit.jsonl"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"authoritative_git_dir": "/data/aios-gitops/repo.git",
|
||||
"approval": {
|
||||
"gitea_db_path": "/data/aios-gitops/runtime/gitea-bin/data/gitea.db",
|
||||
"repo_id": 1,
|
||||
"owner_user_id": 2,
|
||||
"governance_ref": "refs/heads/governance/approvals",
|
||||
"required_machine_contexts": [
|
||||
"aios/branch-protection",
|
||||
"aios/evidence-closure",
|
||||
"aios/secret-scan",
|
||||
"aios/state-machine",
|
||||
"aios/validator"
|
||||
]
|
||||
},
|
||||
"merge_transport": {
|
||||
"transport": "unix_json",
|
||||
"socket_path": "/run/aios-gitops/integration-merge-writer.sock",
|
||||
"timeout_seconds": 120
|
||||
},
|
||||
"tag_transport": {
|
||||
"transport": "http_json",
|
||||
"url": "http://127.0.0.1:8080/api/create-tag",
|
||||
"credential_name": "tag_api",
|
||||
"timeout_seconds": 120,
|
||||
"expected_statuses": [
|
||||
200
|
||||
]
|
||||
},
|
||||
"remote_tag": {
|
||||
"remote_url": "https://g.cnnetmkt.top/git/aios/aios.git",
|
||||
"gitea_base_url": "https://g.cnnetmkt.top/git",
|
||||
"repo_owner": "aios",
|
||||
"repo_name": "aios",
|
||||
"credential_name": "gitea_http"
|
||||
},
|
||||
"deployment": {
|
||||
"artifact_path": "/var/lib/aios-gitops/artifacts/V2R1C4.tar.zst",
|
||||
"artifact_manifest_member": "artifact-manifest.json",
|
||||
"release_root": "/opt/aios/releases",
|
||||
"current_symlink": "/opt/aios/current",
|
||||
"previous_symlink": "/opt/aios/previous",
|
||||
"state_path": "/var/lib/aios-gitops/autonomous-control-plane/release/deployment-state.json",
|
||||
"service_units": [
|
||||
"aios-gitops-model-api.service"
|
||||
],
|
||||
"health_checks": [
|
||||
{
|
||||
"url": "http://127.0.0.1:8080/health",
|
||||
"expected_status": 200,
|
||||
"timeout_seconds": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"registry_path": "/var/lib/aios-gitops/release/versions/V2R1C4.json",
|
||||
"baseline_path": "/var/lib/aios-gitops/release/PRODUCTION_BASELINE",
|
||||
"registry_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/registry-plan.json",
|
||||
"baseline_plan_path": "/var/lib/aios-gitops/autonomous-control-plane/release/baseline-plan.txt",
|
||||
"metadata_snapshot_root": "/var/lib/aios-gitops/autonomous-control-plane/release/metadata-snapshots"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"approval_expires_at": "2026-08-05T00:00:00Z",
|
||||
"approval_head_oid": "<40_HEX_OID>",
|
||||
"approval_manifest_path": "approvals/release/V2R1C4-PRODUCTION-RELEASE-ENVELOPE.json",
|
||||
"approval_manifest_sha256": "<64_HEX_SHA256>",
|
||||
"approval_merge_oid": "<40_HEX_OID>",
|
||||
"approval_plan_id": "<OWNER_APPROVED_PLAN_ID>",
|
||||
"approval_pr_number": 1,
|
||||
"artifact_sha256": "<64_HEX_SHA256>",
|
||||
"baseline_plan_sha256": "<64_HEX_SHA256>",
|
||||
"deployment_plan_sha256": "<64_HEX_SHA256>",
|
||||
"evidence_closure_oid": "<40_HEX_OID>",
|
||||
"evidence_closure_sha256": "<64_HEX_SHA256>",
|
||||
"owner_review_oid": 1,
|
||||
"registry_plan_sha256": "<64_HEX_SHA256>",
|
||||
"rollback_artifact_sha256": "<64_HEX_SHA256>",
|
||||
"rollback_plan_sha256": "<64_HEX_SHA256>",
|
||||
"sbom_sha256": "<64_HEX_SHA256>",
|
||||
"tag_plan_sha256": "<64_HEX_SHA256>",
|
||||
"target_oid": "<40_HEX_TARGET_OID>",
|
||||
"target_tree_oid": "<40_HEX_TREE_OID>",
|
||||
"test_evidence_sha256": "<64_HEX_SHA256>",
|
||||
"version": "V2R1C4"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"state_path": "/var/lib/aios-gitops/autonomous-control-plane/release/state.json",
|
||||
"audit_path": "/var/lib/aios-gitops/autonomous-control-plane/release/audit.jsonl",
|
||||
"hooks": {
|
||||
"verify_owner_approval": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"verify_owner_approval",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"merge_release": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"merge_release",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"create_tag": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"create_tag",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"verify_remote_tag": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"verify_remote_tag",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"deploy": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"deploy",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"health_check": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"health_check",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"rollback": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"rollback",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"verify_previous": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"verify_previous",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"restore": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"restore",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"verify_restored": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"verify_restored",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"update_registry": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"update_registry",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"update_baseline": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"update_baseline",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"verify_consistency": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"verify_consistency",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"snapshot_metadata": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"snapshot_metadata",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
],
|
||||
"restore_metadata": [
|
||||
"/usr/bin/aios-release-hook",
|
||||
"restore_metadata",
|
||||
"--config",
|
||||
"/etc/aios-gitops/autonomous-control-plane/release-actions.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"allowed_output_roots": [
|
||||
"/var/lib/aios-gitops/autonomous-control-plane/projection/releases"
|
||||
],
|
||||
"poll_seconds": 5,
|
||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/projection/receipts",
|
||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/projection/requests",
|
||||
"source_git_dir": "/data/aios-gitops/runtime/gitea-bin/data/gitea-repositories/aios/aios.git"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"poll_seconds": 10,
|
||||
"receipts_dir": "/var/lib/aios-gitops/autonomous-control-plane/renewal/receipts",
|
||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/renewal/requests"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"admin_credential_name": "admin_gitea_http",
|
||||
"encrypted_credential_path": "/etc/credstore.encrypted/aios-governed-development-writer.gitea_http",
|
||||
"expected_writer_username": "aios-governed-development-writer",
|
||||
"gitea_base_url": "http://127.0.0.1:3000",
|
||||
"grant_path": "/var/lib/aios-gitops/autonomous-control-plane/writer/grant.json",
|
||||
"receipt_path": "/var/lib/aios-gitops/autonomous-control-plane/writer/revocation-receipt.json",
|
||||
"revoked_grant_path": "/var/lib/aios-gitops/autonomous-control-plane/writer/grant.revoked.json",
|
||||
"writer_credential_name": "writer_gitea_http",
|
||||
"writer_unit": "aios-governed-development-writer.service"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"exact_paths": [
|
||||
"/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
||||
"/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py"
|
||||
],
|
||||
"output_path": "/var/lib/aios-gitops/autonomous-control-plane/projection/runtime-source-evidence.json",
|
||||
"task_id": "V2R1C4-RELEASE-COMPLETION"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"gitea_db_path": "/data/aios-gitops/runtime/gitea-bin/data/gitea.db",
|
||||
"output_dir": "/var/lib/aios-gitops/autonomous-control-plane/watcher/status",
|
||||
"owner_user_id": 2,
|
||||
"poll_seconds": 5,
|
||||
"repo_id": 1,
|
||||
"requests_dir": "/var/lib/aios-gitops/autonomous-control-plane/watcher/requests"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"socket_path": "/run/aios-gitops/autonomous-control-plane/writer.sock",
|
||||
"allowed_peer_uids": [
|
||||
0
|
||||
],
|
||||
"policy_path": "/etc/aios-gitops/autonomous-control-plane/development-policy.json",
|
||||
"grant_path": "/var/lib/aios-gitops/autonomous-control-plane/writer/grant.json",
|
||||
"grant_quota_ledger": "/var/lib/aios-gitops/autonomous-control-plane/writer/grant-quota.sqlite",
|
||||
"worktree_roots": [
|
||||
"/data/aios-gitops/worktrees"
|
||||
],
|
||||
"credential_name": "gitea_http",
|
||||
"remote": "origin",
|
||||
"operation_ledger": "/var/lib/aios-gitops/autonomous-control-plane/writer/operations.sqlite",
|
||||
"audit_path": "/var/lib/aios-gitops/autonomous-control-plane/writer/audit.jsonl",
|
||||
"max_request_bytes": 4194304
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
[Unit]
|
||||
Description=AIOS Autonomous Release Candidate API (Dormant/Shadow Only)
|
||||
After=network.target
|
||||
ConditionPathExists=/opt/aios-autonomous-release-candidate/aios_autonomous_release_candidate
|
||||
Description=AIOS Autonomous Release Candidate (dormant shadow only)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
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
|
||||
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
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
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
|
||||
StateDirectory=aios-autonomous-release-candidate
|
||||
RuntimeDirectory=aios-autonomous-release-candidate
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
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
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
ReadOnlyPaths=/opt/aios-autonomous-release-candidate
|
||||
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
|
||||
ReadWritePaths=/var/lib/aios-autonomous-release-candidate
|
||||
ReadWritePaths=/var/cache/aios-autonomous-release-candidate
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=AIOS autonomous execution renewal controller
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/renewal.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-autonomous-renewal-controller --config /etc/aios-gitops/autonomous-control-plane/renewal.json
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane/renewal /run/aios-gitops/autonomous-control-plane
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=AIOS governance approval watcher
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/watcher.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-governance-approval-watcher --config /etc/aios-gitops/autonomous-control-plane/watcher.json
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadOnlyPaths=/data/aios-gitops/runtime/gitea-bin/data/gitea.db
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane /run/aios-gitops/autonomous-control-plane
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
IPAddressDeny=any
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=AIOS dormant single-owner machine-status prerequisite
|
||||
RefuseManualStop=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aiman
|
||||
Group=gitops
|
||||
Environment=AIOS_SINGLE_OWNER_GOVERNANCE_ENABLED=false
|
||||
Environment=AIOS_SINGLE_OWNER_ACTIVATION_ACTIVE=false
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/python3 -m aios_single_owner_governance.service --component machine-status-producer --self-check --checkpoint-dir /var/lib/aios-gitops/runtime/single-owner-governance
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/runtime/single-owner-governance
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=AIOS governance approval mirror worker
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/mirror.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-governance-mirror-worker --config /etc/aios-gitops/autonomous-control-plane/mirror.json
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/data/aios-gitops/repo.git /var/lib/aios-gitops/autonomous-control-plane/mirror /run/aios-gitops/autonomous-control-plane
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=AIOS dormant single-owner review adapter prerequisite
|
||||
RefuseManualStop=no
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=aiman
|
||||
Group=gitops
|
||||
Environment=AIOS_SINGLE_OWNER_GOVERNANCE_ENABLED=false
|
||||
Environment=AIOS_SINGLE_OWNER_ACTIVATION_ACTIVE=false
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/python3 -m aios_single_owner_governance.service --component owner-review-adapter --self-check --checkpoint-dir /var/lib/aios-gitops/runtime/single-owner-governance
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-gitops/runtime/single-owner-governance
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=AIOS governance request publisher
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/publisher.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-governance-request-publisher
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-governance-request-publisher --config /etc/aios-gitops/autonomous-control-plane/publisher.json
|
||||
LoadCredentialEncrypted=gitea_http:/etc/credstore.encrypted/aios-governance-request-publisher.gitea_http
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
RestrictSUIDSGID=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane /run/aios-gitops/autonomous-control-plane
|
||||
IPAddressAllow=127.0.0.1
|
||||
IPAddressDeny=any
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=AIOS single-owner governance activation verifier
|
||||
ConditionPathExists=/etc/aios-gitops/single-owner-activation-bindings.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-single-owner-governance --verify --bindings /etc/aios-gitops/single-owner-activation-bindings.json --state-root /var/lib/aios-gitops/governance-activation --lock-path /run/aios-gitops/governance-activation.lock
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/aios-gitops/governance-activation /run/aios-gitops
|
||||
RestrictSUIDSGID=true
|
||||
UMask=0027
|
||||
|
||||
# Deliberately not installable. Deployment remains disabled/inactive;
|
||||
# activation is an explicit approved CLI transaction, never an always-on unit.
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=AIOS governed development writer credential revoker
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/development-policy.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-development-writer-revoker --config /etc/aios-gitops/autonomous-control-plane/revoker.json
|
||||
LoadCredentialEncrypted=writer_gitea_http:/etc/credstore.encrypted/aios-governed-development-writer.gitea_http
|
||||
LoadCredentialEncrypted=admin_gitea_http:/etc/credstore.encrypted/aios-governance-admin.gitea_http
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane/writer /run/aios-gitops/autonomous-control-plane -/etc/credstore.encrypted/aios-governed-development-writer.gitea_http
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=AIOS governed development writer revocation timer
|
||||
|
||||
[Timer]
|
||||
OnBootSec=5min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
Unit=aios-governed-development-writer-revoker.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,33 @@
|
||||
[Unit]
|
||||
Description=AIOS governed development writer
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/development-policy.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-governed-development-writer
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-development-writer-server --config /etc/aios-gitops/autonomous-control-plane/writer-server.json
|
||||
LoadCredentialEncrypted=gitea_http:/etc/credstore.encrypted/aios-governed-development-writer.gitea_http
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressAllow=127.0.0.1
|
||||
IPAddressDeny=any
|
||||
ReadWritePaths=/data/aios-gitops/worktrees /var/lib/aios-gitops/autonomous-control-plane/writer /run/aios-gitops/autonomous-control-plane
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=AIOS Integration Refresh Provisioner (sandbox control plane)
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
User=aios
|
||||
Group=aios
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_INTEGRATION_REFRESH_MODE=sandbox
|
||||
Environment=AIOS_CREDENTIAL_PROVIDER=disabled
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/var/lib/aios/integration-refresh
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
IPAddressDeny=any
|
||||
ExecStart=/usr/bin/python3 /opt/aios/active-runtime/app/aios_isolated_writer_api/integration_refresh.py --sandbox-preflight --state-dir /var/lib/aios/integration-refresh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=AIOS exact release object projection
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/release-projection.json
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-release-object-projector --config /etc/aios-gitops/autonomous-control-plane/release-projection.json
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane/projection /run/aios-gitops/autonomous-control-plane
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=AIOS exact runtime source read projection
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/runtime-projection.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-runtime-source-projector --config /etc/aios-gitops/autonomous-control-plane/runtime-projection.json
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane/projection /run/aios-gitops/autonomous-control-plane
|
||||
|
||||
PrivateNetwork=true
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=AIOS V2R1C4 unified governed release envelope executor
|
||||
After=network-online.target
|
||||
ConditionPathExists=/etc/aios-gitops/autonomous-control-plane/release-hooks.json
|
||||
ConditionPathExists=/var/lib/aios-gitops/autonomous-control-plane/release/envelope.json
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=gitops
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
ExecStart=/usr/bin/aios-v2r1c4-release-executor --config /etc/aios-gitops/autonomous-control-plane/release-hooks.json --envelope /var/lib/aios-gitops/autonomous-control-plane/release/envelope.json --operation-id v2r1c4-production-release
|
||||
LoadCredentialEncrypted=gitea_http:/etc/credstore.encrypted/aios-release-executor.gitea_http
|
||||
LoadCredentialEncrypted=tag_api:/etc/credstore.encrypted/aios-release-executor.tag_api
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
UMask=0027
|
||||
ReadWritePaths=/var/lib/aios-gitops/autonomous-control-plane/release /var/lib/aios-gitops/release /opt/aios /run/aios-gitops/autonomous-control-plane
|
||||
@@ -0,0 +1,19 @@
|
||||
d /var/lib/aios-gitops/autonomous-control-plane 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/writer 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/mirror 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/projection 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/renewal 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/release 0750 root gitops -
|
||||
d /run/aios-gitops/autonomous-control-plane 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/publisher 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/watcher 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/watcher/requests 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/watcher/status 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/mirror/requests 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/mirror/receipts 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/projection/requests 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/projection/receipts 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/projection/releases 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/renewal/requests 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/renewal/receipts 0750 root gitops -
|
||||
d /var/lib/aios-gitops/autonomous-control-plane/release/metadata-snapshots 0750 root gitops -
|
||||
@@ -0,0 +1,4 @@
|
||||
d /var/lib/aios-gitops/governance-activation 0750 root gitops -
|
||||
d /var/lib/aios-gitops/governance-activation/wal 0750 root gitops -
|
||||
d /var/lib/aios-gitops/governance-activation/receipts 0750 root gitops -
|
||||
d /run/aios-gitops 0750 root gitops -
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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,47 +1,82 @@
|
||||
# AIOS Autonomous Release Execution Package
|
||||
|
||||
## Mode
|
||||
## Release Strategy
|
||||
|
||||
Candidate/shadow first. Production changes require an independent release approval.
|
||||
V2R1C4 uses a two-stage model:
|
||||
|
||||
## Flow
|
||||
1. Dormant code deployment.
|
||||
2. Capability activation through a separate approved release.
|
||||
|
||||
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.
|
||||
## Dormant Deployment
|
||||
|
||||
## Hard stops
|
||||
The first production release must keep autonomous execution disabled:
|
||||
|
||||
- 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:
|
||||
|
||||
```
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
until release approval is independently verified.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"execution_renewal_manifest_blob_oid": "417f78abb3d795f7b0c04a0894069bd166bb15df",
|
||||
"execution_renewal_manifest_sha256": "069091eff9c7c46bceccde365da6d07a8538e76bee9e288a57211f3114b94ab3",
|
||||
"execution_renewal_merge_oid": "3a22c3c7d86930efd4bd93ac05d80ad322050cb9",
|
||||
"observed_at": "2026-07-28T09:09:27.841968Z",
|
||||
"runtime_source_evidence_sha256": "23f03d9c6abe6f11e1acda5210b8708696e3b77caa0083b6bfaef30a44b2a35f",
|
||||
"runtime_source_observed_at": "2026-07-28T05:53:01.253909Z",
|
||||
"schema": "aios.integration-refresh-source-map/v1",
|
||||
"source_mapping_approval_merge_oid": "9514062cf33e45941abe4a8c98377e0713b0b3f6",
|
||||
"source_mapping_manifest_blob_oid": "00c5e90e529cb98c7750a5761b0a0d6ccf507174",
|
||||
"source_mapping_manifest_sha256": "17af8ebc43673c0e46bb9ab1bf44497e8c147f1b4a9d7a8a404edd5c0abd6f21",
|
||||
"sources": [
|
||||
{
|
||||
"byte_for_byte_equal": true,
|
||||
"canonical_blob_oid": "9c68be36e0b6a18260e3099e3d7b0dcab6d814aa",
|
||||
"canonical_commit_oid": "85426f4f570e4d40b212318d47e52e4aed5cb06a",
|
||||
"canonical_git_path": "active-runtime/app/aios_autonomous_release_authorization/controller.py",
|
||||
"canonical_tree_oid": "cdbd9bb845b18d4b5224d91aace2656a51eb7c14",
|
||||
"runtime_blob_oid": "9c68be36e0b6a18260e3099e3d7b0dcab6d814aa",
|
||||
"runtime_path": "/usr/libexec/aios-autonomous-release-authorization/controller.py",
|
||||
"runtime_sha256": "39b532c3058c4ae49ed691d7c66c3b7745a25111fa61039f388c8335fd262c50"
|
||||
},
|
||||
{
|
||||
"byte_for_byte_equal": true,
|
||||
"canonical_blob_oid": "ee0595ca61a0598f8cb3dc10865a255e21679b2a",
|
||||
"canonical_commit_oid": "85426f4f570e4d40b212318d47e52e4aed5cb06a",
|
||||
"canonical_git_path": "active-runtime/app/aios_chatgpt_action_task_router/action_task_router.py",
|
||||
"canonical_tree_oid": "cdbd9bb845b18d4b5224d91aace2656a51eb7c14",
|
||||
"runtime_blob_oid": "ee0595ca61a0598f8cb3dc10865a255e21679b2a",
|
||||
"runtime_path": "/usr/libexec/aios-chatgpt-action-task-router-continuous-20260726/action_task_router.py",
|
||||
"runtime_sha256": "738fb7035a6b792115d6467b4d1e4ffd47a8c5d5646f7032ebc68ea587cbd554"
|
||||
}
|
||||
],
|
||||
"task_id": "AIOS-INTEGRATION-REFRESH-PROVISIONER-IMPLEMENTATION-20260726"
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
# 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,65 @@
|
||||
{
|
||||
"evidence_type": "FULL_PACKAGE_FILE_MANIFEST_DRIFT_CLOSURE",
|
||||
"release": "V2R1C4",
|
||||
"entries": [
|
||||
{
|
||||
"path": "VERSION",
|
||||
"old_sha256": "1095953d5764d9c72f02e84b1d525e265d3a67a5bdb451ab9ef51a8e8b3032b8",
|
||||
"new_sha256": "b090062a92aeb14211bbfc1fcd43b5ee4f315d30054b437f3bcdac657210ee95",
|
||||
"source_commit": "9917145a643c7938a1a4f4f72690674f13f4c3dd",
|
||||
"reason": "V2R1C4 release completion froze the candidate package version",
|
||||
"resolution": "accept the V2R1C4 candidate content and update only this manifest entry"
|
||||
},
|
||||
{
|
||||
"path": "active-runtime/app/aios_gitops_model_api.py",
|
||||
"old_sha256": "ee6e4fc6bc98a590fbc3585b62707e2562115e5a3f722e69a7c464d966bdc458",
|
||||
"new_sha256": "a664714bc1e13160cdd0dc32de460c65a83a3a95aa5cdad1833b1b650ab90e9d",
|
||||
"source_commit": "45dd78ec796faf9fdfba397e5bab73176aeef3f6",
|
||||
"reason": "tracked GitOps API gained the governed short-link and webhook endpoint",
|
||||
"resolution": "accept current tracked content and update only this manifest entry"
|
||||
},
|
||||
{
|
||||
"path": "config/domains.env",
|
||||
"old_sha256": "a3872f24e4cd64288758846ecd0b374db4aa41111feec3b17c41438136d6d5d0",
|
||||
"new_sha256": "5a27d48db343dcb723bcc1d9d8222aa3f72094c57cc43640aebe6558963bd20f",
|
||||
"source_commit": "e7d36ad706473c95a4d4b9ec791120271ed856cd",
|
||||
"reason": "tracked authentication routing made cnnetmkt Casdoor the primary endpoint",
|
||||
"resolution": "accept current tracked content and update only this manifest entry"
|
||||
},
|
||||
{
|
||||
"path": "scripts/install_v1r1c0_gitops_model_overlay.sh",
|
||||
"old_sha256": "4d54a4ed6d35bbb6e9fe2924f3cfceeea9c3eed898017788f6c5ab8041394a47",
|
||||
"new_sha256": "e76cd986d2387b76439e4a7fe503e48a2de8fc70fb52c45a13641c9d943f108f",
|
||||
"source_commit": "a2481421589e167680e7d801433758c6a01db832",
|
||||
"reason": "tracked installer added online Codex patch-branch synchronization",
|
||||
"resolution": "accept current tracked content and update only this manifest entry"
|
||||
},
|
||||
{
|
||||
"path": "scripts/package_contract_check_v1r1c0.sh",
|
||||
"old_sha256": "e0abd8ad44d4f088108f2046fd9d7bbd4465af50f79bd3b9fcbb72a8c61bee65",
|
||||
"new_sha256": "f537c58fc08685601f47fac5c0a3a2856e3ce9806c1918848c87a194a49c28a0",
|
||||
"source_commit": "9917145a643c7938a1a4f4f72690674f13f4c3dd",
|
||||
"reason": "release completion generalized the legacy version check without weakening required files or compilation checks",
|
||||
"resolution": "accept the generalized semantic version check and update only this manifest entry"
|
||||
},
|
||||
{
|
||||
"path": "scripts/release_version.sh",
|
||||
"old_sha256": "abef8df642c6d233af4a03c512876b02b326e19c924020b8dd8cc74236d18817",
|
||||
"new_sha256": "14ab40a7c342e21e8315def14d552dcff4b214d23f1f4be493e9d3fc2cee1a2f",
|
||||
"source_commit": "27a0ae126b96d63c40544174e7a3859e30eba056",
|
||||
"reason": "tracked release helper closed tag-publication governance gaps",
|
||||
"resolution": "accept current tracked content and update only this manifest entry"
|
||||
}
|
||||
],
|
||||
"verifier_change": {
|
||||
"path": "package_verify_full.sh",
|
||||
"old_sha256": "b77bce1ca78cede27de6594e25f6ce5bb0c4990c0953eeb79f7e2608d2515c32",
|
||||
"new_sha256": "ca49f4165b50171968485ffa5535bc33d2158237ea55af10a3888a5fa0f7a535",
|
||||
"source_commit": "V2R1C4 release completion candidate",
|
||||
"reason": "remove the checksum failure suppression and fail closed when the manifest is absent or mismatched",
|
||||
"resolution": "update only the verifier self-entry after the six historical drifts were closed"
|
||||
},
|
||||
"entry_count": 6,
|
||||
"whole_manifest_regenerated": false,
|
||||
"verifier_fail_closed": true
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Agent Integration Plan
|
||||
|
||||
1. Verify package SHA-256 and FILE_SHA256.json.
|
||||
2. Apply added/modified source files to patch/v2r1c4-release-completion-20260729.
|
||||
3. Do not copy __pycache__, .pytest_cache, TEST_RECEIPT.json, or local package metadata into runtime paths.
|
||||
4. Preserve existing approved WeChat and single-owner activation Git objects.
|
||||
5. Install the Python package and verify every pyproject console entrypoint.
|
||||
6. Render environment-specific JSON configs from config/examples.
|
||||
7. Use systemd encrypted credentials only; never persist plaintext tokens.
|
||||
8. Run compileall, all repository tests, systemd unit verification, secret scan, and online copied-Gitea acceptance.
|
||||
9. Build one autonomous-control-plane Artifact and rollback Artifact.
|
||||
10. Create one Owner approval for AIOS_AUTONOMOUS_RELEASE_CONTROL_PLANE_BOOTSTRAP.
|
||||
11. After deployment, complete V2R1C4 code freeze and create one V2R1C4_PRODUCTION_RELEASE_ENVELOPE.
|
||||
12. Do not split unchanged code/artifact/permission scope into additional approvals.
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"capabilities": {
|
||||
"approval_mirror_worker": "DELIVERED",
|
||||
"approval_watcher": "DELIVERED",
|
||||
"artifact_atomic_deployment": "DELIVERED",
|
||||
"automatic_renewal_controller": "DELIVERED",
|
||||
"credential_revoker_with_old_token_401": "DELIVERED",
|
||||
"governance_request_publisher": "DELIVERED",
|
||||
"governed_development_writer_server": "DELIVERED",
|
||||
"governed_merge_transport": "DELIVERED",
|
||||
"governed_tag_transport": "DELIVERED",
|
||||
"grant_quota_idempotency": "DELIVERED",
|
||||
"grant_revocation_evidence": "DELIVERED",
|
||||
"health_rollback_restore": "DELIVERED",
|
||||
"owner_exact_head_release_approval_verification": "DELIVERED",
|
||||
"registry_baseline_snapshot_compensation": "DELIVERED",
|
||||
"release_object_projection": "DELIVERED",
|
||||
"remote_tag_and_final_consistency": "DELIVERED",
|
||||
"runtime_source_projection": "DELIVERED",
|
||||
"task_bound_writer_grant": "DELIVERED",
|
||||
"unified_release_envelope_executor": "DELIVERED"
|
||||
},
|
||||
"delivery_status": "CODE_COMPLETE_OFFLINE_VALIDATED",
|
||||
"direct_main_push": false,
|
||||
"entrypoints": {
|
||||
"aios-autonomous-control-plane": "aios_autonomous_control_plane.cli:main",
|
||||
"aios-autonomous-renewal-controller": "aios_autonomous_control_plane.renewal_service:main",
|
||||
"aios-development-writer-revoker": "aios_autonomous_control_plane.revoker:main",
|
||||
"aios-development-writer-server": "aios_autonomous_control_plane.writer_server:main",
|
||||
"aios-governance-approval-watcher": "aios_autonomous_control_plane.watcher_service:main",
|
||||
"aios-governance-mirror-worker": "aios_autonomous_control_plane.mirror_service:main",
|
||||
"aios-governance-request-publisher": "aios_autonomous_control_plane.publisher:main",
|
||||
"aios-release-hook": "aios_autonomous_control_plane.release_action:main",
|
||||
"aios-release-object-projector": "aios_autonomous_control_plane.projection_service:release_main",
|
||||
"aios-runtime-source-projector": "aios_autonomous_control_plane.projection_service:runtime_main",
|
||||
"aios-v2r1c4-release-executor": "aios_autonomous_control_plane.release_service:main"
|
||||
},
|
||||
"force_push": false,
|
||||
"global_writes_must_remain_false": true,
|
||||
"package_version": "0.2.0",
|
||||
"schema": "aios.code-handoff/v2",
|
||||
"source": "codex-chartgpt-bridge",
|
||||
"systemd_units": [
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service",
|
||||
"deploy/systemd/aios-governance-request-publisher.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.timer",
|
||||
"deploy/systemd/aios-governed-development-writer.service",
|
||||
"deploy/systemd/aios-release-object-projection.service",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service",
|
||||
"deploy/systemd/aios-v2r1c4-release-envelope.service"
|
||||
],
|
||||
"tag_rewrite": false,
|
||||
"target_branch": "patch/v2r1c4-release-completion-20260729",
|
||||
"tests": {
|
||||
"collection_errors": 0,
|
||||
"compileall": "PASS",
|
||||
"pytest_failed": 0,
|
||||
"pytest_passed": 21,
|
||||
"secret_findings": [
|
||||
{
|
||||
"path": "tests/autonomous_control_plane/test_policy_and_writer.py",
|
||||
"rule": "literal-credential-assignment"
|
||||
}
|
||||
],
|
||||
"secret_findings_classification": {
|
||||
"tests/autonomous_control_plane/test_policy_and_writer.py": "Deliberate fake api_key assignment used by the negative secret-rejection test."
|
||||
},
|
||||
"secret_scan": "PASS_TEST_FIXTURE_ONLY"
|
||||
},
|
||||
"wechat_backend": {
|
||||
"new_code_generated": false,
|
||||
"reason": "The controlled PR #20 full diff remains too large for this interface; existing exact Git objects must be retained and validated, not re-invented.",
|
||||
"required_agent_action": "Preserve already integrated approved WeChat code, run its online acceptance suite, and include its exact object IDs in the final release envelope."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"added_files": [
|
||||
"AGENT_INTEGRATION_PLAN.md",
|
||||
"config/examples/grant.json",
|
||||
"config/examples/mirror.json",
|
||||
"config/examples/publisher.json",
|
||||
"config/examples/release-actions.json",
|
||||
"config/examples/release-envelope.json",
|
||||
"config/examples/release-hooks.json",
|
||||
"config/examples/release-projection.json",
|
||||
"config/examples/renewal.json",
|
||||
"config/examples/revoker.json",
|
||||
"config/examples/runtime-projection.json",
|
||||
"config/examples/watcher.json",
|
||||
"config/examples/writer-server.json",
|
||||
"deploy/systemd/aios-v2r1c4-release-envelope.service",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/credentials.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/gitea_api.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/grant.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/publisher.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_action.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_deploy.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_hooks.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_transport.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/revoker.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/socket_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer_server.py",
|
||||
"tests/autonomous_control_plane/test_deployment_and_contracts.py",
|
||||
"tests/autonomous_control_plane/test_services_and_hooks.py"
|
||||
],
|
||||
"base_package_sha256": "795feb406541af86f30c1fb31f85e6d7485ee72a9d3ac93351f9a63fe4ad3e8f",
|
||||
"deleted_files": [
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/__init__.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/cli.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/common.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/ledger.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/mirror.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/policy.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/projection.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/release.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/renewal.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/watcher.cpython-313.pyc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/writer.cpython-313.pyc",
|
||||
"tests/.pytest_cache/.gitignore",
|
||||
"tests/.pytest_cache/CACHEDIR.TAG",
|
||||
"tests/.pytest_cache/README.md",
|
||||
"tests/.pytest_cache/v/cache/lastfailed",
|
||||
"tests/.pytest_cache/v/cache/nodeids",
|
||||
"tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc",
|
||||
"tests/autonomous_control_plane/__pycache__/test_mirror_projection.cpython-313-pytest-9.0.2.pyc",
|
||||
"tests/autonomous_control_plane/__pycache__/test_policy_and_writer.cpython-313-pytest-9.0.2.pyc",
|
||||
"tests/autonomous_control_plane/__pycache__/test_renewal_release.cpython-313-pytest-9.0.2.pyc",
|
||||
"tests/autonomous_control_plane/__pycache__/test_watcher.cpython-313-pytest-9.0.2.pyc"
|
||||
],
|
||||
"deletion_policy": "Only generated caches listed here may be removed. Do not delete repository source files.",
|
||||
"integration_strategy": "Apply additions and replacements on patch/v2r1c4-release-completion-20260729; preserve existing approved Git objects and user code.",
|
||||
"modified_files": [
|
||||
"CODE_HANDOFF_MANIFEST.json",
|
||||
"INTEGRATION_NOTES.md",
|
||||
"TEST_RECEIPT.json",
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service",
|
||||
"deploy/systemd/aios-governance-request-publisher.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service",
|
||||
"deploy/systemd/aios-governed-development-writer.service",
|
||||
"deploy/systemd/aios-release-object-projection.service",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service",
|
||||
"deploy/tmpfiles.d/aios-autonomous-control-plane.conf",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer.py",
|
||||
"services/aios-autonomous-control-plane/pyproject.toml",
|
||||
"tests/autonomous_control_plane/test_mirror_projection.py",
|
||||
"tests/autonomous_control_plane/test_renewal_release.py"
|
||||
],
|
||||
"schema": "aios.code-handoff-delta/v1",
|
||||
"target_package_name": "aios_v2r1c4_autonomy_patch_v2_20260729.zip"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"AGENT_INTEGRATION_PLAN.md": "0af0bb6c3ed22d30c48e1e9af74540a0f4571dabe5dcf97bc67077d5cca6ea1d",
|
||||
"CODE_HANDOFF_MANIFEST.json": "b1e3fde19a864671b28945ff21fd6c4f1fe2fd4bb7d78048ba1430d3de286cf9",
|
||||
"DELTA_FROM_V1.json": "296571cac8fe3117443ad04c045a02203f1bb9eef5284f1704dda9c04e75e9ae",
|
||||
"INTEGRATION_NOTES.md": "e70df9ca86ed136d193b1773596f7187d6c1f8751f40c133d3e93f017233d05c",
|
||||
"TEST_RECEIPT.json": "e1e3cd8934d8b79a88bf740575e37c1407ab2a6dbad099a2730476e3b073564a",
|
||||
"config/examples/development-policy.json": "51ab926a733c9a5173df43a3bf23d25ee3bd2b4743f2005cc458dcef232f62e4",
|
||||
"config/examples/grant.json": "2dbece7418377880c63108e07155a3e97859f8b24d087ad353749cddf2572ffd",
|
||||
"config/examples/mirror.json": "41b3948ad34a0a58b5af389d93f9a51241544449b08b90daa93b74ab5d22d292",
|
||||
"config/examples/publisher.json": "d5198ba71dc87e9863b1fa1fcce0fabcc5d347e508fa2736041772fa68c083ea",
|
||||
"config/examples/release-actions.json": "08d08fe97f3fdd336cd8f0dbc1c358bb5c9a9b2c7b1f66d5ab0682ea6aa0572e",
|
||||
"config/examples/release-envelope.json": "6b3f31e75a9ec0d820e304932487c8a7961488d65b98c59a45320c52b3ca7a0f",
|
||||
"config/examples/release-hooks.json": "6bb755673fee080e968395478cbf3d4697b8afc47d1f791890b528693cfcdbf2",
|
||||
"config/examples/release-projection.json": "fa898f3fdb877f1c449999a827a9afb8797e026fd574ca7375b386069cf60e3d",
|
||||
"config/examples/renewal.json": "59c27809e4f066e7b12cd0b57fecf46dedf28640be1c92c6906c1b1c19e7b25a",
|
||||
"config/examples/revoker.json": "e4eaf3255e444c50ed29e9ee78b5d3284d26d74b55e17d0060b7e285b88b5845",
|
||||
"config/examples/runtime-projection.json": "4f11d1d15d8daf26b2c6dc7e01d5626315fa7f2ca3c32923f029728a67b86534",
|
||||
"config/examples/watcher.json": "95d83d7eedc3cbaf43901e9ec313f3cf08d9f5832c02910837187a2e32b9a465",
|
||||
"config/examples/writer-server.json": "f0b5648f3f8e4e37ad934aaff8c55480a25139b731f676e49adf9ae1a0ebe0e1",
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service": "38cf3269cac8e73cc004de50e051f26ea2e1f1a1427682e129b47960210e3c31",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service": "e48174384113644771eaf893c68e49432fc7b5e8ab4b4fa301d191f079b4e30f",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service": "1ef26557563b93d411b7a116800ca960dcde69051fd222c64555bf274ec15ca8",
|
||||
"deploy/systemd/aios-governance-request-publisher.service": "c4a69bea0fc23849aa3307d8939e08c3925c9df445d0e43ef7fb689e285e5f89",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service": "f9128b9a2a4f77bde98f7e8a735babc67e14186860e8cb34eb94f6126dd6f7b5",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.timer": "0bf2bc416012b5009ccb9c7b8f76d546fa6485295a926829fd3b26e0c1f071ce",
|
||||
"deploy/systemd/aios-governed-development-writer.service": "b5d052b9f3cf81bc5587dd0545ac25ae55d29e37c32c760fb9725dced351598e",
|
||||
"deploy/systemd/aios-release-object-projection.service": "91acf73dcab189aebe7900237f25a3accaa804624c1b4f52a2b477128142ed46",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service": "45cf8a5c17fcd6960970ed8f26b02a1d3fdc53874d39c14b4a1d1d89d543679e",
|
||||
"deploy/systemd/aios-v2r1c4-release-envelope.service": "b9b4ffb3a41cf90256cb970fb300a8e633e91527f19c8a8b8ee1e3cabdda4492",
|
||||
"deploy/tmpfiles.d/aios-autonomous-control-plane.conf": "d5f3aefe63515b5699fbb81421480015bfa3bc1794ec02b8214a3c21f9cea8f2",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__init__.py": "52a7864b6a4ac94be8f67100c825813b19ccf889283bbb7dbecbd342b9ec29dc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/cli.py": "cd42b70c35b2d810433db0df013a439dc7993c45c68ea930a9efd5b5faced84c",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/common.py": "395c669df606ed366774663d4ac229225469c77ab3ff72f6a9efcbcef8031f9e",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/credentials.py": "61322d9f494a63764f1148550853d7ca7bcd9be74451bd3c3cf3cbe6388d2136",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/gitea_api.py": "9b0a7d9db9a4497078d8f4b0bf7622b292b40282204a1e1855e11f1edc93b6de",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/grant.py": "5b1cb302a1365f4a6f03df73f8c5f8a1595304d65e9d7ac129178d51b38dde59",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/ledger.py": "a51eea543d4e44c2faab6b59c7850600908f36090f0a1ddf448c1ca7c21acdbd",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror.py": "20093cf68c0b4db4940d1092b0f09d7638ed210e2739973b1a558a4016208e01",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror_service.py": "a46549c4c8b29006ed2b1aec201e470e88e2f9b347ef2fa81839ac7d8093320b",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/policy.py": "5fc5b50e781cfa47e99164635155a9559d168f1f556d8ca2f1694b57b1b57195",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection.py": "6ee91b9b6482d4f10c98aa7e78bd23dc5f9f759c875db2e0a3207cfe27714c4c",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection_service.py": "2019ca8e51affcebe6c776c2c3b51a54e1d6134874e3a9b12fc774393023172a",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/publisher.py": "fd65490c1b384b2725315a066715c9822255bbef3c213ab343495c6c96a7e90f",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release.py": "2579fc38834d77c6370dcdc4269134fe42cf85540efcebf9e4f769ab6cbde200",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_action.py": "26254215e032f361737584e2b8eb8367fe9f6930b946511e16f57d8e7f50cee9",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_deploy.py": "bfb46b6dfacd88c69cee14c9f482a99a95a7b12f1de4b8778fdcdf1b66cfdbd3",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_hooks.py": "10e485d39a4fcfba4323297101f4281e6a0a94a60f6ee64a8e22a5d171d79965",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_service.py": "8d67a4b1562879b500120f65fb8f030183b0fb70acee646656dd235f00d0b618",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_transport.py": "822257f2c48c1fe861d8766aad2d61302e8b4e223a15b26f3c75c993c30140e2",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal.py": "2272c428999a769218142b27b0a80e938c96b7cf1e553fdfd94873123dd9ad86",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal_service.py": "c7fc5e307bd6eaef35844868cecc530b463088b4f320e531800d8683f3a3733a",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/revoker.py": "6953ec6442635a495ec602fbf6b2815aee40e43ad3790bf972a887bbb2de537d",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/socket_service.py": "a42c80002e8fcea7000ee0f4fd2a437b44b18773edbdbb9dfc088d385d2782e7",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher.py": "eed557d5f6acfe44881bee44dd92fea6e04ac19f33e2dea874c2a4662b04c824",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher_service.py": "db502f08bafad752935180477894bb78d5b36f6261f8af13d8db2d9ecce184bb",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer.py": "59b8f8d5c96a455e280de5a12280aca05070f86deb443e8f6fd842d7872dc60f",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer_server.py": "1680ca5a053da5b9a7430a616904f474cf27833496f50ead2803227598fd901f",
|
||||
"services/aios-autonomous-control-plane/pyproject.toml": "4dae4bf9a88d033511a8de6d2871583d36cff868c3e0c6287c78d584933816df",
|
||||
"tests/autonomous_control_plane/test_deployment_and_contracts.py": "2606242519f4cdf5783dd36708357c6ec9520d66f8f8ec18ef28529e3bd240d4",
|
||||
"tests/autonomous_control_plane/test_mirror_projection.py": "5dc56cc0b5910d4b83afa4354f0375a2668ed5a328823357dc3ef2819235f26e",
|
||||
"tests/autonomous_control_plane/test_policy_and_writer.py": "723a6fafcc542de7da028d2a2f76a6eb2b7975d4e6e9d1a23c17973019f74b7c",
|
||||
"tests/autonomous_control_plane/test_renewal_release.py": "3199b9e87995d43ee97e8e62ebe85ef4f242afb16912bd1551d7570cb7d7474c",
|
||||
"tests/autonomous_control_plane/test_services_and_hooks.py": "e58bde487ed38dfbb123a37ae43733c28a519d294a7729d01823adf7d2e48dd5",
|
||||
"tests/autonomous_control_plane/test_watcher.py": "025ab4432522478ab7094751edf8da702b0e7be25d93876115cf9f416b7188b1",
|
||||
"tests/conftest.py": "32a3df0734d253b070594fce0327b929c8ee0bbb136ea6fa83a2abecb826415b"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# AIOS V2R1C4 Autonomy Patch v2
|
||||
|
||||
This package completes the capabilities that were missing from the first handoff.
|
||||
|
||||
## Newly delivered
|
||||
|
||||
- Idempotent, credential-isolated governance request publisher.
|
||||
- Authenticated Unix-socket development writer server with SO_PEERCRED checks.
|
||||
- Root-owned task/branch/repository/path-bound Writer Grant and idempotent quota ledger.
|
||||
- Writer credential revocation, old-token HTTP 401 proof, encrypted credential removal,
|
||||
and read-only revoked Grant evidence.
|
||||
- Persistent watcher, mirror, projection, and renewal service entrypoints.
|
||||
- Complete release hook implementation:
|
||||
- Owner exact-head and machine-status validation.
|
||||
- Approved governance Manifest verification.
|
||||
- Governed merge and tag transports.
|
||||
- Remote/Gitea tag verification.
|
||||
- Safe Artifact extraction and atomic release symlink deployment.
|
||||
- systemd health checks.
|
||||
- rollback and restore.
|
||||
- Registry/Baseline snapshots, compensation, and final consistency.
|
||||
- Unified V2R1C4 release executor with automatic failure rollback.
|
||||
- 11 installed console entrypoints and 10 systemd/timer contracts.
|
||||
|
||||
## Offline verification
|
||||
|
||||
- compileall: PASS
|
||||
- pytest: 21 passed
|
||||
- Secret scan: REVIEW
|
||||
- Path traversal archive test: PASS
|
||||
- Metadata compensation test: PASS
|
||||
- Publisher idempotency test: PASS
|
||||
- Grant replay/quota test: PASS
|
||||
- Old credential 401 test: PASS
|
||||
|
||||
## Integration rule
|
||||
|
||||
Apply this package to `patch/v2r1c4-release-completion-20260729`.
|
||||
Agent should only supply environment configuration, existing governed endpoint
|
||||
addresses, encrypted credentials, exact Git object IDs, and online acceptance.
|
||||
Do not redesign or replace the delivered control-plane logic.
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"compatibility_adaptations": [
|
||||
{
|
||||
"binding": "resolved active Python executable; release hook rejects symlink executables",
|
||||
"new_sha256": "bd5c90b12f5f128cb3151f83e27748e8c382d1f07672dc67e3a97f5cb7ef5b03",
|
||||
"old_sha256": "e58bde487ed38dfbb123a37ae43733c28a519d294a7729d01823adf7d2e48dd5",
|
||||
"path": "tests/autonomous_control_plane/test_services_and_hooks.py",
|
||||
"product_runtime_modified": false,
|
||||
"reason": "host Python is 3.12 and package declares requires-python >=3.11; remove fixture-only /usr/bin/python3.13 binding"
|
||||
}
|
||||
],
|
||||
"compiled_cache_files_integrated": false,
|
||||
"delta_added_count": 32,
|
||||
"delta_deleted_cache_count": 21,
|
||||
"delta_modified_count": 18,
|
||||
"direct_main_push": false,
|
||||
"force_push_used": false,
|
||||
"gitea_api_bootstrap_token_status": 401,
|
||||
"gitea_api_preapply_oid": "e5dc9185dd153efdfb007f9b674197f613c10796",
|
||||
"gitea_api_reader_token_status": 401,
|
||||
"global_gitops_writes_enabled": false,
|
||||
"handoff_metadata_location": "evidence/releases/V2R1C4-code-handoff-v2",
|
||||
"integrated_runtime_and_test_paths": [
|
||||
"config/examples/grant.json",
|
||||
"config/examples/mirror.json",
|
||||
"config/examples/publisher.json",
|
||||
"config/examples/release-actions.json",
|
||||
"config/examples/release-envelope.json",
|
||||
"config/examples/release-hooks.json",
|
||||
"config/examples/release-projection.json",
|
||||
"config/examples/renewal.json",
|
||||
"config/examples/revoker.json",
|
||||
"config/examples/runtime-projection.json",
|
||||
"config/examples/watcher.json",
|
||||
"config/examples/writer-server.json",
|
||||
"deploy/systemd/aios-v2r1c4-release-envelope.service",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/credentials.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/gitea_api.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/grant.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/publisher.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_action.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_deploy.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_hooks.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release_transport.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/revoker.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/socket_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher_service.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer_server.py",
|
||||
"tests/autonomous_control_plane/test_deployment_and_contracts.py",
|
||||
"tests/autonomous_control_plane/test_services_and_hooks.py",
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service",
|
||||
"deploy/systemd/aios-governance-request-publisher.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service",
|
||||
"deploy/systemd/aios-governed-development-writer.service",
|
||||
"deploy/systemd/aios-release-object-projection.service",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service",
|
||||
"deploy/tmpfiles.d/aios-autonomous-control-plane.conf",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer.py",
|
||||
"services/aios-autonomous-control-plane/pyproject.toml",
|
||||
"tests/autonomous_control_plane/test_mirror_projection.py",
|
||||
"tests/autonomous_control_plane/test_renewal_release.py"
|
||||
],
|
||||
"schema": "aios.code-handoff-integration/v2",
|
||||
"source_file_hash_entries": 64,
|
||||
"source_file_hashes_verified": true,
|
||||
"source_zip_sha256": "7afbcfae0ff692d2b859cd1e7e96536b8e80188667554803da438dcddc292d69",
|
||||
"tag_rewritten": false,
|
||||
"v1_modified_base_files_verified": 15
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"collection_errors": 0,
|
||||
"compileall_returncode": 0,
|
||||
"delta_added_files": 32,
|
||||
"delta_deleted_files": 21,
|
||||
"delta_modified_files": 18,
|
||||
"entrypoint_count": 11,
|
||||
"generated_at": "2026-07-29T08:11:08.888275Z",
|
||||
"pytest_failed": 0,
|
||||
"pytest_passed": 21,
|
||||
"pytest_returncode": 0,
|
||||
"secret_findings": [
|
||||
{
|
||||
"path": "tests/autonomous_control_plane/test_policy_and_writer.py",
|
||||
"rule": "literal-credential-assignment"
|
||||
}
|
||||
],
|
||||
"secret_scan": "PASS_TEST_FIXTURE_ONLY",
|
||||
"systemd_contract_count": 10
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"actions": {
|
||||
"artifact_built": false,
|
||||
"baseline_modified": false,
|
||||
"control_plane_deployed": false,
|
||||
"core_redesigned_by_agent": false,
|
||||
"main_modified": false,
|
||||
"online_write_acceptance_executed": false,
|
||||
"owner_approval_pr_created": false,
|
||||
"production_modified": false,
|
||||
"registry_modified": false,
|
||||
"tag_created": false
|
||||
},
|
||||
"blocking_layer": "CODE_HANDOFF_SECURITY_INVARIANTS",
|
||||
"candidate_base_oid": "e5dc9185dd153efdfb007f9b674197f613c10796",
|
||||
"confirmed_security_findings": [
|
||||
{
|
||||
"evidence": "release_action.py:53-98",
|
||||
"id": "P0-OWNER-ENVELOPE-BINDING",
|
||||
"summary": "approved manifest digest is checked but manifest fields are not parsed and made equal to the release envelope"
|
||||
},
|
||||
{
|
||||
"evidence": "release.py:20-30,52-59",
|
||||
"id": "P0-UNCONSUMED-RELEASE-DIGESTS",
|
||||
"summary": "rollback, SBOM, test, evidence and tag/deployment/rollback plan digests are format checked but not verified against objects before side effects"
|
||||
},
|
||||
{
|
||||
"evidence": "release_deploy.py:173-188; release.py:168-170,210-214",
|
||||
"id": "P0-PARTIAL-DEPLOY-NO-COMPENSATION",
|
||||
"summary": "deployment side effects occur while phase is TAG_VERIFIED, which is excluded from automatic rollback"
|
||||
},
|
||||
{
|
||||
"evidence": "release_deploy.py:191-212",
|
||||
"id": "P0-ROLLBACK-TARGET-UNBOUND",
|
||||
"summary": "previous and restored directories are not constrained to release_root and are not bound to the approved rollback digest"
|
||||
},
|
||||
{
|
||||
"evidence": "policy.py:72-76; grant.py:10-25,62-71; writer.py:94-107; writer_server.py:28-38,64-68",
|
||||
"id": "P0-WRITER-GRANT-BOUNDARY",
|
||||
"summary": "expected base ancestry, exact task worktree/common-dir/remote and task credential identity are not closed"
|
||||
},
|
||||
{
|
||||
"evidence": "revoker.py:36-64; aios-governed-development-writer-revoker.timer:4-8",
|
||||
"id": "P0-REVOKER-EXPIRY-RECOVERY",
|
||||
"summary": "fixed five-minute timer ignores grant expiry and credential deletion precedes recoverable grant and receipt closure"
|
||||
},
|
||||
{
|
||||
"evidence": "deploy/tmpfiles.d/aios-autonomous-control-plane.conf:1-19",
|
||||
"id": "P1-SERVICE-DIRECTORY-PERMISSIONS",
|
||||
"summary": "0750 root:gitops directories are not writable by non-root writer and publisher users"
|
||||
},
|
||||
{
|
||||
"evidence": "release_transport.py:65-76; release_deploy.py:221-228",
|
||||
"id": "P1-LOOPBACK-URL-PREFIX",
|
||||
"summary": "string prefix checks accept non-loopback hosts and can forward credentials"
|
||||
}
|
||||
],
|
||||
"deployment_decision": "BLOCKED_PRODUCTION_DEPLOYMENT",
|
||||
"execution_status": "PARTIALLY_COMPLETED",
|
||||
"governance": {
|
||||
"AIOS_GITOPS_WRITES_ENABLED": false,
|
||||
"direct_main_push": false,
|
||||
"force_push_used": false,
|
||||
"owner_approval_respected": true,
|
||||
"secrets_committed": false,
|
||||
"tag_rewritten": false
|
||||
},
|
||||
"integrity": {
|
||||
"file_sha256": "64/64 PASS",
|
||||
"hardcoded_credentials": 0,
|
||||
"path_traversal_entries": 0,
|
||||
"secret_scan": "PASS",
|
||||
"v1_modified_base_files": "15/15 PASS",
|
||||
"zip_sha256": "7afbcfae0ff692d2b859cd1e7e96536b8e80188667554803da438dcddc292d69"
|
||||
},
|
||||
"live_binding_inventory": {
|
||||
"authoritative_bare": "/data/aios-gitops/repo.git",
|
||||
"candidate_health_endpoint_127_0_0_1_18890": "PRESENT_HTTP_200_NOT_APPROVED_AS_RELEASE_BINDING",
|
||||
"configured_health_endpoint_127_0_0_1_8080": "ABSENT",
|
||||
"configured_tag_endpoint_127_0_0_1_8080": "ABSENT",
|
||||
"gitea_bare": "/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios.git",
|
||||
"gitea_db": "/data/aios-gitops/runtime/gitea-bin/data/gitea.db",
|
||||
"gitea_repo_id": 1,
|
||||
"integration_merge_writer_socket": "ABSENT",
|
||||
"owner_user_id": 2,
|
||||
"production_baseline_path": "ABSENT",
|
||||
"release_root_current_previous": "ABSENT",
|
||||
"v2r1c4_artifact": "ABSENT",
|
||||
"v2r1c4_registry": "ABSENT"
|
||||
},
|
||||
"observed_at": "2026-07-29T09:11:19.725943+00:00",
|
||||
"schema": "aios.code-handoff-validation/v2",
|
||||
"verification": {
|
||||
"collection_errors": 0,
|
||||
"compileall": "PASS",
|
||||
"console_entrypoints": "11/11 load PASS",
|
||||
"package_tests_after_fixture_adapter": "21 passed",
|
||||
"package_tests_original_host": "20 passed, 1 failed due fixture-only /usr/bin/python3.13 binding",
|
||||
"package_version": "0.2.0",
|
||||
"repository_tests": "660 passed, 1 skipped, 180 subtests passed",
|
||||
"runtime_dependencies": "0",
|
||||
"systemd_units": "10/10 staged-root syntax PASS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"components": [
|
||||
"governed-development-writer",
|
||||
"approval-mirror-worker",
|
||||
"approval-watcher",
|
||||
"release-object-projection",
|
||||
"runtime-source-projection",
|
||||
"automatic-renewal-controller",
|
||||
"v2r1c4-release-envelope"
|
||||
],
|
||||
"direct_main_push": false,
|
||||
"force_push": false,
|
||||
"global_writes_must_remain_false": true,
|
||||
"schema": "aios.code-handoff/v1",
|
||||
"source": "codex-chartgpt-bridge",
|
||||
"tag_rewrite": false,
|
||||
"target_branch": "patch/v2r1c4-release-completion-20260729",
|
||||
"wechat_backend": {
|
||||
"code_generated": false,
|
||||
"reason": "PR #20 source diff exceeded the available controlled diff interface; do not invent API contracts.",
|
||||
"required_action": "Import the exact approved PR #20 objects, then apply the release-envelope tests and hardening checks."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"CODE_HANDOFF_MANIFEST.json": "5723d17bee218d5fac627b439b898fe57506f3a153feab4561013b1f37937c26",
|
||||
"INTEGRATION_NOTES.md": "e058b51d6c0fabb25abcdff44058a56f5d6adcaf9733b29b9cc4fc10de1603b3",
|
||||
"TEST_RECEIPT.json": "e8699510eca3b805dac879ba3d6955954ebe17de7cd2142e60c205902627b8cf",
|
||||
"config/examples/development-policy.json": "51ab926a733c9a5173df43a3bf23d25ee3bd2b4743f2005cc458dcef232f62e4",
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service": "1e8825341884c428535ed0e56259c5c5919387d7dba130d36812bbb3312d4e29",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service": "19d5a1e1b5e89405dc8fbe3332daaa4fd652cc903d12e1a81a3e5fb8cf5ad538",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service": "8d7ed60961ec19fe72ef183d28c5ce8185decff5000f5f884acfb83a4bf21bbd",
|
||||
"deploy/systemd/aios-governance-request-publisher.service": "037c709d737677257ac3911285768db5549d557cada7f31f3913131873f93c70",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service": "ac566dd3a01d4b83e44a32fcd78d34355a2cf439c91d3e53f554e65c8e480246",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.timer": "0bf2bc416012b5009ccb9c7b8f76d546fa6485295a926829fd3b26e0c1f071ce",
|
||||
"deploy/systemd/aios-governed-development-writer.service": "2f3061c2fe114243b3bb66031bb50175d65ead7448d8c83082faa2135af7b6cc",
|
||||
"deploy/systemd/aios-release-object-projection.service": "e5341f89ceb4c362b2ed87a6eba7c8bf150aec293788c1aa997d5dfc689dff44",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service": "1a83e23203f7edee365d74d8a825f4a3e1dfea6177b84c3faa6c5dda6c1ee78b",
|
||||
"deploy/tmpfiles.d/aios-autonomous-control-plane.conf": "9e36e522d87f85a13fd03c68f006e63068cf94bec06525eb420344dc734fa162",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__init__.py": "52a7864b6a4ac94be8f67100c825813b19ccf889283bbb7dbecbd342b9ec29dc",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/__init__.cpython-313.pyc": "6704553f751bce9e6a544178580eedf7c3a11f58b6f9eab554e1dc53a027020e",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/cli.cpython-313.pyc": "9d827fb60110c05593916050fcd647708886c12c6e9e784533a56f3525dd8c9b",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/common.cpython-313.pyc": "9e6368b69c75abf46cf708267c7dde591b0a6cdd535f87b6022ea9393ac7c95b",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/ledger.cpython-313.pyc": "abf49a51060c8637eb3aae378a86ea3938177b5d442bd93f1f581434a2c45604",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/mirror.cpython-313.pyc": "0dbdfc445d19227346c75815597476e8dc5bbcba6e08a748624bc5813f49dcb9",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/policy.cpython-313.pyc": "75724693bc0810d8e58fb44389afba62cb6a828e8447e76b61751de83d7c5d60",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/projection.cpython-313.pyc": "a09ae9226e4398fe1b469772b06383832d2266134493fa79f19c62fcd3101da2",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/release.cpython-313.pyc": "dafaafbb1076b5e590b0e25eb103cceb5c6ae336fc2db31e014e8cc3fffc7432",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/renewal.cpython-313.pyc": "9438c81cda1987100cf5f6448186688a58fc01ac59333497afefaeb4c080026a",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/watcher.cpython-313.pyc": "4a729399caf5421c688fd7ab37c98fe796a9b9b5361929185fcb2a1ea3ed3456",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__pycache__/writer.cpython-313.pyc": "75e593baf6864353004edb76abe72f1f566ca77a820c23dcee5703e092c5c621",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/cli.py": "cd42b70c35b2d810433db0df013a439dc7993c45c68ea930a9efd5b5faced84c",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/common.py": "395c669df606ed366774663d4ac229225469c77ab3ff72f6a9efcbcef8031f9e",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/ledger.py": "a51eea543d4e44c2faab6b59c7850600908f36090f0a1ddf448c1ca7c21acdbd",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror.py": "20093cf68c0b4db4940d1092b0f09d7638ed210e2739973b1a558a4016208e01",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/policy.py": "5fc5b50e781cfa47e99164635155a9559d168f1f556d8ca2f1694b57b1b57195",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection.py": "3498affdf02fcb51d8a702ff57046ac6d528480ced808b70de1ad56250f61522",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release.py": "21f4a93a7121c893aaa52bbb31909215a4f64db32673fb10abc61abfdd94897c",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal.py": "2272c428999a769218142b27b0a80e938c96b7cf1e553fdfd94873123dd9ad86",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher.py": "eed557d5f6acfe44881bee44dd92fea6e04ac19f33e2dea874c2a4662b04c824",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer.py": "79503f3819469051ffb5f222156891a22981fa62ed37b72ce42da6247ae5792d",
|
||||
"services/aios-autonomous-control-plane/pyproject.toml": "52200725816a1cdbb5e22b6c477fd80539a11013bcf0d6545d99807b29a0c697",
|
||||
"tests/.pytest_cache/.gitignore": "3ed731b65d06150c138e2dadb0be0697550888a6b47eb8c45ecc9adba8b8e9bd",
|
||||
"tests/.pytest_cache/CACHEDIR.TAG": "37dc88ef9a0abeddbe81053a6dd8fdfb13afb613045ea1eb4a5c815a74a3bde4",
|
||||
"tests/.pytest_cache/README.md": "73fd6fccdd802c419a6b2d983d6c3173b7da97558ac4b589edec2dfe443db9ad",
|
||||
"tests/.pytest_cache/v/cache/lastfailed": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"tests/.pytest_cache/v/cache/nodeids": "bcee695f0c2c1125930b78eae1badd0ffce72495c63bdbb0a44cd50e4d54be8d",
|
||||
"tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc": "10fb2a851650254dd15bbfb89011cd8373d6c7a052d3181d8e258110d2a3f961",
|
||||
"tests/autonomous_control_plane/__pycache__/test_mirror_projection.cpython-313-pytest-9.0.2.pyc": "3cbb8295357bee399ab2d2c33b08d55cccb8e2cd5568accb9eedcb756b3be4d1",
|
||||
"tests/autonomous_control_plane/__pycache__/test_policy_and_writer.cpython-313-pytest-9.0.2.pyc": "41573389ecbc16ae8c1b9990db12dd2f5994469584388ebc82cd0aeb345ca36f",
|
||||
"tests/autonomous_control_plane/__pycache__/test_renewal_release.cpython-313-pytest-9.0.2.pyc": "e7b05c53ba735723d27cedb7275b24450310109b1e752178c54122a534b2775b",
|
||||
"tests/autonomous_control_plane/__pycache__/test_watcher.cpython-313-pytest-9.0.2.pyc": "5e13e650b803412db0105d3e7f3e84cada3187a170173360c6da0304a193cb1e",
|
||||
"tests/autonomous_control_plane/test_mirror_projection.py": "818410d5571d36cb23df0db0be444a9c08c705325ecfeeebe88fc0d82dbffcd3",
|
||||
"tests/autonomous_control_plane/test_policy_and_writer.py": "723a6fafcc542de7da028d2a2f76a6eb2b7975d4e6e9d1a23c17973019f74b7c",
|
||||
"tests/autonomous_control_plane/test_renewal_release.py": "29dd1031e59da0d3b5a96a4f613f1a110e0feb77f983ef2072b53795212e7ea1",
|
||||
"tests/autonomous_control_plane/test_watcher.py": "025ab4432522478ab7094751edf8da702b0e7be25d93876115cf9f416b7188b1",
|
||||
"tests/conftest.py": "32a3df0734d253b070594fce0327b929c8ee0bbb136ea6fa83a2abecb826415b"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# AIOS V2R1C4 Autonomy Patch
|
||||
|
||||
This package contains code developed for integration into
|
||||
`patch/v2r1c4-release-completion-20260729`.
|
||||
|
||||
It deliberately does not invent WeChat Backend endpoints or schemas. The
|
||||
business implementation from PR #20 must be imported from its real Git objects,
|
||||
then tested against the release envelope. The control-plane code in this package
|
||||
is complete and independently testable without production credentials.
|
||||
|
||||
## Components
|
||||
|
||||
- Governed Development Writer with branch/path/operation policy, idempotent
|
||||
ledger, secret scan, expected-head CAS, push verification and reconciliation.
|
||||
- Approval Mirror Worker using file quarantine, add-only approvals validation,
|
||||
fast-forward-only authoritative update and connectivity fsck.
|
||||
- Gitea SQLite Approval Watcher with exact-head Owner review validation.
|
||||
- Release Object Projection and root-only Runtime Source Projection.
|
||||
- Automatic Renewal Controller with immutable-field comparison and bounded
|
||||
renewal windows.
|
||||
- Unified V2R1C4 Release Envelope executor covering merge, tag, remote
|
||||
verification, deploy, health, rollback, restore, Registry, Baseline and final
|
||||
consistency.
|
||||
- Hardened systemd units and tmpfiles definitions.
|
||||
|
||||
## Agent integration constraints
|
||||
|
||||
- Keep `AIOS_GITOPS_WRITES_ENABLED=false`.
|
||||
- Apply on a non-main branch only.
|
||||
- Do not copy sample credentials or secrets.
|
||||
- Resolve actual Gitea repository and database paths dynamically.
|
||||
- Replace server wrapper placeholders under `/usr/libexec` with thin adapters
|
||||
calling the supplied Python package.
|
||||
- Run the supplied tests plus existing repository tests before requesting Owner
|
||||
approval.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compiled_cache_files_integrated": false,
|
||||
"credential_scan": "PASS_TEST_REJECTION_FIXTURE_ONLY",
|
||||
"direct_main_push": false,
|
||||
"file_sha256_entries": 52,
|
||||
"file_sha256_verified": true,
|
||||
"fixture_value_sha256": "653bb1245e828fcda4fa53fcd5a3def5bd7654e651f54b4132b73d74e64435c4",
|
||||
"force_push_used": false,
|
||||
"global_gitops_writes_enabled": false,
|
||||
"path_traversal_entries": 0,
|
||||
"schema": "aios.code-handoff-integration/v1",
|
||||
"source_files_integrated": [
|
||||
"config/examples/development-policy.json",
|
||||
"deploy/tmpfiles.d/aios-autonomous-control-plane.conf",
|
||||
"services/aios-autonomous-control-plane/pyproject.toml",
|
||||
"tests/conftest.py",
|
||||
"deploy/systemd/aios-autonomous-renewal-controller.service",
|
||||
"deploy/systemd/aios-governance-approval-watcher.service",
|
||||
"deploy/systemd/aios-governance-mirror-worker.service",
|
||||
"deploy/systemd/aios-governance-request-publisher.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.service",
|
||||
"deploy/systemd/aios-governed-development-writer-revoker.timer",
|
||||
"deploy/systemd/aios-governed-development-writer.service",
|
||||
"deploy/systemd/aios-release-object-projection.service",
|
||||
"deploy/systemd/aios-runtime-source-read-projection.service",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/__init__.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/cli.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/common.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/ledger.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/mirror.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/policy.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/projection.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/release.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/renewal.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/watcher.py",
|
||||
"services/aios-autonomous-control-plane/aios_autonomous_control_plane/writer.py",
|
||||
"tests/autonomous_control_plane/test_mirror_projection.py",
|
||||
"tests/autonomous_control_plane/test_policy_and_writer.py",
|
||||
"tests/autonomous_control_plane/test_renewal_release.py",
|
||||
"tests/autonomous_control_plane/test_watcher.py"
|
||||
],
|
||||
"tag_rewritten": false,
|
||||
"zip_sha256": "795feb406541af86f30c1fb31f85e6d7485ee72a9d3ac93351f9a63fe4ad3e8f"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compileall_returncode": 0,
|
||||
"pytest_returncode": 0,
|
||||
"pytest_stderr": "Spreadsheet runtime warmup failed during python startup\nTraceback (most recent call last):\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/patches/warm_spreadsheet_runtime_on_startup.py\", line 26, in warm_spreadsheet_runtime_on_startup\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/spreadsheet_warmup.py\", line 785, in warm_spreadsheet_runtime\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/spreadsheet_warmup.py\", line 720, in _warm_feature_flows\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/spreadsheet_warmup.py\", line 704, in _warm_collaboration_flows\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/generated/interface/models.py\", line 30820, in hydrate_crdt_from_proto\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/rpc/remote.py\", line 749, in __call__\n File \"/tmp/tmp.yTcnQsZYiA/artifact_tool_v2-2.8.4/artifact_tool/rpc/client.py\", line 150, in call\nartifact_tool.rpc.client.RemoteError: hydrateCrdtFromProto requires an empty collaborative document.\n",
|
||||
"pytest_stdout": "\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m [100%]\u001b[0m\n\u001b[32m\u001b[32m\u001b[1m10 passed\u001b[0m\u001b[32m in 0.45s\u001b[0m\u001b[0m\n"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"artifact_path": "/var/lib/aios-gitops/runtime/tasks/AIOS-SINGLE-OWNER-GOVERNANCE-ACTIVATION-20260729/artifacts/hardening-71def340e7cc34f4f19918dcf8d3c847abbe4fbf/live-activation-71def340e7cc34f4f19918dcf8d3c847abbe4fbf.tar.gz",
|
||||
"artifact_sha256": "32bc49c207ec454b6fd81b0484f5b257bb789481b37175995029a2ec78cc62fa",
|
||||
"copied_live_db_acceptance": "PASS",
|
||||
"implementation_commit_oid": "71def340e7cc34f4f19918dcf8d3c847abbe4fbf",
|
||||
"implementation_tree_oid": "3cc61bfbf69cbe0a24d54a0e53a6c50bcc130066",
|
||||
"independent_validator": "ALLOW_CANDIDATE",
|
||||
"production_mutation": false,
|
||||
"result": "PASS",
|
||||
"rollback_artifact_path": "/var/lib/aios-gitops/runtime/tasks/AIOS-SINGLE-OWNER-GOVERNANCE-ACTIVATION-20260729/artifacts/hardening-71def340e7cc34f4f19918dcf8d3c847abbe4fbf/live-activation-rollback-71def340e7cc34f4f19918dcf8d3c847abbe4fbf.tar.gz",
|
||||
"rollback_artifact_sha256": "4efe1ea73d4fd72e5364fc91a16ad5e9d1e6ebe78d8aa466f7b11c4915b08403",
|
||||
"runtime_source_sha256": {
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/activation.py": "48846a58fa3afb2b7c8ffe6e88504a9333863932df867f33ba0321ad3bac6003",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/gitea_protected_branch.py": "00775efe3f3d631d88379eff4ed2dcfaf386a2fa43111abe961e75109e80d768",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/journal.py": "a8f121245e04487d99c99fb03ada83f0c907723f8eb18552872ff46dcf5a7873",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/state_machine.py": "0b08a2c378d143cb9031a25132afc7b7d825bdc03d11efe10610341d4b70167e",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/validator.py": "754639846cb15cb997d612a1fe0ceb0224ddef5bbc3ccac9802a3747f122dac1"
|
||||
},
|
||||
"subtests_passed": 17,
|
||||
"task_id": "AIOS-SINGLE-OWNER-GOVERNANCE-ACTIVATION-20260729",
|
||||
"test_evidence_path": "/var/lib/aios-gitops/runtime/tasks/AIOS-SINGLE-OWNER-GOVERNANCE-ACTIVATION-20260729/artifacts/hardening-71def340e7cc34f4f19918dcf8d3c847abbe4fbf/test-evidence-71def340e7cc34f4f19918dcf8d3c847abbe4fbf.json",
|
||||
"test_evidence_sha256": "c1633732767f3f051ffb01404d12d05a6168a4b01b91efdb34a64988d6dee684",
|
||||
"tests_failed": 0,
|
||||
"tests_passed": 90
|
||||
}
|
||||
+10
-1
@@ -2,6 +2,15 @@
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT"
|
||||
manifest_log="${TMPDIR:-/tmp}/aios_manifest_check.$$.log"
|
||||
cleanup() {
|
||||
rm -f -- "$manifest_log"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
bash scripts/package_contract_check_v1r1c0.sh
|
||||
if [ -f FULL_PACKAGE_FILE_MANIFEST.sha256 ]; then sha256sum -c FULL_PACKAGE_FILE_MANIFEST.sha256 --ignore-missing >/tmp/aios_manifest_check.log || true; fi
|
||||
if [ ! -f FULL_PACKAGE_FILE_MANIFEST.sha256 ]; then
|
||||
echo "FULL_PACKAGE_FILE_MANIFEST.sha256 missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
sha256sum -c FULL_PACKAGE_FILE_MANIFEST.sha256 --ignore-missing >"$manifest_log"
|
||||
echo "AIOS_V1R1C0_PACKAGE_VERIFY_OK"
|
||||
|
||||
@@ -1,68 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic Candidate artifact without overwriting evidence."""
|
||||
"""Build deterministic dormant-release candidate artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aios_autonomous_release_candidate.artifact import ArtifactBuilder
|
||||
import tarfile
|
||||
|
||||
|
||||
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 sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
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:
|
||||
parser = argparse.ArgumentParser()
|
||||
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,
|
||||
)
|
||||
)
|
||||
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)))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,513 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Candidate acceptance and emit a non-overwriting JSON evidence record."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import Sequence
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
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))
|
||||
|
||||
|
||||
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.acceptance import ( # noqa: E402
|
||||
AcceptancePhase,
|
||||
AcceptanceStatus,
|
||||
DormantAcceptanceGate,
|
||||
DormantAcceptanceInput,
|
||||
FinalDormantAcceptanceGate,
|
||||
FinalDormantAcceptanceInput,
|
||||
)
|
||||
from aios_autonomous_release_candidate.models import sha256_json # noqa: E402
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
REPORT_SCHEMA = "aios.dormant-release.acceptance/v1"
|
||||
|
||||
|
||||
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
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunnerDecision:
|
||||
phase: str
|
||||
status: str
|
||||
report: Mapping[str, Any]
|
||||
exit_code: int
|
||||
|
||||
|
||||
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)
|
||||
def read_json_object(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
content = (json.dumps(payload, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||
os.write(descriptor, content)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
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",
|
||||
)
|
||||
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,
|
||||
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.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
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
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()
|
||||
parser.add_argument("--repo-root", type=Path)
|
||||
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.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:
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,7 +16,7 @@ required=(
|
||||
"config/runtime_selector.yaml"
|
||||
)
|
||||
for f in "${required[@]}"; do test -f "$ROOT/$f" || { echo "MISSING $f"; exit 1; }; done
|
||||
grep -Eq "^V1R1C[0-9]+$" "$ROOT/VERSION"
|
||||
grep -Eq "^V[0-9]+R[0-9]+C[0-9]+$" "$ROOT/VERSION"
|
||||
python3 -m py_compile \
|
||||
"$ROOT/active-runtime/app/aios_gitops_model_api.py" \
|
||||
"$ROOT/active-runtime/app/aios_chatgpt_actions_api.py" \
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""AIOS autonomous control-plane primitives."""
|
||||
|
||||
from .mirror import ApprovalMirrorWorker, MirrorError
|
||||
from .policy import DevelopmentPolicy, PolicyError
|
||||
from .projection import ReleaseObjectProjector, RuntimeSourceProjector
|
||||
from .release import ReleaseEnvelope, ReleaseEnvelopeExecutor, ReleaseError
|
||||
from .renewal import RenewalController, RenewalError
|
||||
from .watcher import ApprovalSnapshot, GiteaSQLiteApprovalWatcher
|
||||
from .writer import CommitRequest, GovernedDevelopmentWriter, WriterError
|
||||
|
||||
__all__ = [
|
||||
"ApprovalMirrorWorker",
|
||||
"ApprovalSnapshot",
|
||||
"CommitRequest",
|
||||
"DevelopmentPolicy",
|
||||
"GiteaSQLiteApprovalWatcher",
|
||||
"GovernedDevelopmentWriter",
|
||||
"MirrorError",
|
||||
"PolicyError",
|
||||
"ReleaseEnvelope",
|
||||
"ReleaseEnvelopeExecutor",
|
||||
"ReleaseError",
|
||||
"ReleaseObjectProjector",
|
||||
"RenewalController",
|
||||
"RenewalError",
|
||||
"RuntimeSourceProjector",
|
||||
"WriterError",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .common import HashChainAudit
|
||||
from .ledger import OperationLedger
|
||||
from .mirror import ApprovalMirrorWorker
|
||||
from .policy import DevelopmentPolicy
|
||||
from .projection import ReleaseObjectProjector, RuntimeSourceProjector
|
||||
from .renewal import RenewalController
|
||||
from .writer import CommitRequest, GovernedDevelopmentWriter
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise SystemExit("JSON object required")
|
||||
return value
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
writer = commands.add_parser("writer-commit")
|
||||
writer.add_argument("--policy", type=Path, required=True)
|
||||
writer.add_argument("--request", type=Path, required=True)
|
||||
writer.add_argument("--worktree", type=Path, required=True)
|
||||
writer.add_argument("--remote", required=True)
|
||||
writer.add_argument("--ledger", type=Path, required=True)
|
||||
writer.add_argument("--audit", type=Path, required=True)
|
||||
|
||||
mirror = commands.add_parser("mirror")
|
||||
mirror.add_argument("--source-git-dir", type=Path, required=True)
|
||||
mirror.add_argument("--authoritative-git-dir", type=Path, required=True)
|
||||
mirror.add_argument("--target-oid", required=True)
|
||||
|
||||
runtime = commands.add_parser("runtime-project")
|
||||
runtime.add_argument("--path", action="append", type=Path, required=True)
|
||||
runtime.add_argument("--output", type=Path, required=True)
|
||||
runtime.add_argument("--task-id", required=True)
|
||||
|
||||
release = commands.add_parser("release-project")
|
||||
release.add_argument("--source-git-dir", type=Path, required=True)
|
||||
release.add_argument("--commit-oid", required=True)
|
||||
release.add_argument("--tree-oid", required=True)
|
||||
release.add_argument("--output", type=Path, required=True)
|
||||
release.add_argument("--path", action="append", required=True)
|
||||
|
||||
renew = commands.add_parser("renew")
|
||||
renew.add_argument("--standing-policy", type=Path, required=True)
|
||||
renew.add_argument("--previous-manifest", type=Path, required=True)
|
||||
renew.add_argument("--observation", type=Path, required=True)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
os.environ.setdefault("AIOS_GITOPS_WRITES_ENABLED", "false")
|
||||
|
||||
if args.command == "writer-commit":
|
||||
policy = DevelopmentPolicy.from_mapping(load_json(args.policy))
|
||||
request_value = load_json(args.request)
|
||||
content_path = Path(request_value.pop("content_path"))
|
||||
request = CommitRequest(content=content_path.read_bytes(), **request_value)
|
||||
result = GovernedDevelopmentWriter(
|
||||
worktree=args.worktree,
|
||||
remote=args.remote,
|
||||
policy=policy,
|
||||
ledger=OperationLedger(args.ledger),
|
||||
audit=HashChainAudit(args.audit),
|
||||
).commit_file(request)
|
||||
elif args.command == "mirror":
|
||||
result = ApprovalMirrorWorker(
|
||||
args.source_git_dir, args.authoritative_git_dir
|
||||
).mirror(expected_target_oid=args.target_oid).__dict__
|
||||
elif args.command == "runtime-project":
|
||||
result = RuntimeSourceProjector(args.path).collect(args.output, args.task_id)
|
||||
elif args.command == "release-project":
|
||||
result = ReleaseObjectProjector(args.source_git_dir).project(
|
||||
commit_oid=args.commit_oid,
|
||||
expected_tree_oid=args.tree_oid,
|
||||
output_path=args.output,
|
||||
allowed_paths=args.path,
|
||||
).__dict__
|
||||
result["output_path"] = str(result["output_path"])
|
||||
else:
|
||||
result = RenewalController().decide(
|
||||
standing_policy=load_json(args.standing_policy),
|
||||
previous_manifest=load_json(args.previous_manifest),
|
||||
observation=load_json(args.observation),
|
||||
).__dict__
|
||||
print(json.dumps(result, sort_keys=True, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Any, Iterator
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
SENSITIVE_FRAGMENTS = (
|
||||
"authorization", "cookie", "credential", "password", "private_key",
|
||||
"secret", "token",
|
||||
)
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: object) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError("FILE_IDENTITY_INVALID")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def git_blob_oid(raw: bytes) -> str:
|
||||
return hashlib.sha1(b"blob " + str(len(raw)).encode() + b"\0" + raw).hexdigest()
|
||||
|
||||
|
||||
def parse_utc(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("TIMESTAMP_INVALID") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise RuntimeError("TIMESTAMP_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def require_oid(value: object, field: str = "oid") -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise RuntimeError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def require_sha256(value: object, field: str = "sha256") -> str:
|
||||
if not isinstance(value, str) or not SHA256_RE.fullmatch(value):
|
||||
raise RuntimeError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def safe_relpath(value: object) -> str:
|
||||
if not isinstance(value, str) or not value or value != value.strip():
|
||||
raise RuntimeError("RELATIVE_PATH_INVALID")
|
||||
if "\0" in value or "\\" in value:
|
||||
raise RuntimeError("RELATIVE_PATH_INVALID")
|
||||
posix = PurePosixPath(value)
|
||||
windows = PureWindowsPath(value)
|
||||
parts = value.split("/")
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or windows.drive
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise RuntimeError("RELATIVE_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def redact(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): (
|
||||
"[REDACTED]"
|
||||
if any(fragment in str(key).lower() for fragment in SENSITIVE_FRAGMENTS)
|
||||
else redact(item)
|
||||
)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [redact(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [redact(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def ensure_directory(path: Path, mode: int = 0o750) -> None:
|
||||
if path.is_symlink():
|
||||
raise RuntimeError("DIRECTORY_SYMLINK_DENIED")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
path.chmod(mode)
|
||||
|
||||
|
||||
def atomic_write(path: Path, raw: bytes, mode: int = 0o640) -> None:
|
||||
ensure_directory(path.parent)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
dir=path.parent, prefix=f".{path.name}.", delete=False
|
||||
) as handle:
|
||||
temporary = Path(handle.name)
|
||||
handle.write(raw)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.chmod(mode)
|
||||
os.replace(temporary, path)
|
||||
path.chmod(mode)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: object, mode: int = 0o640) -> None:
|
||||
atomic_write(
|
||||
path,
|
||||
json.dumps(redact(value), sort_keys=True, indent=2).encode("utf-8") + b"\n",
|
||||
mode,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def exclusive_lock(path: Path) -> Iterator[None]:
|
||||
ensure_directory(path.parent)
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o640)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class HashChainAudit:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
def records(self) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
result = []
|
||||
for line in self.path.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
item = json.loads(line)
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("AUDIT_RECORD_INVALID")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def verify(self) -> str:
|
||||
previous = "0" * 64
|
||||
for sequence, record in enumerate(self.records(), 1):
|
||||
if record.get("sequence") != sequence:
|
||||
raise RuntimeError("AUDIT_SEQUENCE_INVALID")
|
||||
if record.get("previous_event_hash") != previous:
|
||||
raise RuntimeError("AUDIT_PREVIOUS_HASH_INVALID")
|
||||
payload = record.get("payload")
|
||||
payload_hash = sha256_json(payload)
|
||||
if record.get("payload_hash") != payload_hash:
|
||||
raise RuntimeError("AUDIT_PAYLOAD_HASH_INVALID")
|
||||
material = {
|
||||
"sequence": sequence,
|
||||
"previous_event_hash": previous,
|
||||
"payload_hash": payload_hash,
|
||||
"operation_id": record.get("operation_id"),
|
||||
}
|
||||
expected = sha256_json(material)
|
||||
if record.get("event_hash") != expected:
|
||||
raise RuntimeError("AUDIT_EVENT_HASH_INVALID")
|
||||
previous = expected
|
||||
return previous
|
||||
|
||||
def append(self, operation_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
ensure_directory(self.path.parent)
|
||||
previous = self.verify()
|
||||
sequence = len(self.records()) + 1
|
||||
clean = redact(payload)
|
||||
payload_hash = sha256_json(clean)
|
||||
material = {
|
||||
"sequence": sequence,
|
||||
"previous_event_hash": previous,
|
||||
"payload_hash": payload_hash,
|
||||
"operation_id": operation_id,
|
||||
}
|
||||
record = {
|
||||
**material,
|
||||
"event_hash": sha256_json(material),
|
||||
"payload": clean,
|
||||
}
|
||||
fd = os.open(
|
||||
self.path,
|
||||
os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(fd, canonical_json(record) + b"\n")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
self.path.chmod(0o640)
|
||||
return record
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
import base64, json, os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
class CredentialError(RuntimeError): pass
|
||||
|
||||
def credential_path(name: str) -> Path:
|
||||
directory = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if not directory:
|
||||
raise CredentialError("CREDENTIALS_DIRECTORY_MISSING")
|
||||
if not name or "/" in name or name in {".",".."}:
|
||||
raise CredentialError("CREDENTIAL_NAME_INVALID")
|
||||
path = Path(directory) / name
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise CredentialError("CREDENTIAL_FILE_INVALID")
|
||||
return path
|
||||
|
||||
def load_credential_json(name: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(credential_path(name).read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CredentialError("CREDENTIAL_JSON_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CredentialError("CREDENTIAL_JSON_INVALID")
|
||||
return value
|
||||
|
||||
def gitea_credential(name: str) -> tuple[str, str, int | None]:
|
||||
value = load_credential_json(name)
|
||||
username, token, token_id = value.get("username"), value.get("token"), value.get("token_id")
|
||||
if not isinstance(username, str) or not username:
|
||||
raise CredentialError("CREDENTIAL_USERNAME_INVALID")
|
||||
if not isinstance(token, str) or len(token) < 16:
|
||||
raise CredentialError("CREDENTIAL_TOKEN_INVALID")
|
||||
if token_id is not None and (not isinstance(token_id, int) or token_id < 1):
|
||||
raise CredentialError("CREDENTIAL_TOKEN_ID_INVALID")
|
||||
return username, token, token_id
|
||||
|
||||
def git_http_auth_env(username: str, token: str) -> dict[str, str]:
|
||||
encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii")
|
||||
return {
|
||||
"GIT_CONFIG_COUNT":"1",
|
||||
"GIT_CONFIG_KEY_0":"http.extraHeader",
|
||||
"GIT_CONFIG_VALUE_0":f"Authorization: Basic {encoded}",
|
||||
"GIT_TERMINAL_PROMPT":"0",
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
import json, urllib.error, urllib.parse, urllib.request
|
||||
from typing import Any
|
||||
|
||||
class GiteaApiError(RuntimeError):
|
||||
def __init__(self, code: str, status: int | None=None):
|
||||
super().__init__(code); self.code=code; self.status=status
|
||||
|
||||
class GiteaApi:
|
||||
def __init__(self, base_url: str, token: str):
|
||||
self.base_url=base_url.rstrip("/"); self.token=token
|
||||
def request(self, method: str, path: str, body: dict[str,Any] | None=None,
|
||||
expected: tuple[int,...]=(200,)) -> Any:
|
||||
raw=None if body is None else json.dumps(body).encode()
|
||||
req=urllib.request.Request(self.base_url+path,data=raw,method=method)
|
||||
req.add_header("Authorization",f"token {self.token}")
|
||||
req.add_header("Accept","application/json")
|
||||
if raw is not None: req.add_header("Content-Type","application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
status,payload=resp.status,resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise GiteaApiError(f"GITEA_HTTP_{exc.code}",exc.code) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise GiteaApiError("GITEA_UNAVAILABLE") from exc
|
||||
if status not in expected:
|
||||
raise GiteaApiError(f"GITEA_HTTP_{status}",status)
|
||||
if not payload: return None
|
||||
try: return json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GiteaApiError("GITEA_RESPONSE_INVALID") from exc
|
||||
def branch(self, owner: str, repo: str, branch: str) -> dict[str,Any]:
|
||||
value=self.request("GET",f"/api/v1/repos/{owner}/{repo}/branches/{urllib.parse.quote(branch,safe='')}")
|
||||
if not isinstance(value,dict): raise GiteaApiError("GITEA_BRANCH_RESPONSE_INVALID")
|
||||
return value
|
||||
def list_open_pulls(self, owner: str, repo: str) -> list[dict[str,Any]]:
|
||||
value=self.request("GET",f"/api/v1/repos/{owner}/{repo}/pulls?state=open&limit=50")
|
||||
if not isinstance(value,list): raise GiteaApiError("GITEA_PULL_LIST_INVALID")
|
||||
return [v for v in value if isinstance(v,dict)]
|
||||
def create_pull(self, owner: str, repo: str, *, head: str, base: str,
|
||||
title: str, body: str) -> dict[str,Any]:
|
||||
value=self.request("POST",f"/api/v1/repos/{owner}/{repo}/pulls",
|
||||
{"head":head,"base":base,"title":title,"body":body},expected=(201,))
|
||||
if not isinstance(value,dict): raise GiteaApiError("GITEA_PULL_CREATE_INVALID")
|
||||
return value
|
||||
def pull(self, owner: str, repo: str, index: int) -> dict[str,Any]:
|
||||
value=self.request("GET",f"/api/v1/repos/{owner}/{repo}/pulls/{index}")
|
||||
if not isinstance(value,dict): raise GiteaApiError("GITEA_PULL_RESPONSE_INVALID")
|
||||
return value
|
||||
def request_reviewers(self, owner: str, repo: str, index: int, reviewers: list[str]) -> None:
|
||||
try:
|
||||
self.request("POST",f"/api/v1/repos/{owner}/{repo}/pulls/{index}/requested_reviewers",
|
||||
{"reviewers":reviewers},expected=(201,204))
|
||||
except GiteaApiError as exc:
|
||||
if exc.status != 422:
|
||||
raise
|
||||
pull=self.pull(owner,repo,index)
|
||||
present={item.get("login") for item in pull.get("requested_reviewers",[]) if isinstance(item,dict)}
|
||||
if not set(reviewers).issubset(present):
|
||||
raise
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatchcase
|
||||
import json, sqlite3
|
||||
from pathlib import Path
|
||||
from .common import parse_utc, require_oid, safe_relpath, sha256_file, utc_now
|
||||
|
||||
class GrantError(RuntimeError): pass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriterGrant:
|
||||
grant_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
actor_identity: str
|
||||
writer_audience: str
|
||||
expected_policy_sha256: str
|
||||
expected_base_oid: str
|
||||
allowed_paths: tuple[str, ...]
|
||||
allowed_operations: tuple[str, ...]
|
||||
max_operations: int
|
||||
max_total_bytes: int
|
||||
expires_at: str
|
||||
grant_sha256: str
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "WriterGrant":
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise GrantError("GRANT_FILE_INVALID")
|
||||
stat = path.stat()
|
||||
if stat.st_uid != 0 or stat.st_mode & 0o022:
|
||||
raise GrantError("GRANT_FILE_PERMISSIONS_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
required = {
|
||||
"grant_id","task_id","repository","branch","actor_identity",
|
||||
"writer_audience","expected_policy_sha256","expected_base_oid",
|
||||
"allowed_paths","allowed_operations","max_operations",
|
||||
"max_total_bytes","expires_at",
|
||||
}
|
||||
if not isinstance(value, dict) or set(value) != required:
|
||||
raise GrantError("GRANT_SCHEMA_INVALID")
|
||||
if not all(isinstance(x,str) and x for x in value["allowed_paths"]):
|
||||
raise GrantError("GRANT_PATHS_INVALID")
|
||||
if not all(isinstance(x,str) and x for x in value["allowed_operations"]):
|
||||
raise GrantError("GRANT_OPERATIONS_INVALID")
|
||||
require_oid(value["expected_base_oid"], "expected_base_oid")
|
||||
parse_utc(value["expires_at"])
|
||||
return cls(
|
||||
grant_id=value["grant_id"], task_id=value["task_id"],
|
||||
repository=value["repository"], branch=value["branch"],
|
||||
actor_identity=value["actor_identity"], writer_audience=value["writer_audience"],
|
||||
expected_policy_sha256=value["expected_policy_sha256"],
|
||||
expected_base_oid=value["expected_base_oid"],
|
||||
allowed_paths=tuple(value["allowed_paths"]),
|
||||
allowed_operations=tuple(value["allowed_operations"]),
|
||||
max_operations=int(value["max_operations"]),
|
||||
max_total_bytes=int(value["max_total_bytes"]),
|
||||
expires_at=value["expires_at"], grant_sha256=sha256_file(path),
|
||||
)
|
||||
|
||||
def validate_request(self, *, task_id: str, repository: str, branch: str,
|
||||
actor_identity: str, writer_audience: str,
|
||||
expected_base_oid: str, operation: str, path: str) -> None:
|
||||
if utc_now() >= parse_utc(self.expires_at):
|
||||
raise GrantError("GRANT_EXPIRED")
|
||||
expected = (self.task_id,self.repository,self.branch,self.actor_identity,
|
||||
self.writer_audience,self.expected_base_oid)
|
||||
actual = (task_id,repository,branch,actor_identity,writer_audience,expected_base_oid)
|
||||
if actual != expected:
|
||||
raise GrantError("GRANT_BINDING_MISMATCH")
|
||||
normalized = safe_relpath(path)
|
||||
if operation not in self.allowed_operations:
|
||||
raise GrantError("GRANT_OPERATION_DENIED")
|
||||
if not any(fnmatchcase(normalized, pattern) for pattern in self.allowed_paths):
|
||||
raise GrantError("GRANT_PATH_DENIED")
|
||||
|
||||
class GrantQuotaLedger:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(path) as db:
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS usage(
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
operations INTEGER NOT NULL,
|
||||
total_bytes INTEGER NOT NULL)""")
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS consumed_operations(
|
||||
grant_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(grant_id,operation_id))""")
|
||||
def consume(self, grant: WriterGrant, operation_id: str,
|
||||
payload_sha256: str, byte_count: int) -> bool:
|
||||
if byte_count < 0 or not operation_id or len(payload_sha256) != 64:
|
||||
raise GrantError("GRANT_CONSUMPTION_INPUT_INVALID")
|
||||
with sqlite3.connect(self.path, isolation_level=None) as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
prior = db.execute(
|
||||
"SELECT payload_sha256,byte_count FROM consumed_operations "
|
||||
"WHERE grant_id=? AND operation_id=?",
|
||||
(grant.grant_id,operation_id)).fetchone()
|
||||
if prior:
|
||||
db.execute("COMMIT")
|
||||
if prior != (payload_sha256,byte_count):
|
||||
raise GrantError("GRANT_OPERATION_ID_PAYLOAD_MISMATCH")
|
||||
return False
|
||||
row = db.execute("SELECT operations,total_bytes FROM usage WHERE grant_id=?",
|
||||
(grant.grant_id,)).fetchone()
|
||||
operations,total_bytes = row if row else (0,0)
|
||||
if operations + 1 > grant.max_operations:
|
||||
db.execute("ROLLBACK"); raise GrantError("GRANT_OPERATION_QUOTA_EXHAUSTED")
|
||||
if total_bytes + byte_count > grant.max_total_bytes:
|
||||
db.execute("ROLLBACK"); raise GrantError("GRANT_BYTE_QUOTA_EXHAUSTED")
|
||||
db.execute("""INSERT INTO usage VALUES(?,?,?)
|
||||
ON CONFLICT(grant_id) DO UPDATE SET
|
||||
operations=excluded.operations,total_bytes=excluded.total_bytes""",
|
||||
(grant.grant_id,operations+1,total_bytes+byte_count))
|
||||
db.execute("INSERT INTO consumed_operations VALUES(?,?,?,?)",
|
||||
(grant.grant_id,operation_id,payload_sha256,byte_count))
|
||||
db.execute("COMMIT")
|
||||
return True
|
||||
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Iterator
|
||||
|
||||
|
||||
class LedgerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OperationLedger:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._db() as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS operations(
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
previous_oid TEXT,
|
||||
result_oid TEXT,
|
||||
result_json TEXT,
|
||||
updated_unix INTEGER NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _db(self) -> Iterator[sqlite3.Connection]:
|
||||
db = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get(self, operation_id: str) -> dict[str, Any] | None:
|
||||
with self._db() as db:
|
||||
row = db.execute(
|
||||
"SELECT operation_id,payload_sha256,state,previous_oid,result_oid,"
|
||||
"result_json,updated_unix FROM operations WHERE operation_id=?",
|
||||
(operation_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"operation_id": row[0],
|
||||
"payload_sha256": row[1],
|
||||
"state": row[2],
|
||||
"previous_oid": row[3],
|
||||
"result_oid": row[4],
|
||||
"result_json": row[5],
|
||||
"updated_unix": row[6],
|
||||
}
|
||||
|
||||
def prepare(
|
||||
self, operation_id: str, payload_sha256: str, previous_oid: str, now_unix: int
|
||||
) -> dict[str, Any]:
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute(
|
||||
"SELECT payload_sha256,state,previous_oid,result_oid,result_json "
|
||||
"FROM operations WHERE operation_id=?",
|
||||
(operation_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
db.execute("COMMIT")
|
||||
if row[0] != payload_sha256:
|
||||
raise LedgerError("OPERATION_ID_PAYLOAD_MISMATCH")
|
||||
return {
|
||||
"operation_id": operation_id,
|
||||
"payload_sha256": row[0],
|
||||
"state": row[1],
|
||||
"previous_oid": row[2],
|
||||
"result_oid": row[3],
|
||||
"result_json": row[4],
|
||||
}
|
||||
db.execute(
|
||||
"INSERT INTO operations VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
operation_id, payload_sha256, "PREPARED", previous_oid,
|
||||
None, None, now_unix,
|
||||
),
|
||||
)
|
||||
db.execute("COMMIT")
|
||||
return {
|
||||
"operation_id": operation_id,
|
||||
"payload_sha256": payload_sha256,
|
||||
"state": "PREPARED",
|
||||
"previous_oid": previous_oid,
|
||||
"result_oid": None,
|
||||
"result_json": None,
|
||||
}
|
||||
|
||||
def finish(
|
||||
self, operation_id: str, state: str, result_oid: str | None,
|
||||
result_json: str, now_unix: int
|
||||
) -> None:
|
||||
if state not in {
|
||||
"COMPLETED", "FAILED_RESTORED", "RECONCILIATION_REQUIRED", "FAILED"
|
||||
}:
|
||||
raise LedgerError("LEDGER_STATE_INVALID")
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
cursor = db.execute(
|
||||
"UPDATE operations SET state=?,result_oid=?,result_json=?,updated_unix=? "
|
||||
"WHERE operation_id=?",
|
||||
(state, result_oid, result_json, now_unix, operation_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
db.execute("ROLLBACK")
|
||||
raise LedgerError("OPERATION_NOT_FOUND")
|
||||
db.execute("COMMIT")
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Iterable
|
||||
|
||||
from .common import require_oid, safe_relpath
|
||||
|
||||
|
||||
class MirrorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MirrorResult:
|
||||
previous_oid: str
|
||||
target_oid: str
|
||||
fsck: str
|
||||
changed_paths: tuple[str, ...]
|
||||
|
||||
|
||||
class ApprovalMirrorWorker:
|
||||
def __init__(self, source_git_dir: Path, authoritative_git_dir: Path):
|
||||
self.source_git_dir = source_git_dir
|
||||
self.authoritative_git_dir = authoritative_git_dir
|
||||
|
||||
@staticmethod
|
||||
def _git(git_dir: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
text=True, capture_output=True
|
||||
)
|
||||
if check and result.returncode:
|
||||
raise MirrorError("GIT_OPERATION_FAILED")
|
||||
return result
|
||||
|
||||
def mirror(
|
||||
self,
|
||||
*,
|
||||
source_ref: str = "refs/heads/governance/approvals",
|
||||
destination_ref: str = "refs/heads/governance/approvals",
|
||||
expected_target_oid: str,
|
||||
allowed_prefixes: Iterable[str] = ("approvals/",),
|
||||
) -> MirrorResult:
|
||||
target = require_oid(expected_target_oid, "expected_target_oid")
|
||||
if source_ref != "refs/heads/governance/approvals":
|
||||
raise MirrorError("SOURCE_REF_DENIED")
|
||||
if destination_ref != "refs/heads/governance/approvals":
|
||||
raise MirrorError("DESTINATION_REF_DENIED")
|
||||
prefixes = tuple(safe_relpath(prefix.rstrip("/")) + "/" for prefix in allowed_prefixes)
|
||||
previous = require_oid(
|
||||
self._git(
|
||||
self.authoritative_git_dir, "rev-parse", "--verify", destination_ref
|
||||
).stdout.strip(),
|
||||
"previous_oid",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="aios-governance-quarantine-") as tmp:
|
||||
quarantine = Path(tmp) / "quarantine.git"
|
||||
subprocess.run(["git", "init", "--bare", str(quarantine)], check=True, capture_output=True)
|
||||
source_url = self.source_git_dir.resolve().as_uri()
|
||||
fetch = self._git(
|
||||
quarantine, "fetch", "--no-tags", "--no-write-fetch-head",
|
||||
source_url, f"{source_ref}:refs/heads/incoming"
|
||||
)
|
||||
observed = require_oid(
|
||||
self._git(quarantine, "rev-parse", "refs/heads/incoming").stdout.strip(),
|
||||
"observed_target_oid",
|
||||
)
|
||||
if observed != target:
|
||||
raise MirrorError("TARGET_OID_MISMATCH")
|
||||
ancestor = self._git(
|
||||
quarantine, "merge-base", "--is-ancestor", previous, target, check=False
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise MirrorError("NON_FAST_FORWARD_DENIED")
|
||||
diff = self._git(
|
||||
quarantine, "diff", "--name-status", previous, target
|
||||
).stdout.splitlines()
|
||||
changed = []
|
||||
for line in diff:
|
||||
fields = line.split("\t")
|
||||
if len(fields) != 2 or fields[0] not in {"A", "M"}:
|
||||
raise MirrorError("GOVERNANCE_DIFF_DENIED")
|
||||
path = safe_relpath(fields[1])
|
||||
if not any(path.startswith(prefix) for prefix in prefixes):
|
||||
raise MirrorError("GOVERNANCE_PATH_DENIED")
|
||||
changed.append(path)
|
||||
fsck = self._git(quarantine, "fsck", "--connectivity-only").stdout.strip()
|
||||
|
||||
quarantine_url = quarantine.resolve().as_uri()
|
||||
self._git(
|
||||
self.authoritative_git_dir, "fetch", "--no-tags", "--no-write-fetch-head",
|
||||
quarantine_url, target
|
||||
)
|
||||
self._git(
|
||||
self.authoritative_git_dir, "update-ref",
|
||||
destination_ref, target, previous
|
||||
)
|
||||
final = self._git(
|
||||
self.authoritative_git_dir, "rev-parse", destination_ref
|
||||
).stdout.strip()
|
||||
if final != target:
|
||||
raise MirrorError("MIRROR_VERIFY_FAILED")
|
||||
self._git(self.authoritative_git_dir, "fsck", "--connectivity-only")
|
||||
return MirrorResult(previous, target, fsck or "PASS", tuple(changed))
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
from .common import atomic_write_json
|
||||
from .mirror import ApprovalMirrorWorker
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True); p.add_argument("--once",action="store_true")
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
requests_dir,receipts_dir=Path(cfg["requests_dir"]),Path(cfg["receipts_dir"])
|
||||
worker=ApprovalMirrorWorker(Path(cfg["source_git_dir"]),Path(cfg["authoritative_git_dir"]))
|
||||
while True:
|
||||
receipts_dir.mkdir(parents=True,exist_ok=True)
|
||||
for request_path in sorted(requests_dir.glob("*.json")):
|
||||
receipt=receipts_dir/f"{request_path.stem}.receipt.json"
|
||||
if receipt.exists(): continue
|
||||
req=json.loads(request_path.read_text(encoding="utf-8"))
|
||||
result=worker.mirror(expected_target_oid=req["expected_target_oid"])
|
||||
atomic_write_json(receipt,result.__dict__)
|
||||
if args.once: break
|
||||
time.sleep(float(cfg.get("poll_seconds",5)))
|
||||
return 0
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from fnmatch import fnmatchcase
|
||||
import os
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .common import parse_utc, require_oid, safe_relpath, sha256_json, utc_now
|
||||
|
||||
|
||||
class PolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DevelopmentPolicy:
|
||||
policy_id: str
|
||||
repository: str
|
||||
allowed_branch_patterns: tuple[str, ...]
|
||||
denied_branch_patterns: tuple[str, ...]
|
||||
allowed_path_patterns: tuple[str, ...]
|
||||
allowed_operations: tuple[str, ...]
|
||||
denied_operations: tuple[str, ...]
|
||||
actor_identity: str
|
||||
writer_audience: str
|
||||
expires_at: datetime
|
||||
policy_sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "DevelopmentPolicy":
|
||||
required = {
|
||||
"policy_id", "repository", "allowed_branch_patterns",
|
||||
"denied_branch_patterns", "allowed_path_patterns",
|
||||
"allowed_operations", "denied_operations", "actor_identity",
|
||||
"writer_audience", "expires_at",
|
||||
}
|
||||
if set(value) != required:
|
||||
raise PolicyError("POLICY_SCHEMA_INVALID")
|
||||
for key in (
|
||||
"allowed_branch_patterns", "denied_branch_patterns",
|
||||
"allowed_path_patterns", "allowed_operations", "denied_operations",
|
||||
):
|
||||
if not isinstance(value[key], list) or not all(
|
||||
isinstance(item, str) and item for item in value[key]
|
||||
):
|
||||
raise PolicyError("POLICY_SCHEMA_INVALID")
|
||||
expires = parse_utc(str(value["expires_at"]))
|
||||
return cls(
|
||||
policy_id=str(value["policy_id"]),
|
||||
repository=str(value["repository"]),
|
||||
allowed_branch_patterns=tuple(value["allowed_branch_patterns"]),
|
||||
denied_branch_patterns=tuple(value["denied_branch_patterns"]),
|
||||
allowed_path_patterns=tuple(value["allowed_path_patterns"]),
|
||||
allowed_operations=tuple(value["allowed_operations"]),
|
||||
denied_operations=tuple(value["denied_operations"]),
|
||||
actor_identity=str(value["actor_identity"]),
|
||||
writer_audience=str(value["writer_audience"]),
|
||||
expires_at=expires,
|
||||
policy_sha256=sha256_json(dict(value)),
|
||||
)
|
||||
|
||||
def validate(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
branch: str,
|
||||
path: str,
|
||||
operation: str,
|
||||
actor_identity: str,
|
||||
writer_audience: str,
|
||||
expected_base_oid: str,
|
||||
) -> None:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise PolicyError("GLOBAL_GITOPS_WRITES_MUST_REMAIN_FALSE")
|
||||
require_oid(expected_base_oid, "expected_base_oid")
|
||||
if utc_now() >= self.expires_at:
|
||||
raise PolicyError("POLICY_EXPIRED")
|
||||
if repository != self.repository:
|
||||
raise PolicyError("REPOSITORY_DENIED")
|
||||
if actor_identity != self.actor_identity:
|
||||
raise PolicyError("ACTOR_IDENTITY_DENIED")
|
||||
if writer_audience != self.writer_audience:
|
||||
raise PolicyError("WRITER_AUDIENCE_DENIED")
|
||||
if any(fnmatchcase(branch, pattern) for pattern in self.denied_branch_patterns):
|
||||
raise PolicyError("BRANCH_DENIED")
|
||||
if not any(fnmatchcase(branch, pattern) for pattern in self.allowed_branch_patterns):
|
||||
raise PolicyError("BRANCH_DENIED")
|
||||
normalized_path = safe_relpath(path)
|
||||
if not any(
|
||||
fnmatchcase(normalized_path, pattern)
|
||||
for pattern in self.allowed_path_patterns
|
||||
):
|
||||
raise PolicyError("PATH_DENIED")
|
||||
if operation in self.denied_operations or operation not in self.allowed_operations:
|
||||
raise PolicyError("OPERATION_DENIED")
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Iterable
|
||||
|
||||
from .common import atomic_write_json, git_blob_oid, require_oid, safe_relpath, sha256_file
|
||||
|
||||
SECRET_RULES = {
|
||||
"private-key": re.compile(rb"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
||||
"credential-assignment": re.compile(
|
||||
rb"(?i)\b(?:api[_-]?key|password|secret|token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{16,}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ProjectionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectedObject:
|
||||
commit_oid: str
|
||||
tree_oid: str
|
||||
output_path: Path
|
||||
files: tuple[str, ...]
|
||||
|
||||
|
||||
class ReleaseObjectProjector:
|
||||
def __init__(self, source_git_dir: Path, allowed_output_roots: Iterable[Path] | None = None):
|
||||
self.source_git_dir = source_git_dir
|
||||
self.allowed_output_roots = tuple(
|
||||
root.resolve() for root in (allowed_output_roots or ())
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _git(git_dir: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
capture_output=True
|
||||
)
|
||||
if check and result.returncode:
|
||||
raise ProjectionError("GIT_OBJECT_PROJECTION_FAILED")
|
||||
return result
|
||||
|
||||
def project(
|
||||
self,
|
||||
*,
|
||||
commit_oid: str,
|
||||
expected_tree_oid: str,
|
||||
output_path: Path,
|
||||
allowed_paths: Iterable[str],
|
||||
) -> ProjectedObject:
|
||||
commit = require_oid(commit_oid, "commit_oid")
|
||||
tree = require_oid(expected_tree_oid, "expected_tree_oid")
|
||||
allowed = tuple(safe_relpath(path) for path in allowed_paths)
|
||||
if not allowed:
|
||||
raise ProjectionError("ALLOWED_PATHS_REQUIRED")
|
||||
if not output_path.is_absolute():
|
||||
raise ProjectionError("OUTPUT_PATH_INVALID")
|
||||
resolved_output = output_path.resolve()
|
||||
if output_path.is_symlink():
|
||||
raise ProjectionError("OUTPUT_PATH_SYMLINK_DENIED")
|
||||
if self.allowed_output_roots:
|
||||
if not any(
|
||||
resolved_output != root
|
||||
and (resolved_output == root or root in resolved_output.parents)
|
||||
for root in self.allowed_output_roots
|
||||
):
|
||||
raise ProjectionError("OUTPUT_PATH_DENIED")
|
||||
with tempfile.TemporaryDirectory(prefix="aios-release-projection-") as tmp:
|
||||
quarantine = Path(tmp) / "objects.git"
|
||||
subprocess.run(["git", "init", "--bare", str(quarantine)], check=True, capture_output=True)
|
||||
self._git(
|
||||
quarantine, "fetch", "--no-tags", "--no-write-fetch-head",
|
||||
self.source_git_dir.resolve().as_uri(), commit
|
||||
)
|
||||
actual_tree = self._git(
|
||||
quarantine, "rev-parse", f"{commit}^{{tree}}"
|
||||
).stdout.decode().strip()
|
||||
if actual_tree != tree:
|
||||
raise ProjectionError("TREE_OID_MISMATCH")
|
||||
names = self._git(
|
||||
quarantine, "ls-tree", "-r", "--name-only", commit
|
||||
).stdout.decode().splitlines()
|
||||
selected = tuple(name for name in names if name in allowed)
|
||||
if set(selected) != set(allowed):
|
||||
raise ProjectionError("PROJECTED_PATH_MISSING")
|
||||
if output_path.exists():
|
||||
if output_path.is_symlink() or not output_path.is_dir():
|
||||
raise ProjectionError("OUTPUT_PATH_REPLACEMENT_DENIED")
|
||||
shutil.rmtree(output_path)
|
||||
output_path.mkdir(parents=True, mode=0o750)
|
||||
for path in selected:
|
||||
raw = self._git(
|
||||
quarantine, "show", f"{commit}:{path}"
|
||||
).stdout
|
||||
destination = output_path / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(raw)
|
||||
destination.chmod(0o440)
|
||||
output_path.chmod(0o550)
|
||||
return ProjectedObject(commit, tree, output_path, selected)
|
||||
|
||||
|
||||
class RuntimeSourceProjector:
|
||||
def __init__(self, exact_paths: Iterable[Path]):
|
||||
self.exact_paths = tuple(path.resolve() for path in exact_paths)
|
||||
if not self.exact_paths:
|
||||
raise ProjectionError("EXACT_PATHS_REQUIRED")
|
||||
|
||||
def collect(self, output_path: Path, task_id: str) -> dict:
|
||||
records = []
|
||||
for path in self.exact_paths:
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise ProjectionError("RUNTIME_PATH_INVALID")
|
||||
raw = path.read_bytes()
|
||||
findings = []
|
||||
for rule_id, pattern in SECRET_RULES.items():
|
||||
for match in pattern.finditer(raw):
|
||||
line_number = raw[:match.start()].count(b"\n") + 1
|
||||
findings.append({
|
||||
"rule_id": rule_id,
|
||||
"line_sha256": hashlib.sha256(
|
||||
raw.splitlines()[line_number - 1]
|
||||
).hexdigest(),
|
||||
})
|
||||
utf8_python = False
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
ast.parse(text)
|
||||
utf8_python = True
|
||||
except (UnicodeDecodeError, SyntaxError):
|
||||
utf8_python = False
|
||||
records.append({
|
||||
"resolved_path": str(path),
|
||||
"size": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"git_blob_oid": git_blob_oid(raw),
|
||||
"utf8_python": utf8_python,
|
||||
"secret_scan": "PASS" if not findings else "HIT",
|
||||
"finding_count": len(findings),
|
||||
"findings": findings,
|
||||
})
|
||||
evidence = {
|
||||
"schema": "aios.runtime-source-read-projection/v2",
|
||||
"task_id": task_id,
|
||||
"projection_mode": "ROOT_EXACT_PATH_READ_ONLY",
|
||||
"network_used": False,
|
||||
"credential_used": False,
|
||||
"secret_content_returned": False,
|
||||
"runtime_source_modified": False,
|
||||
"sources": records,
|
||||
}
|
||||
atomic_write_json(output_path, evidence)
|
||||
return evidence
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
from .common import atomic_write_json
|
||||
from .projection import ReleaseObjectProjector, RuntimeSourceProjector
|
||||
|
||||
def release_main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True); p.add_argument("--once",action="store_true")
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
requests_dir,receipts_dir=Path(cfg["requests_dir"]),Path(cfg["receipts_dir"])
|
||||
projector=ReleaseObjectProjector(
|
||||
Path(cfg["source_git_dir"]),
|
||||
[Path(value) for value in cfg["allowed_output_roots"]],
|
||||
)
|
||||
while True:
|
||||
receipts_dir.mkdir(parents=True,exist_ok=True)
|
||||
for request_path in sorted(requests_dir.glob("*.json")):
|
||||
receipt=receipts_dir/f"{request_path.stem}.receipt.json"
|
||||
if receipt.exists(): continue
|
||||
req=json.loads(request_path.read_text(encoding="utf-8"))
|
||||
result=projector.project(commit_oid=req["commit_oid"],expected_tree_oid=req["tree_oid"],
|
||||
output_path=Path(req["output_path"]),allowed_paths=req["allowed_paths"])
|
||||
value=result.__dict__.copy(); value["output_path"]=str(value["output_path"])
|
||||
atomic_write_json(receipt,value)
|
||||
if args.once: break
|
||||
time.sleep(float(cfg.get("poll_seconds",5)))
|
||||
return 0
|
||||
|
||||
def runtime_main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True)
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
evidence=RuntimeSourceProjector([Path(v) for v in cfg["exact_paths"]]).collect(
|
||||
Path(cfg["output_path"]),cfg["task_id"])
|
||||
return 0 if all(x["secret_scan"]=="PASS" for x in evidence["sources"]) else 2
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, os, subprocess, time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .common import HashChainAudit, canonical_json, require_oid
|
||||
from .credentials import gitea_credential, git_http_auth_env
|
||||
from .gitea_api import GiteaApi
|
||||
from .ledger import OperationLedger
|
||||
from .socket_service import JsonLineUnixServer
|
||||
|
||||
class PublisherError(RuntimeError): pass
|
||||
|
||||
def under(path: Path, roots: list[Path]) -> bool:
|
||||
resolved=path.resolve()
|
||||
return any(resolved==r.resolve() or r.resolve() in resolved.parents for r in roots)
|
||||
|
||||
class PublisherApplication:
|
||||
def __init__(self, config: dict[str,Any]):
|
||||
self.config=config
|
||||
self.username,self.token,_=gitea_credential(config["credential_name"])
|
||||
self.api=GiteaApi(config["gitea_base_url"],self.token)
|
||||
self.ledger=OperationLedger(Path(config["operation_ledger"]))
|
||||
self.audit=HashChainAudit(Path(config["audit_path"]))
|
||||
self.git_roots=[Path(v) for v in config["allowed_git_dir_roots"]]
|
||||
def git(self, git_dir: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
result=subprocess.run(["git",f"--git-dir={git_dir}",*args],text=True,capture_output=True,
|
||||
env={**os.environ,**git_http_auth_env(self.username,self.token)})
|
||||
if result.returncode: raise PublisherError("PUBLISH_GIT_FAILED")
|
||||
return result
|
||||
def dispatch(self,value: dict[str,Any],peer: dict[str,int]) -> dict[str,Any]:
|
||||
required={"operation_id","repository","local_git_dir","source_oid","head_branch",
|
||||
"base_branch","expected_base_oid","title","body"}
|
||||
if set(value)!=required: raise PublisherError("REQUEST_SCHEMA_INVALID")
|
||||
if value["repository"]!=self.config["repository"]: raise PublisherError("REPOSITORY_DENIED")
|
||||
if value["base_branch"]!=self.config["base_branch"]: raise PublisherError("BASE_BRANCH_DENIED")
|
||||
if not value["head_branch"].startswith(self.config["head_prefix"]):
|
||||
raise PublisherError("HEAD_BRANCH_DENIED")
|
||||
source_oid=require_oid(value["source_oid"],"source_oid")
|
||||
expected_base_oid=require_oid(value["expected_base_oid"],"expected_base_oid")
|
||||
git_dir=Path(value["local_git_dir"])
|
||||
if not git_dir.is_absolute() or not git_dir.is_dir() or git_dir.is_symlink() or not under(git_dir,self.git_roots):
|
||||
raise PublisherError("LOCAL_GIT_DIR_DENIED")
|
||||
payload={k:value[k] for k in sorted(value) if k!="body"}
|
||||
payload["body_sha256"]=hashlib.sha256(value["body"].encode()).hexdigest()
|
||||
payload_sha=hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
existing=self.ledger.get(value["operation_id"])
|
||||
if existing:
|
||||
if existing["payload_sha256"]!=payload_sha: raise PublisherError("OPERATION_ID_PAYLOAD_MISMATCH")
|
||||
if existing["state"]=="COMPLETED": return json.loads(existing["result_json"])
|
||||
if existing["state"] not in {"PREPARED","RECONCILIATION_REQUIRED"}:
|
||||
raise PublisherError(existing["state"])
|
||||
owner,repo=self.config["repository"].split("/",1)
|
||||
base=self.api.branch(owner,repo,value["base_branch"])
|
||||
if base.get("commit",{}).get("id")!=expected_base_oid: raise PublisherError("BASE_BRANCH_MOVED")
|
||||
if existing is None:
|
||||
self.ledger.prepare(value["operation_id"],payload_sha,expected_base_oid,int(time.time()))
|
||||
self.git(git_dir,"push","--porcelain",self.config["remote_url"],
|
||||
f"{source_oid}:refs/heads/{value['head_branch']}")
|
||||
remote=self.api.branch(owner,repo,value["head_branch"])
|
||||
if remote.get("commit",{}).get("id")!=source_oid: raise PublisherError("PUBLISHED_HEAD_MISMATCH")
|
||||
pull=None
|
||||
for item in self.api.list_open_pulls(owner,repo):
|
||||
if item.get("head",{}).get("ref")==value["head_branch"] and item.get("base",{}).get("ref")==value["base_branch"]:
|
||||
pull=item; break
|
||||
if pull is None:
|
||||
pull=self.api.create_pull(owner,repo,head=value["head_branch"],base=value["base_branch"],
|
||||
title=value["title"],body=value["body"])
|
||||
number=int(pull["number"])
|
||||
self.api.request_reviewers(owner,repo,number,[self.config["owner_reviewer"]])
|
||||
result={"operation_id":value["operation_id"],"head_branch":value["head_branch"],
|
||||
"head_oid":source_oid,"base_branch":value["base_branch"],
|
||||
"base_oid":expected_base_oid,"pr_number":number,"pr_url":pull.get("html_url"),
|
||||
"owner_reviewer":self.config["owner_reviewer"],"peer_uid":peer["uid"]}
|
||||
encoded=json.dumps(result,sort_keys=True)
|
||||
self.ledger.finish(value["operation_id"],"COMPLETED",source_oid,encoded,int(time.time()))
|
||||
self.audit.append(value["operation_id"],{"event":"GOVERNANCE_REQUEST_PUBLISHED",**result})
|
||||
return result
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True)
|
||||
args=p.parse_args(argv); config=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
app=PublisherApplication(config)
|
||||
server=JsonLineUnixServer(Path(config["socket_path"]),
|
||||
allowed_uids={int(v) for v in config["allowed_peer_uids"]},
|
||||
dispatch=app.dispatch,max_request_bytes=int(config.get("max_request_bytes",1048576)))
|
||||
try: server.serve_forever()
|
||||
finally: server.server_close()
|
||||
return 0
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .common import HashChainAudit, atomic_write_json, require_oid, require_sha256, sha256_json
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseEnvelope:
|
||||
version: str
|
||||
target_oid: str
|
||||
target_tree_oid: str
|
||||
artifact_sha256: str
|
||||
rollback_artifact_sha256: str
|
||||
sbom_sha256: str
|
||||
test_evidence_sha256: str
|
||||
evidence_closure_oid: str
|
||||
evidence_closure_sha256: str
|
||||
tag_plan_sha256: str
|
||||
deployment_plan_sha256: str
|
||||
rollback_plan_sha256: str
|
||||
registry_plan_sha256: str
|
||||
baseline_plan_sha256: str
|
||||
owner_review_oid: int
|
||||
approval_merge_oid: str
|
||||
approval_pr_number: int
|
||||
approval_head_oid: str
|
||||
approval_manifest_sha256: str
|
||||
approval_manifest_path: str
|
||||
approval_expires_at: str
|
||||
approval_plan_id: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ReleaseEnvelope":
|
||||
expected = set(cls.__dataclass_fields__)
|
||||
if set(value) != expected:
|
||||
raise ReleaseError("RELEASE_ENVELOPE_SCHEMA_INVALID")
|
||||
if value["version"] != "V2R1C4":
|
||||
raise ReleaseError("VERSION_DENIED")
|
||||
for field in (
|
||||
"target_oid", "target_tree_oid", "evidence_closure_oid",
|
||||
"approval_merge_oid", "approval_head_oid",
|
||||
):
|
||||
require_oid(value[field], field)
|
||||
for field in (
|
||||
"artifact_sha256", "rollback_artifact_sha256", "sbom_sha256",
|
||||
"test_evidence_sha256", "evidence_closure_sha256", "tag_plan_sha256",
|
||||
"deployment_plan_sha256", "rollback_plan_sha256",
|
||||
"registry_plan_sha256", "baseline_plan_sha256",
|
||||
"approval_manifest_sha256",
|
||||
):
|
||||
require_sha256(value[field], field)
|
||||
if not isinstance(value["owner_review_oid"], int) or value["owner_review_oid"] < 1:
|
||||
raise ReleaseError("OWNER_REVIEW_OID_INVALID")
|
||||
if not isinstance(value["approval_pr_number"], int) or value["approval_pr_number"] < 1:
|
||||
raise ReleaseError("APPROVAL_PR_NUMBER_INVALID")
|
||||
if not isinstance(value["approval_plan_id"], str) or not value["approval_plan_id"]:
|
||||
raise ReleaseError("APPROVAL_PLAN_ID_INVALID")
|
||||
from .common import parse_utc, safe_relpath
|
||||
approval_path = safe_relpath(value["approval_manifest_path"])
|
||||
if not approval_path.startswith("approvals/"):
|
||||
raise ReleaseError("APPROVAL_MANIFEST_PATH_DENIED")
|
||||
parse_utc(value["approval_expires_at"])
|
||||
return cls(**value)
|
||||
|
||||
|
||||
class ReleaseHooks(Protocol):
|
||||
def verify_owner_approval(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def merge_release(self, envelope: ReleaseEnvelope) -> str: ...
|
||||
def create_tag(self, envelope: ReleaseEnvelope, release_oid: str) -> tuple[str, str]: ...
|
||||
def verify_remote_tag(self, envelope: ReleaseEnvelope, tag_oid: str, release_oid: str) -> None: ...
|
||||
def deploy(self, envelope: ReleaseEnvelope, release_oid: str) -> None: ...
|
||||
def health_check(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def rollback(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def verify_previous(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def restore(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def verify_restored(self, envelope: ReleaseEnvelope) -> None: ...
|
||||
def snapshot_metadata(self, envelope: ReleaseEnvelope) -> str: ...
|
||||
def update_registry(self, envelope: ReleaseEnvelope, release_oid: str, tag_oid: str) -> None: ...
|
||||
def update_baseline(self, envelope: ReleaseEnvelope, release_oid: str, tag_oid: str) -> None: ...
|
||||
def restore_metadata(self, envelope: ReleaseEnvelope, snapshot_id: str) -> None: ...
|
||||
def verify_consistency(self, envelope: ReleaseEnvelope, release_oid: str, tag_oid: str) -> None: ...
|
||||
|
||||
|
||||
class ReleaseEnvelopeExecutor:
|
||||
PHASES = (
|
||||
"PREPARED", "OWNER_VERIFIED", "RELEASE_MERGED", "TAG_CREATED",
|
||||
"TAG_VERIFIED", "DEPLOYED", "HEALTHY", "ROLLED_BACK",
|
||||
"PREVIOUS_VERIFIED", "RESTORED", "RESTORE_VERIFIED",
|
||||
"METADATA_SNAPSHOTTED", "REGISTRY_UPDATED", "BASELINE_UPDATED", "COMPLETED",
|
||||
)
|
||||
|
||||
def __init__(self, state_path: Path, audit_path: Path, hooks: ReleaseHooks):
|
||||
self.state_path = state_path
|
||||
self.audit = HashChainAudit(audit_path)
|
||||
self.hooks = hooks
|
||||
|
||||
def _load(self) -> dict[str, Any] | None:
|
||||
if not self.state_path.exists():
|
||||
return None
|
||||
value = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseError("RELEASE_STATE_INVALID")
|
||||
return value
|
||||
|
||||
def _save(self, state: dict[str, Any]) -> None:
|
||||
atomic_write_json(self.state_path, state)
|
||||
self.audit.append(state["operation_id"], {
|
||||
"event": state["phase"],
|
||||
"envelope_sha256": state["envelope_sha256"],
|
||||
"release_oid": state.get("release_oid"),
|
||||
"tag_oid": state.get("tag_oid"),
|
||||
})
|
||||
|
||||
def execute(self, envelope: ReleaseEnvelope, operation_id: str) -> dict[str, Any]:
|
||||
envelope_dict = envelope.__dict__
|
||||
digest = sha256_json(envelope_dict)
|
||||
state = self._load()
|
||||
if state:
|
||||
if state.get("operation_id") != operation_id:
|
||||
raise ReleaseError("CONCURRENT_RELEASE_OPERATION_DENIED")
|
||||
if state.get("envelope_sha256") != digest:
|
||||
raise ReleaseError("OPERATION_ID_PAYLOAD_MISMATCH")
|
||||
if state.get("phase") == "COMPLETED":
|
||||
return state
|
||||
else:
|
||||
state = {
|
||||
"operation_id": operation_id,
|
||||
"envelope_sha256": digest,
|
||||
"phase": "PREPARED",
|
||||
"release_oid": None,
|
||||
"tag_oid": None,
|
||||
"tag_target_oid": None,
|
||||
"metadata_snapshot_id": None,
|
||||
}
|
||||
self._save(state)
|
||||
|
||||
try:
|
||||
if state["phase"] == "PREPARED":
|
||||
self.hooks.verify_owner_approval(envelope)
|
||||
state["phase"] = "OWNER_VERIFIED"; self._save(state)
|
||||
if state["phase"] == "OWNER_VERIFIED":
|
||||
state["release_oid"] = require_oid(
|
||||
self.hooks.merge_release(envelope), "release_oid"
|
||||
)
|
||||
if state["release_oid"] != envelope.target_oid:
|
||||
raise ReleaseError("RELEASE_TARGET_OID_MISMATCH")
|
||||
state["phase"] = "RELEASE_MERGED"; self._save(state)
|
||||
if state["phase"] == "RELEASE_MERGED":
|
||||
tag_oid, target_oid = self.hooks.create_tag(
|
||||
envelope, state["release_oid"]
|
||||
)
|
||||
state["tag_oid"] = require_oid(tag_oid, "tag_oid")
|
||||
state["tag_target_oid"] = require_oid(target_oid, "tag_target_oid")
|
||||
if state["tag_target_oid"] != state["release_oid"]:
|
||||
raise ReleaseError("TAG_TARGET_MISMATCH")
|
||||
state["phase"] = "TAG_CREATED"; self._save(state)
|
||||
if state["phase"] == "TAG_CREATED":
|
||||
self.hooks.verify_remote_tag(envelope, state["tag_oid"], state["release_oid"])
|
||||
state["phase"] = "TAG_VERIFIED"; self._save(state)
|
||||
if state["phase"] == "TAG_VERIFIED":
|
||||
self.hooks.deploy(envelope, state["release_oid"])
|
||||
state["phase"] = "DEPLOYED"; self._save(state)
|
||||
if state["phase"] == "DEPLOYED":
|
||||
self.hooks.health_check(envelope)
|
||||
state["phase"] = "HEALTHY"; self._save(state)
|
||||
if state["phase"] == "HEALTHY":
|
||||
self.hooks.rollback(envelope)
|
||||
state["phase"] = "ROLLED_BACK"; self._save(state)
|
||||
if state["phase"] == "ROLLED_BACK":
|
||||
self.hooks.verify_previous(envelope)
|
||||
state["phase"] = "PREVIOUS_VERIFIED"; self._save(state)
|
||||
if state["phase"] == "PREVIOUS_VERIFIED":
|
||||
self.hooks.restore(envelope)
|
||||
state["phase"] = "RESTORED"; self._save(state)
|
||||
if state["phase"] == "RESTORED":
|
||||
self.hooks.verify_restored(envelope)
|
||||
state["phase"] = "RESTORE_VERIFIED"; self._save(state)
|
||||
if state["phase"] == "RESTORE_VERIFIED":
|
||||
state["metadata_snapshot_id"] = self.hooks.snapshot_metadata(envelope)
|
||||
if not isinstance(state["metadata_snapshot_id"], str) or not state["metadata_snapshot_id"]:
|
||||
raise ReleaseError("METADATA_SNAPSHOT_INVALID")
|
||||
state["phase"] = "METADATA_SNAPSHOTTED"; self._save(state)
|
||||
if state["phase"] == "METADATA_SNAPSHOTTED":
|
||||
self.hooks.update_registry(
|
||||
envelope, state["release_oid"], state["tag_oid"]
|
||||
)
|
||||
state["phase"] = "REGISTRY_UPDATED"; self._save(state)
|
||||
if state["phase"] == "REGISTRY_UPDATED":
|
||||
self.hooks.update_baseline(
|
||||
envelope, state["release_oid"], state["tag_oid"]
|
||||
)
|
||||
state["phase"] = "BASELINE_UPDATED"; self._save(state)
|
||||
if state["phase"] == "BASELINE_UPDATED":
|
||||
self.hooks.verify_consistency(
|
||||
envelope, state["release_oid"], state["tag_oid"]
|
||||
)
|
||||
state["phase"] = "COMPLETED"; self._save(state)
|
||||
return state
|
||||
except Exception as exc:
|
||||
state["last_error"] = type(exc).__name__
|
||||
state["failed_phase"] = state.get("phase")
|
||||
if state.get("phase") in {
|
||||
"DEPLOYED","HEALTHY","ROLLED_BACK","PREVIOUS_VERIFIED",
|
||||
"RESTORED","RESTORE_VERIFIED","METADATA_SNAPSHOTTED",
|
||||
"REGISTRY_UPDATED","BASELINE_UPDATED",
|
||||
}:
|
||||
try:
|
||||
if state.get("metadata_snapshot_id"):
|
||||
self.hooks.restore_metadata(envelope, state["metadata_snapshot_id"])
|
||||
state["metadata_compensation"] = "PASS"
|
||||
self.hooks.rollback(envelope)
|
||||
self.hooks.verify_previous(envelope)
|
||||
state["automatic_failure_rollback"] = "PASS"
|
||||
except Exception as rollback_exc:
|
||||
state["automatic_failure_rollback"] = "FAILED"
|
||||
state["rollback_error"] = type(rollback_exc).__name__
|
||||
atomic_write_json(self.state_path, state)
|
||||
raise
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .common import parse_utc, require_oid, sha256_file, utc_now
|
||||
from .credentials import gitea_credential, git_http_auth_env
|
||||
from .gitea_api import GiteaApi
|
||||
from .release import ReleaseEnvelope, ReleaseError
|
||||
from .release_deploy import ArtifactDeployment, MetadataManager, _symlink_target
|
||||
from .release_transport import StructuredTransport
|
||||
from .watcher import GiteaSQLiteApprovalWatcher
|
||||
|
||||
|
||||
class ReleaseActionError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
def _git(git_dir: Path, *args: str, env: dict[str, str] | None = None,
|
||||
check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", **(env or {})},
|
||||
)
|
||||
if check and result.returncode:
|
||||
raise ReleaseActionError("RELEASE_GIT_OPERATION_FAILED")
|
||||
return result
|
||||
|
||||
|
||||
class ReleaseActions:
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
self.authoritative_git_dir = Path(config["authoritative_git_dir"])
|
||||
if (
|
||||
not self.authoritative_git_dir.is_absolute()
|
||||
or not self.authoritative_git_dir.is_dir()
|
||||
or self.authoritative_git_dir.is_symlink()
|
||||
):
|
||||
raise ReleaseActionError("AUTHORITATIVE_GIT_DIR_INVALID")
|
||||
self.deployment = ArtifactDeployment(
|
||||
config["deployment"], Path(config["deployment"]["state_path"])
|
||||
)
|
||||
self.metadata = MetadataManager(config["metadata"])
|
||||
|
||||
def verify_owner_approval(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
if utc_now() >= parse_utc(envelope.approval_expires_at):
|
||||
raise ReleaseActionError("RELEASE_APPROVAL_EXPIRED")
|
||||
approval = self.config["approval"]
|
||||
watcher = GiteaSQLiteApprovalWatcher(
|
||||
Path(approval["gitea_db_path"]),
|
||||
repo_id=int(approval["repo_id"]),
|
||||
owner_user_id=int(approval["owner_user_id"]),
|
||||
)
|
||||
required_contexts = tuple(approval["required_machine_contexts"])
|
||||
snapshot = watcher.snapshot(
|
||||
envelope.approval_pr_number,
|
||||
expected_head_oid=envelope.approval_head_oid,
|
||||
required_machine_contexts=required_contexts,
|
||||
)
|
||||
if not snapshot.owner_approved:
|
||||
raise ReleaseActionError("OWNER_EXACT_HEAD_APPROVAL_MISSING")
|
||||
if snapshot.owner_review_oid != envelope.owner_review_oid:
|
||||
raise ReleaseActionError("OWNER_REVIEW_OID_MISMATCH")
|
||||
if snapshot.stale_reviews or snapshot.dismissed_reviews:
|
||||
raise ReleaseActionError("APPROVAL_REVIEW_INVALID")
|
||||
for context in required_contexts:
|
||||
if snapshot.machine_statuses.get(context) != "success":
|
||||
raise ReleaseActionError(f"MACHINE_STATUS_NOT_PASS:{context}")
|
||||
current_ref = approval.get(
|
||||
"governance_ref", "refs/heads/governance/approvals"
|
||||
)
|
||||
ancestor = _git(
|
||||
self.authoritative_git_dir,
|
||||
"merge-base", "--is-ancestor", envelope.approval_merge_oid, current_ref,
|
||||
check=False,
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise ReleaseActionError("APPROVAL_MERGE_NOT_ACTIVE")
|
||||
manifest = _git(
|
||||
self.authoritative_git_dir,
|
||||
"show", f"{envelope.approval_merge_oid}:{envelope.approval_manifest_path}",
|
||||
).stdout.encode("utf-8")
|
||||
if hashlib.sha256(manifest).hexdigest() != envelope.approval_manifest_sha256:
|
||||
raise ReleaseActionError("APPROVAL_MANIFEST_SHA256_MISMATCH")
|
||||
return {
|
||||
"owner_review_oid": snapshot.owner_review_oid,
|
||||
"approval_head_oid": snapshot.owner_reviewed_head_oid,
|
||||
"machine_statuses": snapshot.machine_statuses,
|
||||
"approval_merge_oid": envelope.approval_merge_oid,
|
||||
}
|
||||
|
||||
def merge_release(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
value = StructuredTransport(self.config["merge_transport"]).call({
|
||||
"operation": "merge_release",
|
||||
"version": envelope.version,
|
||||
"target_oid": envelope.target_oid,
|
||||
"target_tree_oid": envelope.target_tree_oid,
|
||||
"approval_merge_oid": envelope.approval_merge_oid,
|
||||
"owner_review_oid": envelope.owner_review_oid,
|
||||
"evidence_closure_oid": envelope.evidence_closure_oid,
|
||||
})
|
||||
release_oid = require_oid(value.get("release_oid"), "release_oid")
|
||||
if release_oid != envelope.target_oid:
|
||||
raise ReleaseActionError("RELEASE_TARGET_OID_MISMATCH")
|
||||
return {"release_oid": release_oid}
|
||||
|
||||
def create_tag(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
value = StructuredTransport(self.config["tag_transport"]).call({
|
||||
"operation": "request_or_create_tag",
|
||||
"execute": True,
|
||||
"version": envelope.version,
|
||||
"target_oid": envelope.target_oid,
|
||||
"evidence_closure_oid": envelope.evidence_closure_oid,
|
||||
"approval_plan_id": envelope.approval_plan_id,
|
||||
"approval_merge_oid": envelope.approval_merge_oid,
|
||||
})
|
||||
tag_oid = require_oid(value.get("tag_oid"), "tag_oid")
|
||||
target_oid = require_oid(
|
||||
value.get("tag_target_oid") or value.get("target_oid"), "tag_target_oid"
|
||||
)
|
||||
if target_oid != envelope.target_oid:
|
||||
raise ReleaseActionError("TAG_TARGET_OID_MISMATCH")
|
||||
return {"tag_oid": tag_oid, "tag_target_oid": target_oid}
|
||||
|
||||
def verify_remote_tag(
|
||||
self, envelope: ReleaseEnvelope, tag_oid: str, release_oid: str
|
||||
) -> dict[str, Any]:
|
||||
remote = self.config["remote_tag"]
|
||||
username, token, _ = gitea_credential(remote["credential_name"])
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git", "ls-remote", "--tags", remote["remote_url"],
|
||||
f"refs/tags/{envelope.version}",
|
||||
f"refs/tags/{envelope.version}^{{}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
**git_http_auth_env(username, token),
|
||||
},
|
||||
)
|
||||
if result.returncode:
|
||||
raise ReleaseActionError("REMOTE_TAG_QUERY_FAILED")
|
||||
refs: dict[str, str] = {}
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) == 2:
|
||||
refs[fields[1]] = require_oid(fields[0], "remote_tag_oid")
|
||||
direct = refs.get(f"refs/tags/{envelope.version}")
|
||||
peeled = refs.get(f"refs/tags/{envelope.version}^{{}}")
|
||||
if direct != tag_oid:
|
||||
raise ReleaseActionError("REMOTE_TAG_OID_MISMATCH")
|
||||
remote_target = peeled or direct
|
||||
if remote_target != release_oid:
|
||||
raise ReleaseActionError("REMOTE_PEELED_TARGET_MISMATCH")
|
||||
api = GiteaApi(remote["gitea_base_url"], token)
|
||||
value = api.request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{remote['repo_owner']}/{remote['repo_name']}/tags/{envelope.version}",
|
||||
)
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseActionError("GITEA_TAG_RESPONSE_INVALID")
|
||||
api_target = (
|
||||
value.get("commit", {}).get("sha")
|
||||
or value.get("commit", {}).get("id")
|
||||
or value.get("target")
|
||||
)
|
||||
if api_target != release_oid:
|
||||
raise ReleaseActionError("GITEA_TAG_TARGET_MISMATCH")
|
||||
return {
|
||||
"tag_oid": direct,
|
||||
"peeled_target_oid": remote_target,
|
||||
"gitea_target_oid": api_target,
|
||||
}
|
||||
|
||||
def deploy(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
release_dir = self.deployment.deploy(envelope)
|
||||
return {"release_dir": str(release_dir)}
|
||||
|
||||
def health_check(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
self.deployment.health()
|
||||
return {"health": "PASS"}
|
||||
|
||||
def rollback(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
target = self.deployment.rollback()
|
||||
return {"rollback_target": str(target)}
|
||||
|
||||
def verify_previous(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
previous = _symlink_target(self.deployment.previous_link)
|
||||
if previous is None:
|
||||
raise ReleaseActionError("PREVIOUS_RELEASE_MISSING")
|
||||
self.deployment.verify_current(previous)
|
||||
self.deployment.health()
|
||||
return {"previous_release": str(previous), "health": "PASS"}
|
||||
|
||||
def restore(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
target = self.deployment.restore()
|
||||
return {"restore_target": str(target)}
|
||||
|
||||
def verify_restored(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
state = json.loads(self.deployment.state_path.read_text(encoding="utf-8"))
|
||||
target = Path(state["release_dir"])
|
||||
self.deployment.verify_current(target)
|
||||
self.deployment.health()
|
||||
return {"restored_release": str(target), "health": "PASS"}
|
||||
|
||||
def snapshot_metadata(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
return {"snapshot_id": self.metadata.snapshot(envelope)}
|
||||
|
||||
def update_registry(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
self.metadata.update_registry(envelope)
|
||||
return {"registry_sha256": sha256_file(self.metadata.registry_path)}
|
||||
|
||||
def update_baseline(self, envelope: ReleaseEnvelope) -> dict[str, Any]:
|
||||
self.metadata.update_baseline(envelope)
|
||||
return {"baseline_sha256": sha256_file(self.metadata.baseline_path)}
|
||||
|
||||
def restore_metadata(
|
||||
self, envelope: ReleaseEnvelope, snapshot_id: str
|
||||
) -> dict[str, Any]:
|
||||
self.metadata.restore(snapshot_id)
|
||||
return {"metadata_restored": True, "snapshot_id": snapshot_id}
|
||||
|
||||
def verify_consistency(
|
||||
self, envelope: ReleaseEnvelope, tag_oid: str, release_oid: str
|
||||
) -> dict[str, Any]:
|
||||
self.metadata.verify_plans_applied(envelope)
|
||||
state = json.loads(self.deployment.state_path.read_text(encoding="utf-8"))
|
||||
self.deployment.verify_current(Path(state["release_dir"]))
|
||||
tag = self.verify_remote_tag(envelope, tag_oid, release_oid)
|
||||
return {
|
||||
"release_consistency": "PASS",
|
||||
"version": envelope.version,
|
||||
"target_oid": release_oid,
|
||||
"tag_oid": tag_oid,
|
||||
**tag,
|
||||
}
|
||||
|
||||
|
||||
def _validate_config_file(path: Path) -> dict[str, Any]:
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise ReleaseActionError("RELEASE_ACTION_CONFIG_INVALID")
|
||||
stat = path.stat()
|
||||
if stat.st_uid != 0 or stat.st_mode & 0o022:
|
||||
raise ReleaseActionError("RELEASE_ACTION_CONFIG_PERMISSIONS_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseActionError("RELEASE_ACTION_CONFIG_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("action", choices=(
|
||||
"verify_owner_approval", "merge_release", "create_tag",
|
||||
"verify_remote_tag", "deploy", "health_check", "rollback",
|
||||
"verify_previous", "restore", "verify_restored", "snapshot_metadata",
|
||||
"update_registry", "update_baseline", "restore_metadata",
|
||||
"verify_consistency",
|
||||
))
|
||||
parser.add_argument("--config", type=Path, required=True)
|
||||
args = parser.parse_args(argv)
|
||||
config = _validate_config_file(args.config)
|
||||
payload = json.loads(sys.stdin.buffer.read())
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("envelope"), dict):
|
||||
raise ReleaseActionError("RELEASE_HOOK_PAYLOAD_INVALID")
|
||||
envelope = ReleaseEnvelope.from_mapping(payload["envelope"])
|
||||
extra = payload.get("extra") or {}
|
||||
actions = ReleaseActions(config)
|
||||
if args.action == "verify_owner_approval":
|
||||
result = actions.verify_owner_approval(envelope)
|
||||
elif args.action == "merge_release":
|
||||
result = actions.merge_release(envelope)
|
||||
elif args.action == "create_tag":
|
||||
result = actions.create_tag(envelope)
|
||||
elif args.action == "verify_remote_tag":
|
||||
result = actions.verify_remote_tag(
|
||||
envelope,
|
||||
require_oid(extra.get("tag_oid"), "tag_oid"),
|
||||
require_oid(extra.get("release_oid"), "release_oid"),
|
||||
)
|
||||
elif args.action == "deploy":
|
||||
result = actions.deploy(envelope)
|
||||
elif args.action == "health_check":
|
||||
result = actions.health_check(envelope)
|
||||
elif args.action == "rollback":
|
||||
result = actions.rollback(envelope)
|
||||
elif args.action == "verify_previous":
|
||||
result = actions.verify_previous(envelope)
|
||||
elif args.action == "restore":
|
||||
result = actions.restore(envelope)
|
||||
elif args.action == "verify_restored":
|
||||
result = actions.verify_restored(envelope)
|
||||
elif args.action == "snapshot_metadata":
|
||||
result = actions.snapshot_metadata(envelope)
|
||||
elif args.action == "update_registry":
|
||||
result = actions.update_registry(envelope)
|
||||
elif args.action == "update_baseline":
|
||||
result = actions.update_baseline(envelope)
|
||||
elif args.action == "restore_metadata":
|
||||
snapshot_id = extra.get("snapshot_id")
|
||||
if not isinstance(snapshot_id, str) or not snapshot_id:
|
||||
raise ReleaseActionError("METADATA_SNAPSHOT_ID_INVALID")
|
||||
result = actions.restore_metadata(envelope, snapshot_id)
|
||||
else:
|
||||
result = actions.verify_consistency(
|
||||
envelope,
|
||||
require_oid(extra.get("tag_oid"), "tag_oid"),
|
||||
require_oid(extra.get("release_oid"), "release_oid"),
|
||||
)
|
||||
print(json.dumps({"status": "PASS", **result}, sort_keys=True))
|
||||
return 0
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .common import atomic_write, atomic_write_json, ensure_directory, safe_relpath, sha256_file
|
||||
from .release import ReleaseEnvelope, ReleaseError
|
||||
|
||||
|
||||
UNIT_RE = re.compile(r"^[A-Za-z0-9_.@:-]+\.service$")
|
||||
|
||||
|
||||
class DeploymentError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
def _run_systemctl(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
["systemctl", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"},
|
||||
)
|
||||
if result.returncode:
|
||||
raise DeploymentError("SYSTEMCTL_FAILED")
|
||||
return result
|
||||
|
||||
|
||||
def _validate_units(units: list[str]) -> tuple[str, ...]:
|
||||
if not isinstance(units, list) or not units:
|
||||
raise DeploymentError("SERVICE_UNITS_INVALID")
|
||||
values = tuple(units)
|
||||
if not all(isinstance(unit, str) and UNIT_RE.fullmatch(unit) for unit in values):
|
||||
raise DeploymentError("SERVICE_UNITS_INVALID")
|
||||
return values
|
||||
|
||||
|
||||
def _symlink_target(path: Path) -> Path | None:
|
||||
if not path.exists() and not path.is_symlink():
|
||||
return None
|
||||
if not path.is_symlink():
|
||||
raise DeploymentError("RELEASE_LINK_NOT_SYMLINK")
|
||||
target = Path(os.readlink(path))
|
||||
return target if target.is_absolute() else (path.parent / target).resolve()
|
||||
|
||||
|
||||
def _atomic_symlink(link: Path, target: Path) -> None:
|
||||
ensure_directory(link.parent)
|
||||
temporary = link.parent / f".{link.name}.tmp-{os.getpid()}"
|
||||
temporary.unlink(missing_ok=True)
|
||||
os.symlink(str(target), temporary)
|
||||
os.replace(temporary, link)
|
||||
directory_fd = os.open(link.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
class ArtifactDeployment:
|
||||
def __init__(self, config: dict[str, Any], state_path: Path):
|
||||
self.config = config
|
||||
self.state_path = state_path
|
||||
self.release_root = Path(config["release_root"]).resolve()
|
||||
self.current_link = Path(config["current_symlink"])
|
||||
self.previous_link = Path(config["previous_symlink"])
|
||||
self.units = _validate_units(config["service_units"])
|
||||
if not self.release_root.is_absolute():
|
||||
raise DeploymentError("RELEASE_ROOT_INVALID")
|
||||
for link in (self.current_link, self.previous_link):
|
||||
if not link.is_absolute() or link.parent.resolve() != self.release_root.parent:
|
||||
raise DeploymentError("RELEASE_LINK_PATH_DENIED")
|
||||
|
||||
def _artifact(self, envelope: ReleaseEnvelope) -> Path:
|
||||
path = Path(self.config["artifact_path"])
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise DeploymentError("ARTIFACT_PATH_INVALID")
|
||||
if sha256_file(path) != envelope.artifact_sha256:
|
||||
raise DeploymentError("ARTIFACT_SHA256_MISMATCH")
|
||||
return path
|
||||
|
||||
def _safe_extract(self, archive: Path, destination: Path) -> dict[str, Any]:
|
||||
with tarfile.open(archive, "r:*") as bundle:
|
||||
members = bundle.getmembers()
|
||||
if not members:
|
||||
raise DeploymentError("ARTIFACT_EMPTY")
|
||||
for member in members:
|
||||
pure = PurePosixPath(member.name)
|
||||
if (
|
||||
pure.is_absolute()
|
||||
or any(part in {"", ".", ".."} for part in pure.parts)
|
||||
or member.issym()
|
||||
or member.islnk()
|
||||
or member.isdev()
|
||||
or member.isfifo()
|
||||
):
|
||||
raise DeploymentError("ARTIFACT_MEMBER_DENIED")
|
||||
bundle.extractall(destination, filter="data")
|
||||
manifest_path = destination / self.config.get(
|
||||
"artifact_manifest_member", "artifact-manifest.json"
|
||||
)
|
||||
if not manifest_path.is_file() or manifest_path.is_symlink():
|
||||
raise DeploymentError("ARTIFACT_MANIFEST_MISSING")
|
||||
value = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict) or not isinstance(value.get("files"), dict):
|
||||
raise DeploymentError("ARTIFACT_MANIFEST_INVALID")
|
||||
return value
|
||||
|
||||
def stage(self, envelope: ReleaseEnvelope) -> Path:
|
||||
archive = self._artifact(envelope)
|
||||
release_dir = self.release_root / f"{envelope.version}-{envelope.target_oid[:12]}"
|
||||
ensure_directory(self.release_root)
|
||||
if release_dir.exists():
|
||||
self.verify_release_dir(envelope, release_dir)
|
||||
return release_dir
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".aios-release-stage-", dir=self.release_root
|
||||
) as temporary_name:
|
||||
temporary = Path(temporary_name)
|
||||
manifest = self._safe_extract(archive, temporary)
|
||||
if manifest.get("version") != envelope.version:
|
||||
raise DeploymentError("ARTIFACT_VERSION_MISMATCH")
|
||||
if manifest.get("target_oid") != envelope.target_oid:
|
||||
raise DeploymentError("ARTIFACT_TARGET_OID_MISMATCH")
|
||||
for raw_path, expected_sha in manifest["files"].items():
|
||||
rel = safe_relpath(raw_path)
|
||||
if not isinstance(expected_sha, str) or len(expected_sha) != 64:
|
||||
raise DeploymentError("ARTIFACT_FILE_DIGEST_INVALID")
|
||||
candidate = temporary / rel
|
||||
if not candidate.is_file() or candidate.is_symlink():
|
||||
raise DeploymentError("ARTIFACT_FILE_MISSING")
|
||||
if sha256_file(candidate) != expected_sha:
|
||||
raise DeploymentError("ARTIFACT_FILE_DIGEST_MISMATCH")
|
||||
manifest["artifact_sha256"] = envelope.artifact_sha256
|
||||
atomic_write_json(temporary / ".aios-release-binding.json", manifest)
|
||||
os.rename(temporary, release_dir)
|
||||
self.verify_release_dir(envelope, release_dir)
|
||||
return release_dir
|
||||
|
||||
def verify_release_dir(self, envelope: ReleaseEnvelope, release_dir: Path) -> None:
|
||||
if (
|
||||
not release_dir.is_dir()
|
||||
or release_dir.is_symlink()
|
||||
or self.release_root not in release_dir.resolve().parents
|
||||
):
|
||||
raise DeploymentError("RELEASE_DIRECTORY_INVALID")
|
||||
binding = release_dir / ".aios-release-binding.json"
|
||||
value = json.loads(binding.read_text(encoding="utf-8"))
|
||||
if value.get("version") != envelope.version:
|
||||
raise DeploymentError("RELEASE_BINDING_VERSION_MISMATCH")
|
||||
if value.get("target_oid") != envelope.target_oid:
|
||||
raise DeploymentError("RELEASE_BINDING_TARGET_MISMATCH")
|
||||
if value.get("artifact_sha256") != envelope.artifact_sha256:
|
||||
raise DeploymentError("RELEASE_BINDING_ARTIFACT_MISMATCH")
|
||||
for raw_path, expected_sha in value.get("files", {}).items():
|
||||
candidate = release_dir / safe_relpath(raw_path)
|
||||
if not candidate.is_file() or candidate.is_symlink():
|
||||
raise DeploymentError("RELEASE_FILE_MISSING")
|
||||
if sha256_file(candidate) != expected_sha:
|
||||
raise DeploymentError("RELEASE_FILE_DIGEST_MISMATCH")
|
||||
|
||||
def deploy(self, envelope: ReleaseEnvelope) -> Path:
|
||||
release_dir = self.stage(envelope)
|
||||
previous = _symlink_target(self.current_link)
|
||||
if previous is not None:
|
||||
_atomic_symlink(self.previous_link, previous)
|
||||
_atomic_symlink(self.current_link, release_dir)
|
||||
_run_systemctl("daemon-reload")
|
||||
for unit in self.units:
|
||||
_run_systemctl("restart", unit)
|
||||
atomic_write_json(self.state_path, {
|
||||
"version": envelope.version,
|
||||
"target_oid": envelope.target_oid,
|
||||
"release_dir": str(release_dir),
|
||||
"previous_dir": str(previous) if previous else None,
|
||||
"units": list(self.units),
|
||||
})
|
||||
return release_dir
|
||||
|
||||
def rollback(self) -> Path:
|
||||
previous = _symlink_target(self.previous_link)
|
||||
if previous is None or not previous.is_dir():
|
||||
raise DeploymentError("PREVIOUS_RELEASE_MISSING")
|
||||
_atomic_symlink(self.current_link, previous)
|
||||
_run_systemctl("daemon-reload")
|
||||
for unit in self.units:
|
||||
_run_systemctl("restart", unit)
|
||||
return previous
|
||||
|
||||
def restore(self) -> Path:
|
||||
if not self.state_path.is_file():
|
||||
raise DeploymentError("DEPLOYMENT_STATE_MISSING")
|
||||
state = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
release_dir = Path(state["release_dir"])
|
||||
if not release_dir.is_dir():
|
||||
raise DeploymentError("RESTORE_RELEASE_MISSING")
|
||||
_atomic_symlink(self.current_link, release_dir)
|
||||
_run_systemctl("daemon-reload")
|
||||
for unit in self.units:
|
||||
_run_systemctl("restart", unit)
|
||||
return release_dir
|
||||
|
||||
def health(self) -> None:
|
||||
for unit in self.units:
|
||||
_run_systemctl("is-active", "--quiet", unit)
|
||||
for check in self.config.get("health_checks", []):
|
||||
if not isinstance(check, dict):
|
||||
raise DeploymentError("HEALTH_CHECK_INVALID")
|
||||
url = check.get("url")
|
||||
if not isinstance(url, str) or not url.startswith(
|
||||
("http://127.0.0.1", "https://127.0.0.1")
|
||||
):
|
||||
raise DeploymentError("HEALTH_CHECK_URL_DENIED")
|
||||
request = urllib.request.Request(url, method=check.get("method", "GET"))
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=float(check.get("timeout_seconds", 10))
|
||||
) as response:
|
||||
status = response.status
|
||||
max_body = int(check.get("max_body_bytes", 65536))
|
||||
body = response.read(max_body + 1)
|
||||
if len(body) > max_body:
|
||||
raise DeploymentError("HEALTH_CHECK_BODY_TOO_LARGE")
|
||||
except (urllib.error.HTTPError, urllib.error.URLError) as exc:
|
||||
raise DeploymentError("HEALTH_CHECK_FAILED") from exc
|
||||
if status != int(check.get("expected_status", 200)):
|
||||
raise DeploymentError("HEALTH_CHECK_STATUS_MISMATCH")
|
||||
expected_sha = check.get("expected_body_sha256")
|
||||
if expected_sha and hashlib.sha256(body).hexdigest() != expected_sha:
|
||||
raise DeploymentError("HEALTH_CHECK_BODY_MISMATCH")
|
||||
|
||||
def verify_current(self, expected: Path) -> None:
|
||||
current = _symlink_target(self.current_link)
|
||||
if current is None or current.resolve() != expected.resolve():
|
||||
raise DeploymentError("CURRENT_RELEASE_MISMATCH")
|
||||
|
||||
|
||||
class MetadataManager:
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.registry_path = Path(config["registry_path"])
|
||||
self.baseline_path = Path(config["baseline_path"])
|
||||
self.registry_plan_path = Path(config["registry_plan_path"])
|
||||
self.baseline_plan_path = Path(config["baseline_plan_path"])
|
||||
self.snapshot_root = Path(config["metadata_snapshot_root"])
|
||||
for path in (
|
||||
self.registry_path, self.baseline_path,
|
||||
self.registry_plan_path, self.baseline_plan_path, self.snapshot_root
|
||||
):
|
||||
if not path.is_absolute():
|
||||
raise DeploymentError("METADATA_PATH_INVALID")
|
||||
|
||||
def _verify_plan(self, path: Path, expected_sha: str) -> bytes:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise DeploymentError("METADATA_PLAN_MISSING")
|
||||
raw = path.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != expected_sha:
|
||||
raise DeploymentError("METADATA_PLAN_SHA256_MISMATCH")
|
||||
return raw
|
||||
|
||||
def snapshot(self, envelope: ReleaseEnvelope) -> str:
|
||||
ensure_directory(self.snapshot_root)
|
||||
snapshot_id = f"{envelope.version}-{envelope.approval_merge_oid[:12]}"
|
||||
destination = self.snapshot_root / snapshot_id
|
||||
if destination.exists():
|
||||
return snapshot_id
|
||||
ensure_directory(destination)
|
||||
for name, source in (
|
||||
("registry", self.registry_path),
|
||||
("baseline", self.baseline_path),
|
||||
):
|
||||
if source.exists():
|
||||
raw = source.read_bytes()
|
||||
atomic_write(destination / f"{name}.bin", raw)
|
||||
atomic_write_json(destination / f"{name}.meta.json", {
|
||||
"exists": True,
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"path": str(source),
|
||||
})
|
||||
else:
|
||||
atomic_write_json(destination / f"{name}.meta.json", {
|
||||
"exists": False,
|
||||
"path": str(source),
|
||||
})
|
||||
return snapshot_id
|
||||
|
||||
def update_registry(self, envelope: ReleaseEnvelope) -> None:
|
||||
raw = self._verify_plan(self.registry_plan_path, envelope.registry_plan_sha256)
|
||||
atomic_write(self.registry_path, raw)
|
||||
|
||||
def update_baseline(self, envelope: ReleaseEnvelope) -> None:
|
||||
raw = self._verify_plan(self.baseline_plan_path, envelope.baseline_plan_sha256)
|
||||
atomic_write(self.baseline_path, raw)
|
||||
|
||||
def restore(self, snapshot_id: str) -> None:
|
||||
if "/" in snapshot_id or "\\" in snapshot_id:
|
||||
raise DeploymentError("METADATA_SNAPSHOT_ID_INVALID")
|
||||
destination = self.snapshot_root / safe_relpath(snapshot_id)
|
||||
if not destination.is_dir():
|
||||
raise DeploymentError("METADATA_SNAPSHOT_MISSING")
|
||||
for name, target in (
|
||||
("registry", self.registry_path),
|
||||
("baseline", self.baseline_path),
|
||||
):
|
||||
meta = json.loads((destination / f"{name}.meta.json").read_text(encoding="utf-8"))
|
||||
if meta.get("path") != str(target):
|
||||
raise DeploymentError("METADATA_SNAPSHOT_PATH_MISMATCH")
|
||||
if meta.get("exists"):
|
||||
raw = (destination / f"{name}.bin").read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != meta.get("sha256"):
|
||||
raise DeploymentError("METADATA_SNAPSHOT_DIGEST_MISMATCH")
|
||||
atomic_write(target, raw)
|
||||
else:
|
||||
target.unlink(missing_ok=True)
|
||||
|
||||
def verify_plans_applied(self, envelope: ReleaseEnvelope) -> None:
|
||||
if sha256_file(self.registry_path) != envelope.registry_plan_sha256:
|
||||
raise DeploymentError("REGISTRY_PLAN_NOT_APPLIED")
|
||||
if sha256_file(self.baseline_path) != envelope.baseline_plan_sha256:
|
||||
raise DeploymentError("BASELINE_PLAN_NOT_APPLIED")
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
import json, os, subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .common import canonical_json, require_oid
|
||||
from .release import ReleaseEnvelope, ReleaseError
|
||||
|
||||
class ReleaseHookError(ReleaseError): pass
|
||||
|
||||
class StructuredHookRunner:
|
||||
def __init__(self, hook_config: dict[str,Any]):
|
||||
self.hook_config=hook_config
|
||||
def command(self,name: str) -> list[str]:
|
||||
value=self.hook_config.get(name)
|
||||
if not isinstance(value,list) or not value or not all(isinstance(x,str) and x for x in value):
|
||||
raise ReleaseHookError(f"HOOK_COMMAND_INVALID:{name}")
|
||||
executable=Path(value[0])
|
||||
if not executable.is_absolute() or not executable.is_file() or executable.is_symlink():
|
||||
raise ReleaseHookError(f"HOOK_EXECUTABLE_INVALID:{name}")
|
||||
stat=executable.stat()
|
||||
if stat.st_uid!=0 or stat.st_mode & 0o022:
|
||||
raise ReleaseHookError(f"HOOK_EXECUTABLE_PERMISSIONS_INVALID:{name}")
|
||||
return value
|
||||
def run(self,name: str,envelope: ReleaseEnvelope,extra: dict[str,Any] | None=None) -> dict[str,Any]:
|
||||
payload={"hook":name,"envelope":envelope.__dict__,"extra":extra or {}}
|
||||
hook_env={"PATH":"/usr/sbin:/usr/bin:/sbin:/bin","AIOS_GITOPS_WRITES_ENABLED":"false"}
|
||||
if "CREDENTIALS_DIRECTORY" in os.environ:
|
||||
hook_env["CREDENTIALS_DIRECTORY"]=os.environ["CREDENTIALS_DIRECTORY"]
|
||||
result=subprocess.run(self.command(name),input=canonical_json(payload),capture_output=True,
|
||||
env=hook_env,timeout=600)
|
||||
if result.returncode: raise ReleaseHookError(f"HOOK_FAILED:{name}")
|
||||
try: value=json.loads(result.stdout)
|
||||
except json.JSONDecodeError as exc: raise ReleaseHookError(f"HOOK_RESPONSE_INVALID:{name}") from exc
|
||||
if not isinstance(value,dict) or value.get("status")!="PASS":
|
||||
raise ReleaseHookError(f"HOOK_RESPONSE_FAILED:{name}")
|
||||
return value
|
||||
|
||||
class ConfiguredReleaseHooks:
|
||||
def __init__(self,runner: StructuredHookRunner): self.runner=runner
|
||||
def verify_owner_approval(self,e): self.runner.run("verify_owner_approval",e)
|
||||
def merge_release(self,e):
|
||||
oid=require_oid(self.runner.run("merge_release",e).get("release_oid"),"release_oid")
|
||||
if oid!=e.target_oid: raise ReleaseHookError("RELEASE_TARGET_OID_MISMATCH")
|
||||
return oid
|
||||
def create_tag(self,e,release_oid):
|
||||
value=self.runner.run("create_tag",e,{"release_oid":release_oid})
|
||||
return require_oid(value.get("tag_oid"),"tag_oid"),require_oid(value.get("tag_target_oid"),"tag_target_oid")
|
||||
def verify_remote_tag(self,e,tag_oid,release_oid):
|
||||
self.runner.run("verify_remote_tag",e,{"tag_oid":tag_oid,"release_oid":release_oid})
|
||||
def deploy(self,e,release_oid): self.runner.run("deploy",e,{"release_oid":release_oid})
|
||||
def health_check(self,e): self.runner.run("health_check",e)
|
||||
def rollback(self,e): self.runner.run("rollback",e)
|
||||
def verify_previous(self,e): self.runner.run("verify_previous",e)
|
||||
def restore(self,e): self.runner.run("restore",e)
|
||||
def verify_restored(self,e): self.runner.run("verify_restored",e)
|
||||
def snapshot_metadata(self,e):
|
||||
value=self.runner.run("snapshot_metadata",e)
|
||||
snapshot_id=value.get("snapshot_id")
|
||||
if not isinstance(snapshot_id,str) or not snapshot_id:
|
||||
raise ReleaseHookError("METADATA_SNAPSHOT_INVALID")
|
||||
return snapshot_id
|
||||
def update_registry(self,e,release_oid,tag_oid):
|
||||
self.runner.run("update_registry",e,{"release_oid":release_oid,"tag_oid":tag_oid})
|
||||
def update_baseline(self,e,release_oid,tag_oid):
|
||||
self.runner.run("update_baseline",e,{"release_oid":release_oid,"tag_oid":tag_oid})
|
||||
def restore_metadata(self,e,snapshot_id):
|
||||
self.runner.run("restore_metadata",e,{"snapshot_id":snapshot_id})
|
||||
def verify_consistency(self,e,release_oid,tag_oid):
|
||||
self.runner.run("verify_consistency",e,{"release_oid":release_oid,"tag_oid":tag_oid})
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json
|
||||
from pathlib import Path
|
||||
from .release import ReleaseEnvelope, ReleaseEnvelopeExecutor
|
||||
from .release_hooks import ConfiguredReleaseHooks, StructuredHookRunner
|
||||
|
||||
def _load_root_json(path: Path) -> dict:
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError("RELEASE_INPUT_FILE_INVALID")
|
||||
stat=path.stat()
|
||||
if stat.st_uid!=0 or stat.st_mode & 0o022:
|
||||
raise RuntimeError("RELEASE_INPUT_FILE_PERMISSIONS_INVALID")
|
||||
value=json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value,dict):
|
||||
raise RuntimeError("RELEASE_INPUT_JSON_INVALID")
|
||||
return value
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument("--config",type=Path,required=True)
|
||||
p.add_argument("--envelope",type=Path,required=True)
|
||||
p.add_argument("--operation-id",required=True)
|
||||
args=p.parse_args(argv)
|
||||
cfg=_load_root_json(args.config)
|
||||
envelope=ReleaseEnvelope.from_mapping(_load_root_json(args.envelope))
|
||||
hooks=ConfiguredReleaseHooks(StructuredHookRunner(cfg["hooks"]))
|
||||
state=ReleaseEnvelopeExecutor(Path(cfg["state_path"]),Path(cfg["audit_path"]),hooks).execute(
|
||||
envelope,args.operation_id)
|
||||
print(json.dumps(state,sort_keys=True))
|
||||
return 0 if state["phase"]=="COMPLETED" else 2
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .credentials import gitea_credential
|
||||
|
||||
|
||||
class ReleaseTransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class StructuredTransport:
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
|
||||
def call(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
mode = self.config.get("transport")
|
||||
if mode == "unix_json":
|
||||
return self._unix_json(payload)
|
||||
if mode == "http_json":
|
||||
return self._http_json(payload)
|
||||
raise ReleaseTransportError("TRANSPORT_MODE_INVALID")
|
||||
|
||||
def _unix_json(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
socket_path = Path(self.config["socket_path"])
|
||||
if not socket_path.is_absolute() or not socket_path.exists() or socket_path.is_symlink():
|
||||
raise ReleaseTransportError("UNIX_SOCKET_INVALID")
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + b"\n"
|
||||
if len(raw) > int(self.config.get("max_request_bytes", 1024 * 1024)):
|
||||
raise ReleaseTransportError("UNIX_REQUEST_TOO_LARGE")
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.settimeout(float(self.config.get("timeout_seconds", 60)))
|
||||
client.connect(str(socket_path))
|
||||
client.sendall(raw)
|
||||
chunks = []
|
||||
size = 0
|
||||
limit = int(self.config.get("max_response_bytes", 1024 * 1024))
|
||||
while True:
|
||||
part = client.recv(65536)
|
||||
if not part:
|
||||
break
|
||||
chunks.append(part)
|
||||
size += len(part)
|
||||
if size > limit:
|
||||
raise ReleaseTransportError("UNIX_RESPONSE_TOO_LARGE")
|
||||
if b"\n" in part:
|
||||
break
|
||||
try:
|
||||
value = json.loads(b"".join(chunks).splitlines()[0])
|
||||
except (IndexError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseTransportError("UNIX_RESPONSE_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseTransportError("UNIX_RESPONSE_INVALID")
|
||||
if value.get("status") == "PASS" and isinstance(value.get("result"), dict):
|
||||
return value["result"]
|
||||
if value.get("status") == "PASS":
|
||||
return value
|
||||
raise ReleaseTransportError(str(value.get("error") or "UNIX_CALL_FAILED"))
|
||||
|
||||
def _http_json(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = self.config.get("url")
|
||||
if not isinstance(url, str) or not url.startswith(("http://127.0.0.1", "https://127.0.0.1")):
|
||||
raise ReleaseTransportError("HTTP_URL_DENIED")
|
||||
username, token, _ = gitea_credential(self.config["credential_name"])
|
||||
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
request = urllib.request.Request(url, data=raw, method="POST")
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
request.add_header("X-AIOS-Actor", username)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=float(self.config.get("timeout_seconds", 60))
|
||||
) as response:
|
||||
status = response.status
|
||||
body = response.read(int(self.config.get("max_response_bytes", 1024 * 1024)) + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise ReleaseTransportError(f"HTTP_CALL_{exc.code}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ReleaseTransportError("HTTP_CALL_UNAVAILABLE") from exc
|
||||
if status not in tuple(self.config.get("expected_statuses", [200])):
|
||||
raise ReleaseTransportError(f"HTTP_CALL_{status}")
|
||||
if len(body) > int(self.config.get("max_response_bytes", 1024 * 1024)):
|
||||
raise ReleaseTransportError("HTTP_RESPONSE_TOO_LARGE")
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReleaseTransportError("HTTP_RESPONSE_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseTransportError("HTTP_RESPONSE_INVALID")
|
||||
if value.get("status") not in {"PASS", "COMPLETED", "SUCCESS"}:
|
||||
raise ReleaseTransportError(str(value.get("error") or "HTTP_CALL_FAILED"))
|
||||
return value.get("result") if isinstance(value.get("result"), dict) else value
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .common import parse_utc, sha256_json, utc_now
|
||||
|
||||
|
||||
class RenewalError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenewalDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
renewal_number: int
|
||||
issued_at: str | None
|
||||
expires_at: str | None
|
||||
manifest: dict[str, Any] | None
|
||||
|
||||
|
||||
class RenewalController:
|
||||
IMMUTABLE_FIELDS = (
|
||||
"policy_sha256",
|
||||
"allowed_paths",
|
||||
"allowed_operations",
|
||||
"denied_operations",
|
||||
"runtime_sources",
|
||||
"artifact_sha256",
|
||||
"target_oid",
|
||||
)
|
||||
|
||||
def decide(
|
||||
self,
|
||||
*,
|
||||
standing_policy: Mapping[str, Any],
|
||||
previous_manifest: Mapping[str, Any],
|
||||
observation: Mapping[str, Any],
|
||||
) -> RenewalDecision:
|
||||
required = {
|
||||
"policy_id", "maximum_automatic_renewals", "maximum_total_window_seconds",
|
||||
"single_renewal_seconds", "effective_at", "expires_at",
|
||||
}
|
||||
if not required.issubset(standing_policy):
|
||||
raise RenewalError("STANDING_POLICY_INVALID")
|
||||
count = int(previous_manifest.get("automatic_renewal_number", 0))
|
||||
maximum = int(standing_policy["maximum_automatic_renewals"])
|
||||
if count >= maximum:
|
||||
return RenewalDecision(False, "AUTOMATIC_RENEWAL_LIMIT_REACHED", count, None, None, None)
|
||||
single = int(standing_policy["single_renewal_seconds"])
|
||||
if single < 1 or single > 12 * 3600:
|
||||
raise RenewalError("SINGLE_RENEWAL_WINDOW_INVALID")
|
||||
total = int(standing_policy["maximum_total_window_seconds"])
|
||||
if total > 7 * 24 * 3600:
|
||||
raise RenewalError("TOTAL_WINDOW_INVALID")
|
||||
if observation.get("secret_scan") != "PASS":
|
||||
return RenewalDecision(False, "SECRET_SCAN_NOT_PASS", count, None, None, None)
|
||||
if observation.get("approval_ref_fast_forward") is not True:
|
||||
return RenewalDecision(False, "APPROVAL_REF_NOT_FAST_FORWARD", count, None, None, None)
|
||||
for field in self.IMMUTABLE_FIELDS:
|
||||
if previous_manifest.get(field) != observation.get(field):
|
||||
return RenewalDecision(False, f"IMMUTABLE_FIELD_CHANGED:{field}", count, None, None, None)
|
||||
start = parse_utc(str(standing_policy["effective_at"]))
|
||||
now = utc_now()
|
||||
if now >= parse_utc(str(standing_policy["expires_at"])):
|
||||
return RenewalDecision(False, "STANDING_POLICY_EXPIRED", count, None, None, None)
|
||||
if (now - start).total_seconds() >= total:
|
||||
return RenewalDecision(False, "TOTAL_WINDOW_EXHAUSTED", count, None, None, None)
|
||||
expiry = min(
|
||||
now + timedelta(seconds=single),
|
||||
start + timedelta(seconds=total),
|
||||
parse_utc(str(standing_policy["expires_at"])),
|
||||
)
|
||||
manifest = dict(previous_manifest)
|
||||
manifest.update({
|
||||
"approval_type": "AUTONOMOUS_EXECUTION_RENEWAL",
|
||||
"automatic_renewal_number": count + 1,
|
||||
"issued_at": now.isoformat().replace("+00:00", "Z"),
|
||||
"expires_at": expiry.isoformat().replace("+00:00", "Z"),
|
||||
"renews_manifest_sha256": sha256_json(dict(previous_manifest)),
|
||||
"standing_policy_id": standing_policy["policy_id"],
|
||||
})
|
||||
return RenewalDecision(
|
||||
True, "PASS", count + 1,
|
||||
manifest["issued_at"], manifest["expires_at"], manifest,
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
from .common import atomic_write_json
|
||||
from .renewal import RenewalController
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True); p.add_argument("--once",action="store_true")
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
requests_dir,receipts_dir=Path(cfg["requests_dir"]),Path(cfg["receipts_dir"])
|
||||
controller=RenewalController()
|
||||
while True:
|
||||
receipts_dir.mkdir(parents=True,exist_ok=True)
|
||||
for request_path in sorted(requests_dir.glob("*.json")):
|
||||
receipt=receipts_dir/f"{request_path.stem}.receipt.json"
|
||||
if receipt.exists(): continue
|
||||
req=json.loads(request_path.read_text(encoding="utf-8"))
|
||||
decision=controller.decide(standing_policy=req["standing_policy"],
|
||||
previous_manifest=req["previous_manifest"],observation=req["observation"])
|
||||
atomic_write_json(receipt,decision.__dict__)
|
||||
if args.once: break
|
||||
time.sleep(float(cfg.get("poll_seconds",10)))
|
||||
return 0
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json, urllib.error, urllib.request, subprocess
|
||||
from pathlib import Path
|
||||
from .common import atomic_write_json, sha256_json, sha256_file
|
||||
from .credentials import gitea_credential
|
||||
from .gitea_api import GiteaApi, GiteaApiError
|
||||
|
||||
class RevocationError(RuntimeError): pass
|
||||
|
||||
def revoke_grant_file(grant_path: Path, revoked_path: Path) -> str:
|
||||
if not grant_path.is_absolute() or not revoked_path.is_absolute():
|
||||
raise RevocationError("GRANT_REVOCATION_PATH_INVALID")
|
||||
if grant_path.parent.resolve() != revoked_path.parent.resolve():
|
||||
raise RevocationError("GRANT_REVOCATION_PATH_INVALID")
|
||||
if grant_path.is_symlink() or revoked_path.is_symlink():
|
||||
raise RevocationError("GRANT_REVOCATION_SYMLINK_DENIED")
|
||||
if revoked_path.exists():
|
||||
return sha256_file(revoked_path)
|
||||
if not grant_path.is_file():
|
||||
raise RevocationError("GRANT_FILE_MISSING")
|
||||
digest=sha256_file(grant_path)
|
||||
grant_path.replace(revoked_path)
|
||||
revoked_path.chmod(0o440)
|
||||
return digest
|
||||
|
||||
def verify_401(base_url: str, token: str) -> int:
|
||||
req=urllib.request.Request(base_url.rstrip("/")+"/api/v1/user")
|
||||
req.add_header("Authorization",f"token {token}")
|
||||
try: urllib.request.urlopen(req,timeout=15)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code==401: return 401
|
||||
raise RevocationError(f"OLD_TOKEN_UNEXPECTED_HTTP_{exc.code}") from exc
|
||||
except urllib.error.URLError as exc: raise RevocationError("OLD_TOKEN_VERIFICATION_UNAVAILABLE") from exc
|
||||
raise RevocationError("OLD_TOKEN_STILL_AUTHORIZED")
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True)
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
writer_username,writer_token,token_id=gitea_credential(cfg["writer_credential_name"])
|
||||
if token_id is None: raise RevocationError("WRITER_TOKEN_ID_REQUIRED")
|
||||
admin_username,admin_token,_=gitea_credential(cfg["admin_credential_name"])
|
||||
if writer_username!=cfg["expected_writer_username"]: raise RevocationError("WRITER_USERNAME_MISMATCH")
|
||||
unit=cfg["writer_unit"]
|
||||
if not isinstance(unit,str) or not unit.endswith(".service") or "/" in unit:
|
||||
raise RevocationError("WRITER_UNIT_INVALID")
|
||||
subprocess.run(["systemctl","stop",unit],check=True,capture_output=True)
|
||||
api=GiteaApi(cfg["gitea_base_url"],admin_token)
|
||||
try: api.request("DELETE",f"/api/v1/users/{writer_username}/tokens/{token_id}",expected=(204,))
|
||||
except GiteaApiError as exc:
|
||||
if exc.status!=404: raise
|
||||
old_status=verify_401(cfg["gitea_base_url"],writer_token)
|
||||
encrypted=Path(cfg["encrypted_credential_path"])
|
||||
if encrypted.resolve().parent!=Path("/etc/credstore.encrypted").resolve() or encrypted.is_symlink():
|
||||
raise RevocationError("ENCRYPTED_CREDENTIAL_PATH_DENIED")
|
||||
encrypted.unlink(missing_ok=True)
|
||||
grant_sha256=revoke_grant_file(
|
||||
Path(cfg["grant_path"]),Path(cfg["revoked_grant_path"])
|
||||
)
|
||||
receipt={"writer_unit":unit,"writer_username":writer_username,"token_id":token_id,
|
||||
"old_token_auth_status":old_status,"encrypted_credential_removed":not encrypted.exists(),
|
||||
"grant_revoked":True,"grant_sha256":grant_sha256,
|
||||
"revoked_grant_path":cfg["revoked_grant_path"],
|
||||
"admin_username_hash":sha256_json({"username":admin_username})}
|
||||
atomic_write_json(Path(cfg["receipt_path"]),receipt)
|
||||
return 0
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
import json, os, socket, socketserver, struct
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from .common import canonical_json, ensure_directory, redact
|
||||
|
||||
class SocketServiceError(RuntimeError): pass
|
||||
|
||||
def peer_credentials(connection: socket.socket) -> tuple[int, int, int]:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise SocketServiceError("SO_PEERCRED_UNAVAILABLE")
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
||||
return struct.unpack("3i", raw)
|
||||
|
||||
class JsonLineUnixHandler(socketserver.StreamRequestHandler):
|
||||
def handle(self) -> None:
|
||||
server = self.server
|
||||
pid, uid, gid = peer_credentials(self.connection)
|
||||
if uid not in server.allowed_uids:
|
||||
self.wfile.write(canonical_json({"status":"DENIED","error":"PEER_UID_DENIED"}) + b"\n")
|
||||
return
|
||||
line = self.rfile.readline(server.max_request_bytes + 1)
|
||||
if not line or len(line) > server.max_request_bytes:
|
||||
self.wfile.write(canonical_json({"status":"DENIED","error":"REQUEST_SIZE_INVALID"}) + b"\n")
|
||||
return
|
||||
try:
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise SocketServiceError("REQUEST_SCHEMA_INVALID")
|
||||
response = server.dispatch(value, {"pid":pid,"uid":uid,"gid":gid})
|
||||
result = {"status":"PASS","result":redact(response)}
|
||||
except Exception as exc:
|
||||
result = {"status":"FAILED","error":str(exc)}
|
||||
self.wfile.write(canonical_json(result) + b"\n")
|
||||
|
||||
class JsonLineUnixServer(socketserver.UnixStreamServer):
|
||||
allow_reuse_address = True
|
||||
def __init__(self, socket_path: Path, *, allowed_uids: set[int],
|
||||
dispatch: Callable[[dict[str, Any], dict[str, int]], dict[str, Any]],
|
||||
max_request_bytes: int = 4 * 1024 * 1024, socket_mode: int = 0o660):
|
||||
if not socket_path.is_absolute():
|
||||
raise SocketServiceError("SOCKET_PATH_INVALID")
|
||||
ensure_directory(socket_path.parent)
|
||||
socket_path.unlink(missing_ok=True)
|
||||
self.allowed_uids = allowed_uids
|
||||
self.dispatch = dispatch
|
||||
self.max_request_bytes = max_request_bytes
|
||||
self.socket_path = socket_path
|
||||
super().__init__(str(socket_path), JsonLineUnixHandler)
|
||||
os.chmod(socket_path, socket_mode)
|
||||
def server_close(self) -> None:
|
||||
super().server_close()
|
||||
self.socket_path.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
from .common import require_oid
|
||||
|
||||
|
||||
class ApprovalError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalSnapshot:
|
||||
pr_number: int
|
||||
head_oid: str
|
||||
base_branch: str
|
||||
merged: bool
|
||||
merged_oid: str | None
|
||||
owner_approved: bool
|
||||
owner_review_oid: int | None
|
||||
owner_reviewed_head_oid: str | None
|
||||
stale_reviews: int
|
||||
dismissed_reviews: int
|
||||
machine_statuses: dict[str, str]
|
||||
|
||||
|
||||
class GiteaSQLiteApprovalWatcher:
|
||||
def __init__(self, db_path: Path, *, repo_id: int, owner_user_id: int = 2):
|
||||
self.db_path = db_path
|
||||
self.repo_id = repo_id
|
||||
self.owner_user_id = owner_user_id
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
pr_number: int,
|
||||
*,
|
||||
expected_head_oid: str,
|
||||
required_machine_contexts: Iterable[str],
|
||||
) -> ApprovalSnapshot:
|
||||
head = require_oid(expected_head_oid, "expected_head_oid")
|
||||
if not self.db_path.is_absolute() or not self.db_path.is_file() or self.db_path.is_symlink():
|
||||
raise ApprovalError("GITEA_DB_INVALID")
|
||||
with sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) as db:
|
||||
db.row_factory = sqlite3.Row
|
||||
pr = db.execute(
|
||||
"SELECT pr.issue_id,pr.head_branch,pr.base_branch,pr.has_merged,"
|
||||
"pr.merged_commit_id FROM pull_request pr "
|
||||
"JOIN issue i ON i.id=pr.issue_id "
|
||||
"WHERE i.repo_id=? AND pr.\"index\"=?",
|
||||
(self.repo_id, pr_number),
|
||||
).fetchone()
|
||||
if pr is None:
|
||||
raise ApprovalError("PR_NOT_FOUND")
|
||||
owner = db.execute(
|
||||
"SELECT id,type,commit_id,stale,dismissed FROM review "
|
||||
"WHERE issue_id=? AND reviewer_id=? ORDER BY id DESC LIMIT 1",
|
||||
(pr["issue_id"], self.owner_user_id),
|
||||
).fetchone()
|
||||
stale = db.execute(
|
||||
"SELECT COUNT(*) FROM review WHERE issue_id=? AND stale=1",
|
||||
(pr["issue_id"],),
|
||||
).fetchone()[0]
|
||||
dismissed = db.execute(
|
||||
"SELECT COUNT(*) FROM review WHERE issue_id=? AND dismissed=1",
|
||||
(pr["issue_id"],),
|
||||
).fetchone()[0]
|
||||
status_rows = db.execute(
|
||||
"SELECT context,state,sha FROM commit_status "
|
||||
"WHERE repo_id=? AND sha=? ORDER BY id DESC",
|
||||
(self.repo_id, head),
|
||||
).fetchall()
|
||||
statuses: dict[str, str] = {}
|
||||
required = set(required_machine_contexts)
|
||||
for row in status_rows:
|
||||
if row["context"] in required:
|
||||
statuses.setdefault(row["context"], row["state"])
|
||||
owner_ok = bool(
|
||||
owner
|
||||
and int(owner["type"]) == 1
|
||||
and owner["commit_id"] == head
|
||||
and int(owner["stale"]) == 0
|
||||
and int(owner["dismissed"]) == 0
|
||||
)
|
||||
return ApprovalSnapshot(
|
||||
pr_number=pr_number,
|
||||
head_oid=head,
|
||||
base_branch=str(pr["base_branch"]),
|
||||
merged=bool(pr["has_merged"]),
|
||||
merged_oid=str(pr["merged_commit_id"]) if pr["merged_commit_id"] else None,
|
||||
owner_approved=owner_ok,
|
||||
owner_review_oid=int(owner["id"]) if owner_ok else None,
|
||||
owner_reviewed_head_oid=str(owner["commit_id"]) if owner_ok else None,
|
||||
stale_reviews=int(stale),
|
||||
dismissed_reviews=int(dismissed),
|
||||
machine_statuses=statuses,
|
||||
)
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
import argparse, json, time
|
||||
from pathlib import Path
|
||||
from .common import atomic_write_json
|
||||
from .watcher import GiteaSQLiteApprovalWatcher
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True); p.add_argument("--once",action="store_true")
|
||||
args=p.parse_args(argv); cfg=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
requests_dir,output_dir=Path(cfg["requests_dir"]),Path(cfg["output_dir"])
|
||||
watcher=GiteaSQLiteApprovalWatcher(Path(cfg["gitea_db_path"]),
|
||||
repo_id=int(cfg["repo_id"]),owner_user_id=int(cfg["owner_user_id"]))
|
||||
while True:
|
||||
output_dir.mkdir(parents=True,exist_ok=True)
|
||||
for request_path in sorted(requests_dir.glob("*.json")):
|
||||
req=json.loads(request_path.read_text(encoding="utf-8"))
|
||||
snapshot=watcher.snapshot(int(req["pr_number"]),expected_head_oid=req["expected_head_oid"],
|
||||
required_machine_contexts=req["required_machine_contexts"])
|
||||
atomic_write_json(output_dir/f"{request_path.stem}.status.json",snapshot.__dict__)
|
||||
if args.once: break
|
||||
time.sleep(float(cfg.get("poll_seconds",5)))
|
||||
return 0
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .common import HashChainAudit, canonical_json, require_oid, safe_relpath
|
||||
from .ledger import LedgerError, OperationLedger
|
||||
from .policy import DevelopmentPolicy, PolicyError
|
||||
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----"),
|
||||
re.compile(rb"(?i)\b(?:api[_-]?key|password|secret|token)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=:-]{16,}"),
|
||||
)
|
||||
|
||||
|
||||
class WriterError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitRequest:
|
||||
task_id: str
|
||||
operation_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
expected_head_oid: str
|
||||
expected_base_oid: str
|
||||
path: str
|
||||
content: bytes
|
||||
content_sha256: str
|
||||
message: str
|
||||
actor_identity: str
|
||||
writer_audience: str
|
||||
|
||||
|
||||
class GovernedDevelopmentWriter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
worktree: Path,
|
||||
remote: str,
|
||||
policy: DevelopmentPolicy,
|
||||
ledger: OperationLedger,
|
||||
audit: HashChainAudit,
|
||||
git_env: dict[str, str] | None = None,
|
||||
):
|
||||
self.worktree = worktree
|
||||
self.remote = remote
|
||||
self.policy = policy
|
||||
self.ledger = ledger
|
||||
self.audit = audit
|
||||
self.git_env = dict(git_env or {})
|
||||
|
||||
def _git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", **self.git_env}
|
||||
config_count = int(env.get("GIT_CONFIG_COUNT", "0"))
|
||||
env[f"GIT_CONFIG_KEY_{config_count}"] = "safe.directory"
|
||||
env[f"GIT_CONFIG_VALUE_{config_count}"] = str(self.worktree)
|
||||
env["GIT_CONFIG_COUNT"] = str(config_count + 1)
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=self.worktree, text=True, capture_output=True, env=env
|
||||
)
|
||||
if check and result.returncode:
|
||||
raise WriterError("GIT_OPERATION_FAILED", 500)
|
||||
return result
|
||||
|
||||
def _head(self) -> str:
|
||||
return require_oid(self._git("rev-parse", "HEAD").stdout.strip(), "head_oid")
|
||||
|
||||
def _remote_head(self, branch: str) -> str | None:
|
||||
result = self._git(
|
||||
"ls-remote", "--heads", self.remote, f"refs/heads/{branch}", check=False
|
||||
)
|
||||
if result.returncode:
|
||||
raise WriterError("REMOTE_PROJECTION_UNAVAILABLE", 503)
|
||||
fields = result.stdout.split()
|
||||
if not fields:
|
||||
return None
|
||||
if len(fields) != 2:
|
||||
raise WriterError("REMOTE_PROJECTION_INVALID", 503)
|
||||
return require_oid(fields[0], "remote_oid")
|
||||
|
||||
def _validate_worktree(self, request: CommitRequest) -> None:
|
||||
if not self.worktree.is_absolute() or not (self.worktree / ".git").exists():
|
||||
raise WriterError("WORKTREE_INVALID", 500)
|
||||
current_branch = self._git("branch", "--show-current").stdout.strip()
|
||||
if current_branch != request.branch:
|
||||
raise WriterError("BRANCH_CHECKOUT_MISMATCH")
|
||||
if self._git("status", "--porcelain").stdout.strip():
|
||||
raise WriterError("WORKTREE_DIRTY")
|
||||
head = self._head()
|
||||
if head != request.expected_head_oid:
|
||||
raise WriterError("BRANCH_HEAD_CHANGED")
|
||||
remote = self._remote_head(request.branch)
|
||||
if remote is not None and remote != head:
|
||||
raise WriterError("REMOTE_BRANCH_HEAD_CHANGED")
|
||||
|
||||
@staticmethod
|
||||
def _secret_scan(content: bytes) -> None:
|
||||
if any(pattern.search(content) for pattern in SECRET_PATTERNS):
|
||||
raise WriterError("SECRET_CONTENT_REJECTED", 403)
|
||||
|
||||
def commit_file(self, request: CommitRequest) -> dict[str, Any]:
|
||||
try:
|
||||
require_oid(request.expected_head_oid, "expected_head_oid")
|
||||
require_oid(request.expected_base_oid, "expected_base_oid")
|
||||
normalized_path = safe_relpath(request.path)
|
||||
if hashlib.sha256(request.content).hexdigest() != request.content_sha256:
|
||||
raise WriterError("CONTENT_DIGEST_MISMATCH", 403)
|
||||
if not request.message or "\n" in request.message or "\r" in request.message:
|
||||
raise WriterError("COMMIT_MESSAGE_INVALID", 403)
|
||||
self.policy.validate(
|
||||
repository=request.repository,
|
||||
branch=request.branch,
|
||||
path=normalized_path,
|
||||
operation="commit_file.atomic",
|
||||
actor_identity=request.actor_identity,
|
||||
writer_audience=request.writer_audience,
|
||||
expected_base_oid=request.expected_base_oid,
|
||||
)
|
||||
except (RuntimeError, PolicyError) as exc:
|
||||
if isinstance(exc, WriterError):
|
||||
raise
|
||||
raise WriterError(str(exc), 403) from exc
|
||||
|
||||
self._secret_scan(request.content)
|
||||
payload = {
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"branch": request.branch,
|
||||
"expected_head_oid": request.expected_head_oid,
|
||||
"expected_base_oid": request.expected_base_oid,
|
||||
"path": normalized_path,
|
||||
"content_sha256": request.content_sha256,
|
||||
"message_sha256": hashlib.sha256(request.message.encode()).hexdigest(),
|
||||
"actor_identity": request.actor_identity,
|
||||
"writer_audience": request.writer_audience,
|
||||
"policy_sha256": self.policy.policy_sha256,
|
||||
}
|
||||
payload_sha = hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
existing = self.ledger.get(request.operation_id)
|
||||
if existing is not None:
|
||||
if existing["payload_sha256"] != payload_sha:
|
||||
raise WriterError("OPERATION_ID_PAYLOAD_MISMATCH", 409)
|
||||
if existing["state"] == "COMPLETED":
|
||||
return json.loads(existing["result_json"])
|
||||
if existing["state"] in {"FAILED_RESTORED", "RECONCILIATION_REQUIRED", "FAILED"}:
|
||||
raise WriterError(existing["state"], 409)
|
||||
|
||||
self._validate_worktree(request)
|
||||
record = self.ledger.prepare(
|
||||
request.operation_id, payload_sha, request.expected_head_oid, int(time.time())
|
||||
)
|
||||
|
||||
target = (self.worktree / normalized_path).resolve()
|
||||
try:
|
||||
target.relative_to(self.worktree.resolve())
|
||||
except ValueError as exc:
|
||||
raise WriterError("PATH_ESCAPE_DENIED", 403) from exc
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
previous_content = target.read_bytes() if target.exists() else None
|
||||
try:
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=".aios-writer-", dir=target.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
os.write(fd, request.content)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.replace(temporary, target)
|
||||
self._git("add", "--", normalized_path)
|
||||
self._git("commit", "-m", request.message, "--", normalized_path)
|
||||
commit_oid = self._head()
|
||||
push = self._git(
|
||||
"push", "--porcelain", self.remote,
|
||||
f"HEAD:refs/heads/{request.branch}", check=False
|
||||
)
|
||||
if push.returncode:
|
||||
try:
|
||||
observed = self._remote_head(request.branch)
|
||||
except WriterError:
|
||||
observed = None
|
||||
if observed == commit_oid:
|
||||
state = "COMPLETED"
|
||||
elif observed == request.expected_head_oid:
|
||||
self._git("reset", "--hard", request.expected_head_oid)
|
||||
state = "FAILED_RESTORED"
|
||||
else:
|
||||
state = "RECONCILIATION_REQUIRED"
|
||||
else:
|
||||
observed = self._remote_head(request.branch)
|
||||
state = "COMPLETED" if observed == commit_oid else "RECONCILIATION_REQUIRED"
|
||||
|
||||
result = {
|
||||
"state": state,
|
||||
"operation_id": request.operation_id,
|
||||
"previous_oid": request.expected_head_oid,
|
||||
"commit_oid": commit_oid,
|
||||
"remote_oid": observed,
|
||||
"path": normalized_path,
|
||||
}
|
||||
self.ledger.finish(
|
||||
request.operation_id, state,
|
||||
commit_oid if state != "FAILED_RESTORED" else None,
|
||||
json.dumps(result, sort_keys=True), int(time.time()),
|
||||
)
|
||||
self.audit.append(request.operation_id, {
|
||||
"event": "WRITER_COMMIT_RESULT",
|
||||
**result,
|
||||
"secrets_included": False,
|
||||
})
|
||||
if state != "COMPLETED":
|
||||
raise WriterError(state)
|
||||
return result
|
||||
except Exception:
|
||||
if self._git("rev-parse", "HEAD", check=False).stdout.strip() != request.expected_head_oid:
|
||||
self._git("reset", "--hard", request.expected_head_oid, check=False)
|
||||
if previous_content is None:
|
||||
target.unlink(missing_ok=True)
|
||||
else:
|
||||
target.write_bytes(previous_content)
|
||||
self._git("reset", "--hard", request.expected_head_oid, check=False)
|
||||
raise
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
import argparse, base64, json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from .common import HashChainAudit, safe_relpath
|
||||
from .credentials import gitea_credential, git_http_auth_env
|
||||
from .grant import GrantQuotaLedger, WriterGrant
|
||||
from .ledger import OperationLedger
|
||||
from .policy import DevelopmentPolicy
|
||||
from .socket_service import JsonLineUnixServer
|
||||
from .writer import CommitRequest, GovernedDevelopmentWriter
|
||||
|
||||
class WriterServerError(RuntimeError): pass
|
||||
|
||||
def under(path: Path, roots: list[Path]) -> bool:
|
||||
resolved = path.resolve()
|
||||
return any(resolved == root.resolve() or root.resolve() in resolved.parents for root in roots)
|
||||
|
||||
class WriterApplication:
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
self.policy = DevelopmentPolicy.from_mapping(
|
||||
json.loads(Path(config["policy_path"]).read_text(encoding="utf-8")))
|
||||
self.grant = WriterGrant.load(Path(config["grant_path"]))
|
||||
if self.grant.expected_policy_sha256 != self.policy.policy_sha256:
|
||||
raise WriterServerError("GRANT_POLICY_DIGEST_MISMATCH")
|
||||
self.quota = GrantQuotaLedger(Path(config["grant_quota_ledger"]))
|
||||
self.worktree_roots = [Path(v) for v in config["worktree_roots"]]
|
||||
self.username,self.token,_ = gitea_credential(config["credential_name"])
|
||||
def dispatch(self, value: dict[str, Any], peer: dict[str,int]) -> dict[str,Any]:
|
||||
required = {"task_id","operation_id","repository","branch","expected_head_oid",
|
||||
"expected_base_oid","path","content_b64","content_sha256","message",
|
||||
"actor_identity","writer_audience","worktree"}
|
||||
if set(value) != required:
|
||||
raise WriterServerError("REQUEST_SCHEMA_INVALID")
|
||||
worktree = Path(value["worktree"])
|
||||
if not worktree.is_absolute() or not under(worktree,self.worktree_roots):
|
||||
raise WriterServerError("WORKTREE_ROOT_DENIED")
|
||||
content = base64.b64decode(value["content_b64"], validate=True)
|
||||
self.grant.validate_request(
|
||||
task_id=value["task_id"],repository=value["repository"],branch=value["branch"],
|
||||
actor_identity=value["actor_identity"],writer_audience=value["writer_audience"],
|
||||
expected_base_oid=value["expected_base_oid"],operation="commit_file.atomic",
|
||||
path=value["path"])
|
||||
import hashlib
|
||||
quota_payload_sha = hashlib.sha256(
|
||||
json.dumps({
|
||||
"task_id":value["task_id"],"repository":value["repository"],
|
||||
"branch":value["branch"],"expected_head_oid":value["expected_head_oid"],
|
||||
"expected_base_oid":value["expected_base_oid"],"path":value["path"],
|
||||
"content_sha256":value["content_sha256"],"message":value["message"],
|
||||
"actor_identity":value["actor_identity"],
|
||||
"writer_audience":value["writer_audience"],
|
||||
},sort_keys=True,separators=(",",":")).encode()
|
||||
).hexdigest()
|
||||
self.quota.consume(self.grant,value["operation_id"],quota_payload_sha,len(content))
|
||||
request = CommitRequest(
|
||||
task_id=value["task_id"],operation_id=value["operation_id"],
|
||||
repository=value["repository"],branch=value["branch"],
|
||||
expected_head_oid=value["expected_head_oid"],
|
||||
expected_base_oid=value["expected_base_oid"],path=safe_relpath(value["path"]),
|
||||
content=content,content_sha256=value["content_sha256"],message=value["message"],
|
||||
actor_identity=value["actor_identity"],writer_audience=value["writer_audience"])
|
||||
result = GovernedDevelopmentWriter(
|
||||
worktree=worktree,remote=self.config["remote"],policy=self.policy,
|
||||
ledger=OperationLedger(Path(self.config["operation_ledger"])),
|
||||
audit=HashChainAudit(Path(self.config["audit_path"])),
|
||||
git_env=git_http_auth_env(self.username,self.token)).commit_file(request)
|
||||
return {**result,"peer_uid":peer["uid"],"grant_id":self.grant.grant_id,
|
||||
"grant_sha256":self.grant.grant_sha256}
|
||||
|
||||
def main(argv: list[str] | None=None) -> int:
|
||||
p=argparse.ArgumentParser(); p.add_argument("--config",type=Path,required=True)
|
||||
args=p.parse_args(argv); config=json.loads(args.config.read_text(encoding="utf-8"))
|
||||
app=WriterApplication(config)
|
||||
server=JsonLineUnixServer(Path(config["socket_path"]),
|
||||
allowed_uids={int(v) for v in config["allowed_peer_uids"]},
|
||||
dispatch=app.dispatch,max_request_bytes=int(config.get("max_request_bytes",4194304)))
|
||||
try: server.serve_forever()
|
||||
finally: server.server_close()
|
||||
return 0
|
||||
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-autonomous-control-plane"
|
||||
version = "0.2.0"
|
||||
description = "Fail-closed AIOS autonomous development and release control plane"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
aios-autonomous-control-plane = "aios_autonomous_control_plane.cli:main"
|
||||
aios-development-writer-server = "aios_autonomous_control_plane.writer_server:main"
|
||||
aios-governance-request-publisher = "aios_autonomous_control_plane.publisher:main"
|
||||
aios-governance-approval-watcher = "aios_autonomous_control_plane.watcher_service:main"
|
||||
aios-governance-mirror-worker = "aios_autonomous_control_plane.mirror_service:main"
|
||||
aios-release-object-projector = "aios_autonomous_control_plane.projection_service:release_main"
|
||||
aios-runtime-source-projector = "aios_autonomous_control_plane.projection_service:runtime_main"
|
||||
aios-autonomous-renewal-controller = "aios_autonomous_control_plane.renewal_service:main"
|
||||
aios-development-writer-revoker = "aios_autonomous_control_plane.revoker:main"
|
||||
aios-v2r1c4-release-executor = "aios_autonomous_control_plane.release_service:main"
|
||||
aios-release-hook = "aios_autonomous_control_plane.release_action:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["aios_autonomous_control_plane*"]
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
"""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
@@ -0,0 +1,233 @@
|
||||
"""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
@@ -0,0 +1,78 @@
|
||||
"""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,15 +1,12 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-autonomous-release-candidate"
|
||||
version = "0.1.0"
|
||||
description = "Candidate-only governed autonomous release control plane"
|
||||
readme = "README.md"
|
||||
description = "Dormant candidate orchestration and release governance for AIOS"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [{ name = "AIOS" }]
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
@@ -19,7 +16,3 @@ 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,51 @@
|
||||
# AIOS Single-Owner Governance Prerequisites
|
||||
|
||||
This package is a dormant candidate produced under the PR #29 prerequisite
|
||||
authorization. It validates exact-head Owner approvals and machine gates,
|
||||
models protection-policy compare-and-swap with compensation, builds evidence,
|
||||
and exercises every mutation path only against disposable fixtures.
|
||||
|
||||
The prerequisite shadow services remain static and refuse live mutation. This
|
||||
branch adds a separate, one-shot activation executor with `--plan`,
|
||||
`--activate`, `--verify`, `--rollback`, and `--status` commands. It is not an
|
||||
always-on writer and never enables global GitOps writes.
|
||||
|
||||
The executor is fail-closed: every consequential command is bound to an exact
|
||||
approval merge, governance OID, evidence closure, Owner review, component
|
||||
digests, full branch-protection snapshot, operation ID, and plan ID. It writes
|
||||
a fsynced WAL before the single-row SQLite CAS, verifies the full applied row,
|
||||
and only rolls back with a target-to-previous CAS. Audit records are
|
||||
hash-chained and credentials are redacted.
|
||||
|
||||
## Hardening Contract
|
||||
|
||||
Fresh activation is authorized only before the approved `expires_at` instant.
|
||||
An already-recorded `PREPARED` transaction may recover after expiry only to
|
||||
reach a safe terminal state using the same exact bindings; `--verify` and
|
||||
`--rollback` remain available for that recorded transaction after expiry.
|
||||
`ROLLED_BACK` and `FAILED` are terminal states and must never be replayed as a
|
||||
new activation.
|
||||
|
||||
The approved execution binding must pin the implementation commit and its
|
||||
exact tree, the installed hashes of the five runtime source files
|
||||
(`activation.py`, `gitea_protected_branch.py`, `journal.py`, `validator.py`,
|
||||
and `state_machine.py`), and independent SHA-256 values for the deployment
|
||||
artifact, rollback artifact, and test evidence. It must also bind an evidence
|
||||
closure that is a commit with the exact direct parent and exactly one approved
|
||||
closure file, including that file's Git blob OID and SHA-256.
|
||||
|
||||
Every required machine status is accepted only when its exact status record,
|
||||
creator identity, producer provenance, target URL, description digest, head
|
||||
OID, state, and observation time match the approved binding. A fixture status
|
||||
or a dormant self-check is not machine-status producer provenance.
|
||||
|
||||
This contract hardens validation; it does not authorize deployment. The
|
||||
existing governed approval rules still apply, and installation remains
|
||||
disabled and inactive until the required merged approval authorizes one
|
||||
explicit execution.
|
||||
|
||||
Installation must remain disabled and inactive until a merged
|
||||
`SINGLE_OWNER_GOVERNANCE_LIVE_ACTIVATION_IMPLEMENTATION_AND_EXECUTION`
|
||||
approval authorizes one explicit execution. The approved runtime paths are
|
||||
`/var/lib/aios-gitops/governance-activation/` and
|
||||
`/run/aios-gitops/governance-activation.lock`.
|
||||
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"activation_approval_merge_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"activation_approval_pr_number": {"minimum": 1, "type": "integer"},
|
||||
"activation_manifest_blob_oid": {"pattern": "^[0-9a-f]{40,64}$", "type": "string"},
|
||||
"activation_manifest_path": {"pattern": "^approvals/.+\\.json$", "type": "string"},
|
||||
"activation_manifest_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"artifact_path": {"pattern": "^/", "type": "string"},
|
||||
"artifact_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"approval_db_path": {"minLength": 1, "type": "string"},
|
||||
"approval_request_head_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"backend": {"enum": ["file", "sqlite"]},
|
||||
"backend_path": {"minLength": 1, "type": "string"},
|
||||
"branch": {"const": "governance/approvals"},
|
||||
"evidence_closure_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"evidence_closure_path": {"pattern": "^(?!/)(?!.*\\.\\.).+", "type": "string"},
|
||||
"evidence_closure_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"evidence_git_dir": {"minLength": 1, "type": "string"},
|
||||
"executor": {"not": {"const": "owner"}, "type": "string"},
|
||||
"expected_branch_protection_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"expected_gitea_schema_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"expected_governance_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"expected_state_machine_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"expected_validator_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"expires_at": {"format": "date-time", "type": "string"},
|
||||
"forbidden_operations": {"additionalProperties": {"const": false}, "type": "object"},
|
||||
"governance_git_dir": {"minLength": 1, "type": "string"},
|
||||
"governance_ref": {"const": "refs/heads/governance/approvals"},
|
||||
"implementation_commit_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"implementation_tree_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"machine_status_producer_id": {"minimum": 1, "type": "integer"},
|
||||
"machine_status_producer_name": {"minLength": 1, "type": "string"},
|
||||
"machine_checks": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"aios/branch-protection": {"$ref": "#/$defs/machineCheck"},
|
||||
"aios/evidence-closure": {"$ref": "#/$defs/machineCheck"},
|
||||
"aios/secret-scan": {"$ref": "#/$defs/machineCheck"},
|
||||
"aios/state-machine": {"$ref": "#/$defs/machineCheck"},
|
||||
"aios/validator": {"$ref": "#/$defs/machineCheck"}
|
||||
},
|
||||
"required": ["aios/branch-protection", "aios/evidence-closure", "aios/secret-scan", "aios/state-machine", "aios/validator"],
|
||||
"type": "object"
|
||||
},
|
||||
"observed_evidence_closure_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"observed_governance_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"observed_owner_review_oid": {"type": ["integer", "string"]},
|
||||
"observed_state_machine_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"observed_validator_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"operation_id": {"minLength": 1, "type": "string"},
|
||||
"owner_review_oid": {"type": ["integer", "string"]},
|
||||
"repo_id": {"minimum": 1, "type": "integer"},
|
||||
"requester": {"not": {"const": "owner"}, "type": "string"},
|
||||
"rollback_artifact_path": {"pattern": "^/", "type": "string"},
|
||||
"rollback_artifact_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"runtime_sources": {
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"git_path": {"minLength": 1, "type": "string"},
|
||||
"runtime_path": {"pattern": "^/", "type": "string"},
|
||||
"sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"}
|
||||
},
|
||||
"required": ["git_path", "runtime_path", "sha256"],
|
||||
"type": "object"
|
||||
},
|
||||
"maxItems": 5,
|
||||
"minItems": 5,
|
||||
"type": "array"
|
||||
},
|
||||
"state_machine_path": {"minLength": 1, "type": "string"},
|
||||
"target_branch_protection": {"type": "object"},
|
||||
"task_id": {"minLength": 1, "type": "string"},
|
||||
"test_evidence_path": {"pattern": "^/", "type": "string"},
|
||||
"test_evidence_sha256": {"pattern": "^[0-9a-f]{64}$", "type": "string"},
|
||||
"validator_path": {"minLength": 1, "type": "string"},
|
||||
"wip": {"const": false}
|
||||
},
|
||||
"$defs": {
|
||||
"machineCheck": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"head_oid": {"pattern": "^[0-9a-f]{40}$", "type": "string"},
|
||||
"status_id": {"minimum": 1, "type": "integer"},
|
||||
"creator_id": {"minimum": 1, "type": "integer"},
|
||||
"creator_login": {"minLength": 1, "type": "string"},
|
||||
"state": {"const": "success"}
|
||||
},
|
||||
"required": ["state", "head_oid", "status_id", "creator_id", "creator_login"],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"task_id", "operation_id", "expected_governance_oid", "observed_governance_oid",
|
||||
"expected_validator_sha256", "observed_validator_sha256", "validator_path",
|
||||
"expected_state_machine_sha256", "observed_state_machine_sha256", "state_machine_path",
|
||||
"expected_branch_protection_sha256", "expected_gitea_schema_sha256",
|
||||
"activation_approval_merge_oid", "activation_approval_pr_number", "approval_request_head_oid",
|
||||
"activation_manifest_path", "activation_manifest_blob_oid", "activation_manifest_sha256",
|
||||
"evidence_closure_oid", "observed_evidence_closure_oid", "evidence_git_dir",
|
||||
"evidence_closure_path", "evidence_closure_sha256",
|
||||
"implementation_commit_oid", "implementation_tree_oid", "runtime_sources",
|
||||
"artifact_path", "artifact_sha256", "rollback_artifact_path", "rollback_artifact_sha256",
|
||||
"test_evidence_path", "test_evidence_sha256",
|
||||
"machine_status_producer_id", "machine_status_producer_name",
|
||||
"owner_review_oid", "observed_owner_review_oid", "approval_db_path", "expires_at",
|
||||
"governance_git_dir", "governance_ref", "machine_checks", "forbidden_operations",
|
||||
"target_branch_protection", "backend", "backend_path", "repo_id", "branch",
|
||||
"requester", "executor", "wip"
|
||||
],
|
||||
"title": "AIOS Single Owner Live Activation Bindings",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Dormant single-owner governance activation prerequisites."""
|
||||
|
||||
from .models import (
|
||||
ApprovalContext,
|
||||
GovernanceState,
|
||||
MachineGate,
|
||||
ProtectionSnapshot,
|
||||
Review,
|
||||
)
|
||||
from .validator import SingleOwnerApprovalValidator, ValidationDenied
|
||||
|
||||
__all__ = [
|
||||
"ApprovalContext",
|
||||
"GovernanceState",
|
||||
"MachineGate",
|
||||
"ProtectionSnapshot",
|
||||
"Review",
|
||||
"SingleOwnerApprovalValidator",
|
||||
"ValidationDenied",
|
||||
]
|
||||
@@ -0,0 +1,860 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from typing import Any, Iterator, Mapping, Protocol
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover
|
||||
fcntl = None
|
||||
|
||||
from .gitea_protected_branch import (
|
||||
CASConflict,
|
||||
FileProtectedBranchAdapter,
|
||||
SQLiteProtectedBranchAdapter,
|
||||
calculate_snapshot_sha256,
|
||||
)
|
||||
from .journal import JournalStore, atomic_write_json, read_json, redact, sha256_json
|
||||
from . import state_machine as state_machine_module
|
||||
from . import validator as validator_module
|
||||
|
||||
|
||||
DEFAULT_STATE_ROOT = Path("/var/lib/aios-gitops/governance-activation")
|
||||
DEFAULT_LOCK_PATH = Path("/run/aios-gitops/governance-activation.lock")
|
||||
LIVE_GITEA_DB = Path("/data/aios-gitops/runtime/gitea-bin/data/gitea.db")
|
||||
LIVE_AUTHORITATIVE_GIT = Path("/data/aios-gitops/repo.git")
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
REQUIRED_BINDINGS = {
|
||||
"task_id",
|
||||
"operation_id",
|
||||
"expected_governance_oid",
|
||||
"expected_validator_sha256",
|
||||
"expected_state_machine_sha256",
|
||||
"expected_branch_protection_sha256",
|
||||
"activation_approval_merge_oid",
|
||||
"evidence_closure_oid",
|
||||
"owner_review_oid",
|
||||
"observed_governance_oid",
|
||||
"observed_validator_sha256",
|
||||
"observed_state_machine_sha256",
|
||||
"observed_evidence_closure_oid",
|
||||
"observed_owner_review_oid",
|
||||
"approval_request_head_oid",
|
||||
"activation_approval_pr_number",
|
||||
"expires_at",
|
||||
"validator_path",
|
||||
"state_machine_path",
|
||||
"governance_git_dir",
|
||||
"governance_ref",
|
||||
"evidence_git_dir",
|
||||
"approval_db_path",
|
||||
"expected_gitea_schema_sha256",
|
||||
"backend",
|
||||
"backend_path",
|
||||
"repo_id",
|
||||
"branch",
|
||||
"activation_manifest_path",
|
||||
"activation_manifest_blob_oid",
|
||||
"activation_manifest_sha256",
|
||||
"implementation_commit_oid",
|
||||
"implementation_tree_oid",
|
||||
"runtime_sources",
|
||||
"artifact_path",
|
||||
"artifact_sha256",
|
||||
"rollback_artifact_path",
|
||||
"rollback_artifact_sha256",
|
||||
"test_evidence_path",
|
||||
"test_evidence_sha256",
|
||||
"evidence_closure_path",
|
||||
"evidence_closure_sha256",
|
||||
"machine_status_producer_id",
|
||||
"machine_status_producer_name",
|
||||
}
|
||||
REQUIRED_MACHINE_CONTEXTS = {
|
||||
"aios/validator",
|
||||
"aios/state-machine",
|
||||
"aios/branch-protection",
|
||||
"aios/secret-scan",
|
||||
"aios/evidence-closure",
|
||||
}
|
||||
REQUIRED_RUNTIME_GIT_PATHS = {
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/activation.py",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/gitea_protected_branch.py",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/journal.py",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/state_machine.py",
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/validator.py",
|
||||
}
|
||||
|
||||
|
||||
class InjectedCrash(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
class Adapter(Protocol):
|
||||
def read_current(self) -> dict[str, Any]: ...
|
||||
def calculate_snapshot_sha256(self, snapshot: Mapping[str, Any]) -> str: ...
|
||||
def apply_atomic(self, expected_sha256: str, target: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||
def verify_applied(self, expected_sha256: str) -> bool: ...
|
||||
def rollback_atomic(self, expected_target_sha256: str, previous: Mapping[str, Any]) -> dict[str, Any]: ...
|
||||
def verify_rollback(self, expected_sha256: str) -> bool: ...
|
||||
|
||||
|
||||
@contextmanager
|
||||
def exclusive_lock(path: Path) -> Iterator[None]:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
flags = os.O_RDWR | os.O_CREAT
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o640)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
yield
|
||||
finally:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _require_hex(name: str, value: object, lengths: tuple[int, ...]) -> None:
|
||||
if not isinstance(value, str) or len(value) not in lengths or not re.fullmatch(r"[0-9a-f]+", value):
|
||||
raise RuntimeError(f"{name.upper()}_INVALID")
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
if not path.is_absolute() or not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError("VERIFIED_COMPONENT_PATH_INVALID")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _git(git_dir: Path, *args: str) -> str:
|
||||
if not git_dir.is_absolute() or not git_dir.exists() or git_dir.is_symlink():
|
||||
raise RuntimeError("GIT_OBJECT_STORE_INVALID")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("GIT_EVIDENCE_VERIFICATION_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git_raw(git_dir: Path, *args: str) -> bytes:
|
||||
if not git_dir.is_absolute() or not git_dir.exists() or git_dir.is_symlink():
|
||||
raise RuntimeError("GIT_OBJECT_STORE_INVALID")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args], capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("GIT_EVIDENCE_VERIFICATION_FAILED")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _parse_utc(value: object) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("EXPIRY_INVALID")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("EXPIRY_INVALID") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise RuntimeError("EXPIRY_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _approved_execution_binding(bindings: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": bindings["task_id"],
|
||||
"operation_id": bindings["operation_id"],
|
||||
"expected_validator_sha256": bindings["expected_validator_sha256"],
|
||||
"expected_state_machine_sha256": bindings["expected_state_machine_sha256"],
|
||||
"expected_branch_protection_sha256": bindings["expected_branch_protection_sha256"],
|
||||
"expected_gitea_schema_sha256": bindings["expected_gitea_schema_sha256"],
|
||||
"target_branch_protection_sha256": calculate_snapshot_sha256(
|
||||
bindings["target_branch_protection"]
|
||||
),
|
||||
"evidence_closure_oid": bindings["evidence_closure_oid"],
|
||||
"evidence_closure_path": bindings["evidence_closure_path"],
|
||||
"evidence_closure_sha256": bindings["evidence_closure_sha256"],
|
||||
"implementation_commit_oid": bindings["implementation_commit_oid"],
|
||||
"implementation_tree_oid": bindings["implementation_tree_oid"],
|
||||
"runtime_sources": bindings["runtime_sources"],
|
||||
"artifact_path": bindings["artifact_path"],
|
||||
"artifact_sha256": bindings["artifact_sha256"],
|
||||
"rollback_artifact_path": bindings["rollback_artifact_path"],
|
||||
"rollback_artifact_sha256": bindings["rollback_artifact_sha256"],
|
||||
"test_evidence_path": bindings["test_evidence_path"],
|
||||
"test_evidence_sha256": bindings["test_evidence_sha256"],
|
||||
"machine_status_producer_id": bindings["machine_status_producer_id"],
|
||||
"machine_status_producer_name": bindings["machine_status_producer_name"],
|
||||
"backend": bindings["backend"],
|
||||
"repo_id": bindings["repo_id"],
|
||||
"branch": bindings["branch"],
|
||||
"requester": bindings["requester"],
|
||||
"executor": bindings["executor"],
|
||||
"forbidden_operations": bindings["forbidden_operations"],
|
||||
}
|
||||
|
||||
|
||||
def _same_path(left: Path, right: Path) -> bool:
|
||||
try:
|
||||
return left.resolve(strict=True) == right.resolve(strict=True) and os.path.samefile(left, right)
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _verify_authority_paths(bindings: Mapping[str, Any]) -> None:
|
||||
if bindings["backend"] != "sqlite":
|
||||
return
|
||||
approval_db = Path(str(bindings["approval_db_path"]))
|
||||
backend_db = Path(str(bindings["backend_path"]))
|
||||
if not _same_path(approval_db, backend_db):
|
||||
raise RuntimeError("APPROVAL_DB_BACKEND_DB_IDENTITY_MISMATCH")
|
||||
live = _same_path(backend_db, LIVE_GITEA_DB)
|
||||
if live:
|
||||
if not _same_path(Path(str(bindings["governance_git_dir"])), LIVE_AUTHORITATIVE_GIT):
|
||||
raise RuntimeError("AUTHORITATIVE_GOVERNANCE_GIT_REQUIRED")
|
||||
if not _same_path(Path(str(bindings["evidence_git_dir"])), LIVE_AUTHORITATIVE_GIT):
|
||||
raise RuntimeError("AUTHORITATIVE_EVIDENCE_GIT_REQUIRED")
|
||||
return
|
||||
acceptance_root = os.environ.get("AIOS_GOVERNANCE_ACTIVATION_ACCEPTANCE_ROOT")
|
||||
if not acceptance_root:
|
||||
raise RuntimeError("NON_LIVE_SQLITE_BACKEND_DENIED")
|
||||
root = Path(acceptance_root).resolve(strict=True)
|
||||
if not str(root).startswith("/run/aios-live-activation-acceptance-"):
|
||||
raise RuntimeError("ACCEPTANCE_ROOT_INVALID")
|
||||
for path in (approval_db, backend_db, Path(str(bindings["governance_git_dir"])), Path(str(bindings["evidence_git_dir"]))):
|
||||
try:
|
||||
path.resolve(strict=True).relative_to(root)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
raise RuntimeError("ACCEPTANCE_AUTHORITY_ESCAPE_DENIED") from exc
|
||||
|
||||
|
||||
def _load_approved_manifest(bindings: Mapping[str, Any], git_dir: Path) -> dict[str, Any]:
|
||||
path = str(bindings["activation_manifest_path"])
|
||||
if not path.startswith("approvals/") or not path.endswith(".json") or ".." in Path(path).parts:
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_PATH_INVALID")
|
||||
merge_oid = str(bindings["activation_approval_merge_oid"])
|
||||
blob_oid = _git(git_dir, "rev-parse", f"{merge_oid}:{path}")
|
||||
if blob_oid != bindings["activation_manifest_blob_oid"]:
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_BLOB_MISMATCH")
|
||||
raw_result = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "show", f"{merge_oid}:{path}"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if raw_result.returncode != 0:
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_READ_FAILED")
|
||||
if hashlib.sha256(raw_result.stdout).hexdigest() != bindings["activation_manifest_sha256"]:
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_SHA256_MISMATCH")
|
||||
try:
|
||||
manifest = json.loads(raw_result.stdout)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_INVALID") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise RuntimeError("ACTIVATION_MANIFEST_INVALID")
|
||||
return manifest
|
||||
|
||||
|
||||
def _require_approval_unexpired(bindings: Mapping[str, Any]) -> None:
|
||||
if _utc_now() >= _parse_utc(bindings["expires_at"]):
|
||||
raise RuntimeError("APPROVAL_EXPIRED")
|
||||
|
||||
|
||||
def _safe_git_path(value: object) -> str:
|
||||
path = str(value)
|
||||
if not path or Path(path).is_absolute() or ".." in Path(path).parts:
|
||||
raise RuntimeError("APPROVED_GIT_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def _verify_approved_entities(bindings: Mapping[str, Any], git_dir: Path) -> None:
|
||||
implementation_oid = str(bindings["implementation_commit_oid"])
|
||||
_git(git_dir, "cat-file", "-e", f"{implementation_oid}^{{commit}}")
|
||||
if _git(git_dir, "rev-parse", f"{implementation_oid}^{{tree}}") != bindings["implementation_tree_oid"]:
|
||||
raise RuntimeError("IMPLEMENTATION_TREE_MISMATCH")
|
||||
sources = bindings["runtime_sources"]
|
||||
if not isinstance(sources, list) or not sources:
|
||||
raise RuntimeError("RUNTIME_SOURCES_INVALID")
|
||||
seen_git: set[str] = set()
|
||||
seen_runtime: set[str] = set()
|
||||
loaded_runtime_paths = {
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/activation.py": Path(__file__).resolve(),
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/gitea_protected_branch.py": Path(__import__(SQLiteProtectedBranchAdapter.__module__, fromlist=["x"]).__file__).resolve(),
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/journal.py": Path(__import__(JournalStore.__module__, fromlist=["x"]).__file__).resolve(),
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/state_machine.py": Path(state_machine_module.__file__).resolve(),
|
||||
"services/aios-single-owner-governance/aios_single_owner_governance/validator.py": Path(validator_module.__file__).resolve(),
|
||||
}
|
||||
for source in sources:
|
||||
if not isinstance(source, dict) or set(source) != {"git_path", "runtime_path", "sha256"}:
|
||||
raise RuntimeError("RUNTIME_SOURCE_BINDING_INVALID")
|
||||
git_path = _safe_git_path(source["git_path"])
|
||||
runtime_path = str(source["runtime_path"])
|
||||
_require_hex("runtime_source_sha256", source["sha256"], (64,))
|
||||
if git_path in seen_git or runtime_path in seen_runtime:
|
||||
raise RuntimeError("RUNTIME_SOURCE_BINDING_DUPLICATE")
|
||||
seen_git.add(git_path)
|
||||
seen_runtime.add(runtime_path)
|
||||
if Path(runtime_path).resolve() != loaded_runtime_paths.get(git_path):
|
||||
raise RuntimeError("RUNTIME_SOURCE_PATH_MISMATCH")
|
||||
if hashlib.sha256(_git_raw(git_dir, "show", f"{implementation_oid}:{git_path}")).hexdigest() != source["sha256"]:
|
||||
raise RuntimeError("IMPLEMENTATION_SOURCE_DIGEST_MISMATCH")
|
||||
if _sha256_file(Path(runtime_path)) != source["sha256"]:
|
||||
raise RuntimeError("RUNTIME_SOURCE_DIGEST_MISMATCH")
|
||||
if seen_git != REQUIRED_RUNTIME_GIT_PATHS:
|
||||
raise RuntimeError("RUNTIME_SOURCE_SET_MISMATCH")
|
||||
for prefix in ("artifact", "rollback_artifact", "test_evidence"):
|
||||
if _sha256_file(Path(str(bindings[f"{prefix}_path"]))) != bindings[f"{prefix}_sha256"]:
|
||||
raise RuntimeError("APPROVED_ENTITY_DIGEST_MISMATCH")
|
||||
|
||||
closure_oid = str(bindings["evidence_closure_oid"])
|
||||
if _git(git_dir, "cat-file", "-t", closure_oid) != "commit":
|
||||
raise RuntimeError("EVIDENCE_CLOSURE_NOT_COMMIT")
|
||||
parents = _git(git_dir, "rev-list", "--parents", "-n", "1", closure_oid).split()
|
||||
if parents != [closure_oid, implementation_oid]:
|
||||
raise RuntimeError("EVIDENCE_CLOSURE_PARENT_MISMATCH")
|
||||
closure_path = _safe_git_path(bindings["evidence_closure_path"])
|
||||
if hashlib.sha256(_git_raw(git_dir, "show", f"{closure_oid}:{closure_path}")).hexdigest() != bindings["evidence_closure_sha256"]:
|
||||
raise RuntimeError("EVIDENCE_CLOSURE_SHA256_MISMATCH")
|
||||
if _git(git_dir, "diff", "--name-status", implementation_oid, closure_oid) != f"A\t{closure_path}":
|
||||
raise RuntimeError("EVIDENCE_CLOSURE_DIFF_INVALID")
|
||||
|
||||
|
||||
def _verify_external_evidence(
|
||||
bindings: Mapping[str, Any], *, require_unexpired: bool = True
|
||||
) -> None:
|
||||
_verify_authority_paths(bindings)
|
||||
validator_sha = _sha256_file(Path(str(bindings["validator_path"])))
|
||||
state_machine_sha = _sha256_file(Path(str(bindings["state_machine_path"])))
|
||||
if validator_sha != bindings["expected_validator_sha256"] or validator_sha != bindings["observed_validator_sha256"]:
|
||||
raise RuntimeError("VALIDATOR_DIGEST_MISMATCH")
|
||||
if state_machine_sha != bindings["expected_state_machine_sha256"] or state_machine_sha != bindings["observed_state_machine_sha256"]:
|
||||
raise RuntimeError("STATE_MACHINE_DIGEST_MISMATCH")
|
||||
|
||||
governance_git_dir = Path(str(bindings["governance_git_dir"]))
|
||||
governance_oid = _git(governance_git_dir, "rev-parse", "--verify", str(bindings["governance_ref"]))
|
||||
if governance_oid != bindings["expected_governance_oid"] or governance_oid != bindings["observed_governance_oid"]:
|
||||
raise RuntimeError("WRONG_GOVERNANCE_OID_DENIED")
|
||||
merge_oid = str(bindings["activation_approval_merge_oid"])
|
||||
_git(governance_git_dir, "cat-file", "-e", f"{merge_oid}^{{commit}}")
|
||||
if subprocess.run(
|
||||
["git", f"--git-dir={governance_git_dir}", "merge-base", "--is-ancestor", merge_oid, governance_oid],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
raise RuntimeError("ACTIVATION_APPROVAL_NOT_IN_GOVERNANCE_REF")
|
||||
manifest = _load_approved_manifest(bindings, governance_git_dir)
|
||||
if manifest.get("approval_type") != "SINGLE_OWNER_GOVERNANCE_LIVE_ACTIVATION_IMPLEMENTATION_AND_EXECUTION":
|
||||
raise RuntimeError("ACTIVATION_APPROVAL_TYPE_MISMATCH")
|
||||
if manifest.get("execution_binding") != _approved_execution_binding(bindings):
|
||||
raise RuntimeError("APPROVED_EXECUTION_BINDING_MISMATCH")
|
||||
if manifest.get("execution_binding_sha256") != sha256_json(manifest["execution_binding"]):
|
||||
raise RuntimeError("APPROVED_EXECUTION_BINDING_SHA256_MISMATCH")
|
||||
if manifest.get("expires_at") != bindings["expires_at"]:
|
||||
raise RuntimeError("APPROVAL_EXPIRY_BINDING_MISMATCH")
|
||||
|
||||
manifest_bound_fields = (
|
||||
"implementation_commit_oid", "implementation_tree_oid", "runtime_sources",
|
||||
"artifact_path", "artifact_sha256", "rollback_artifact_path",
|
||||
"rollback_artifact_sha256", "test_evidence_path", "test_evidence_sha256",
|
||||
"evidence_closure_oid", "evidence_closure_path", "evidence_closure_sha256",
|
||||
"machine_status_producer_id", "machine_status_producer_name",
|
||||
)
|
||||
if any(manifest.get(field) != bindings[field] for field in manifest_bound_fields):
|
||||
raise RuntimeError("APPROVED_ARTIFACT_BINDING_MISMATCH")
|
||||
|
||||
evidence_git_dir = Path(str(bindings["evidence_git_dir"]))
|
||||
evidence_oid = str(bindings["evidence_closure_oid"])
|
||||
if evidence_oid != bindings["observed_evidence_closure_oid"]:
|
||||
raise RuntimeError("WRONG_EVIDENCE_CLOSURE_DENIED")
|
||||
_verify_approved_entities(bindings, evidence_git_dir)
|
||||
|
||||
db_path = Path(str(bindings["approval_db_path"]))
|
||||
if not db_path.is_absolute() or not db_path.is_file() or db_path.is_symlink():
|
||||
raise RuntimeError("APPROVAL_DATABASE_INVALID")
|
||||
expiry = _parse_utc(bindings["expires_at"])
|
||||
if require_unexpired:
|
||||
_require_approval_unexpired(bindings)
|
||||
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
pr = connection.execute(
|
||||
"SELECT pr.issue_id,pr.has_merged,pr.merged_commit_id,pr.merged_unix,i.is_closed "
|
||||
"FROM pull_request pr JOIN issue i ON i.id=pr.issue_id "
|
||||
"WHERE i.repo_id=? AND pr.\"index\"=? AND pr.base_branch=?",
|
||||
(int(bindings["repo_id"]), int(bindings["activation_approval_pr_number"]), str(bindings["branch"])),
|
||||
).fetchone()
|
||||
if pr is None or int(pr["has_merged"]) != 1 or int(pr["is_closed"]) != 1:
|
||||
raise RuntimeError("ACTIVATION_APPROVAL_PR_NOT_MERGED")
|
||||
if pr["merged_commit_id"] != bindings["activation_approval_merge_oid"]:
|
||||
raise RuntimeError("ACTIVATION_APPROVAL_MERGE_MISMATCH")
|
||||
if int(pr["merged_unix"] or 0) <= 0 or datetime.fromtimestamp(int(pr["merged_unix"]), timezone.utc) > expiry:
|
||||
raise RuntimeError("ACTIVATION_APPROVAL_MERGED_AFTER_EXPIRY")
|
||||
review = connection.execute(
|
||||
"SELECT r.id,r.type,r.reviewer_id,r.commit_id,r.stale,r.dismissed,r.created_unix,u.lower_name "
|
||||
"FROM review r JOIN user u ON u.id=r.reviewer_id WHERE r.id=? AND r.issue_id=?",
|
||||
(int(bindings["owner_review_oid"]), int(pr["issue_id"])),
|
||||
).fetchone()
|
||||
if review is None or int(review["id"]) != int(bindings["observed_owner_review_oid"]):
|
||||
raise RuntimeError("WRONG_APPROVAL_OID_DENIED")
|
||||
if (
|
||||
int(review["type"]) != 1
|
||||
or int(review["reviewer_id"]) != 2
|
||||
or review["lower_name"] != "owner"
|
||||
or review["commit_id"] != bindings["approval_request_head_oid"]
|
||||
or int(review["stale"]) != 0
|
||||
or int(review["dismissed"]) != 0
|
||||
or datetime.fromtimestamp(int(review["created_unix"]), timezone.utc) >= expiry
|
||||
):
|
||||
raise RuntimeError("OWNER_APPROVAL_INVALID")
|
||||
statuses = connection.execute(
|
||||
"SELECT cs.id,cs.context,cs.state,cs.sha,cs.creator_id,u.lower_name AS creator_login "
|
||||
"FROM commit_status cs LEFT JOIN user u ON u.id=cs.creator_id "
|
||||
"WHERE cs.repo_id=? AND cs.sha=? ORDER BY cs.id DESC",
|
||||
(int(bindings["repo_id"]), str(bindings["approval_request_head_oid"])),
|
||||
).fetchall()
|
||||
observed: dict[str, sqlite3.Row] = {}
|
||||
for row in statuses:
|
||||
if row["context"] in REQUIRED_MACHINE_CONTEXTS:
|
||||
observed.setdefault(row["context"], row)
|
||||
if set(observed) != REQUIRED_MACHINE_CONTEXTS:
|
||||
raise RuntimeError("MACHINE_GATE_SET_MISMATCH")
|
||||
for context, row in observed.items():
|
||||
if row["state"] != "success" or row["sha"] != bindings["approval_request_head_oid"]:
|
||||
raise RuntimeError("MACHINE_GATE_FAILED")
|
||||
if (
|
||||
int(row["creator_id"] or 0) != int(bindings["machine_status_producer_id"])
|
||||
or row["creator_login"] != bindings["machine_status_producer_name"]
|
||||
):
|
||||
raise RuntimeError("MACHINE_GATE_PRODUCER_MISMATCH")
|
||||
expected = bindings["machine_checks"][context]
|
||||
actual = {
|
||||
"state": row["state"], "head_oid": row["sha"], "status_id": int(row["id"]),
|
||||
"creator_id": int(row["creator_id"]), "creator_login": row["creator_login"],
|
||||
}
|
||||
if expected != actual:
|
||||
raise RuntimeError("MACHINE_GATE_BINDING_MISMATCH")
|
||||
|
||||
|
||||
def validate_bindings(
|
||||
bindings: Mapping[str, Any], *, require_unexpired: bool = True
|
||||
) -> None:
|
||||
missing = sorted(REQUIRED_BINDINGS - set(bindings))
|
||||
if missing:
|
||||
raise RuntimeError("MISSING_BINDINGS:" + ",".join(missing))
|
||||
for name in (
|
||||
"expected_governance_oid",
|
||||
"activation_approval_merge_oid",
|
||||
"evidence_closure_oid",
|
||||
"implementation_commit_oid",
|
||||
"implementation_tree_oid",
|
||||
):
|
||||
_require_hex(name, bindings[name], (40,))
|
||||
for name in (
|
||||
"expected_validator_sha256",
|
||||
"expected_state_machine_sha256",
|
||||
"expected_branch_protection_sha256",
|
||||
"observed_validator_sha256",
|
||||
"observed_state_machine_sha256",
|
||||
"expected_gitea_schema_sha256",
|
||||
"activation_manifest_sha256",
|
||||
"artifact_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"test_evidence_sha256",
|
||||
"evidence_closure_sha256",
|
||||
):
|
||||
_require_hex(name, bindings[name], (64,))
|
||||
_require_hex("activation_manifest_blob_oid", bindings["activation_manifest_blob_oid"], (40, 64))
|
||||
if not isinstance(bindings["owner_review_oid"], (int, str)) or str(bindings["owner_review_oid"]).strip() == "":
|
||||
raise RuntimeError("OWNER_REVIEW_OID_INVALID")
|
||||
if bindings["governance_ref"] != "refs/heads/governance/approvals":
|
||||
raise RuntimeError("GOVERNANCE_REF_INVALID")
|
||||
if bindings["branch"] != "governance/approvals":
|
||||
raise RuntimeError("BRANCH_BINDING_DENIED")
|
||||
if bindings["backend"] not in {"file", "sqlite"}:
|
||||
raise RuntimeError("UNKNOWN_PROTECTION_BACKEND")
|
||||
if bindings["observed_governance_oid"] != bindings["expected_governance_oid"]:
|
||||
raise RuntimeError("WRONG_GOVERNANCE_OID_DENIED")
|
||||
if bindings["observed_evidence_closure_oid"] != bindings["evidence_closure_oid"]:
|
||||
raise RuntimeError("WRONG_EVIDENCE_CLOSURE_DENIED")
|
||||
if str(bindings["observed_owner_review_oid"]) != str(bindings["owner_review_oid"]):
|
||||
raise RuntimeError("WRONG_APPROVAL_OID_DENIED")
|
||||
if bindings.get("requester") == "owner":
|
||||
raise RuntimeError("REQUESTER_SELF_APPROVAL_DENIED")
|
||||
if bindings.get("executor") == "owner":
|
||||
raise RuntimeError("EXECUTOR_SELF_APPROVAL_DENIED")
|
||||
if bindings.get("wip") is not False:
|
||||
raise RuntimeError("WIP_BLOCKED")
|
||||
checks = bindings.get("machine_checks")
|
||||
if not isinstance(checks, dict) or set(checks) != REQUIRED_MACHINE_CONTEXTS:
|
||||
raise RuntimeError("MACHINE_GATE_SET_MISMATCH")
|
||||
if not isinstance(bindings["machine_status_producer_id"], int) or bindings["machine_status_producer_id"] < 1:
|
||||
raise RuntimeError("MACHINE_STATUS_PRODUCER_INVALID")
|
||||
if not isinstance(bindings["machine_status_producer_name"], str) or not bindings["machine_status_producer_name"]:
|
||||
raise RuntimeError("MACHINE_STATUS_PRODUCER_INVALID")
|
||||
for context, check in checks.items():
|
||||
if (
|
||||
not isinstance(check, dict)
|
||||
or set(check) != {"state", "head_oid", "status_id", "creator_id", "creator_login"}
|
||||
or check.get("state") != "success"
|
||||
or check.get("head_oid") != bindings["approval_request_head_oid"]
|
||||
or not isinstance(check.get("status_id"), int)
|
||||
or check.get("status_id") < 1
|
||||
or check.get("creator_id") != bindings["machine_status_producer_id"]
|
||||
or check.get("creator_login") != bindings["machine_status_producer_name"]
|
||||
):
|
||||
raise RuntimeError(f"MACHINE_GATE_FAILED:{context}")
|
||||
forbidden = bindings.get("forbidden_operations", {})
|
||||
if any(bool(value) for value in forbidden.values()):
|
||||
raise RuntimeError("FORBIDDEN_OPERATION_REQUESTED")
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise RuntimeError("GLOBAL_GITOPS_WRITES_MUST_REMAIN_FALSE")
|
||||
if not isinstance(bindings.get("target_branch_protection"), dict):
|
||||
raise RuntimeError("TARGET_BRANCH_PROTECTION_REQUIRED")
|
||||
_verify_external_evidence(bindings, require_unexpired=require_unexpired)
|
||||
|
||||
|
||||
def binding_payload_hash(bindings: Mapping[str, Any]) -> str:
|
||||
stable = dict(bindings)
|
||||
stable.pop("crash_at", None)
|
||||
return sha256_json(redact(stable))
|
||||
|
||||
|
||||
def plan_id(bindings: Mapping[str, Any], previous: Mapping[str, Any], target: Mapping[str, Any]) -> str:
|
||||
stable = dict(bindings)
|
||||
stable.pop("crash_at", None)
|
||||
return sha256_json(
|
||||
{
|
||||
"bindings": redact(stable),
|
||||
"previous": dict(previous),
|
||||
"target": dict(target),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ActivationEngine:
|
||||
def __init__(self, adapter: Adapter, state_root: Path, lock_path: Path) -> None:
|
||||
self.adapter = adapter
|
||||
self.journal = JournalStore(state_root)
|
||||
self.lock_path = lock_path
|
||||
|
||||
def plan(self, bindings: Mapping[str, Any]) -> dict[str, Any]:
|
||||
validate_bindings(bindings)
|
||||
previous = self.adapter.read_current()
|
||||
previous_sha = calculate_snapshot_sha256(previous)
|
||||
if previous_sha != bindings["expected_branch_protection_sha256"]:
|
||||
raise CASConflict("EXPECTED_BRANCH_PROTECTION_SHA256_MISMATCH")
|
||||
target = dict(bindings["target_branch_protection"])
|
||||
# Validate without writing by using the public target validator through a
|
||||
# disposable comparison: adapters enforce the same checks again in CAS.
|
||||
from .gitea_protected_branch import validate_target
|
||||
|
||||
validate_target(previous, target)
|
||||
result = {
|
||||
"status": "PLAN_READY",
|
||||
"task_id": bindings["task_id"],
|
||||
"operation_id": bindings["operation_id"],
|
||||
"plan_id": plan_id(bindings, previous, target),
|
||||
"payload_hash": binding_payload_hash(bindings),
|
||||
"previous_sha256": previous_sha,
|
||||
"target_sha256": calculate_snapshot_sha256(target),
|
||||
}
|
||||
return result
|
||||
|
||||
def _recover(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
current = self.adapter.read_current()
|
||||
current_sha = calculate_snapshot_sha256(current)
|
||||
previous_sha = record["previous_sha256"]
|
||||
target_sha = record["target_sha256"]
|
||||
if record["phase"] == "PREPARED":
|
||||
if current_sha == previous_sha:
|
||||
return {**record, "recovery": "RESUME_BEFORE_APPLY"}
|
||||
if current_sha == target_sha:
|
||||
record = {**record, "phase": "APPLIED", "recovery": "RESUME_AFTER_APPLY"}
|
||||
self.journal.write(record["operation_id"], record)
|
||||
return record
|
||||
raise RuntimeError("MANUAL_INTERVENTION_REQUIRED")
|
||||
if record["phase"] in {"APPLIED", "COMMITTED"} and current_sha not in {target_sha, previous_sha}:
|
||||
raise RuntimeError("MANUAL_INTERVENTION_REQUIRED")
|
||||
return record
|
||||
|
||||
def _ensure_closure(self, record: dict[str, Any], event: str) -> None:
|
||||
expected_sha = (
|
||||
record["target_sha256"] if record["phase"] == "COMMITTED" else record["previous_sha256"]
|
||||
)
|
||||
if calculate_snapshot_sha256(self.adapter.read_current()) != expected_sha:
|
||||
raise RuntimeError("MANUAL_INTERVENTION_REQUIRED")
|
||||
atomic_write_json(
|
||||
self.journal.root / "current-snapshot.json",
|
||||
record["target"] if record["phase"] == "COMMITTED" else record["previous"],
|
||||
)
|
||||
atomic_write_json(self.journal.root / "rollback-snapshot.json", record["previous"])
|
||||
self.journal.write_receipt(record["operation_id"], record)
|
||||
if not self.journal.audit.has_event(record["operation_id"], event):
|
||||
payload = {"event": event}
|
||||
if event == "SINGLE_OWNER_ACTIVATION_COMMITTED":
|
||||
payload.update(
|
||||
payload_hash=record["payload_hash"],
|
||||
target_sha256=record["target_sha256"],
|
||||
)
|
||||
self.journal.audit.append(record["operation_id"], payload)
|
||||
atomic_write_json(self.journal.root / "state.json", record)
|
||||
|
||||
def activate(self, bindings: Mapping[str, Any], supplied_plan_id: str) -> dict[str, Any]:
|
||||
with exclusive_lock(self.lock_path):
|
||||
self.journal.audit.verify()
|
||||
operation_id = str(bindings["operation_id"])
|
||||
payload_hash = binding_payload_hash(bindings)
|
||||
record = self.journal.load(operation_id)
|
||||
if record:
|
||||
if record.get("payload_hash") != payload_hash:
|
||||
raise RuntimeError("OPERATION_ID_PAYLOAD_MISMATCH_DENIED")
|
||||
if record.get("plan_id") != supplied_plan_id:
|
||||
raise RuntimeError("PLAN_ID_MISMATCH")
|
||||
if record.get("phase") == "ROLLED_BACK":
|
||||
raise RuntimeError("OPERATION_TERMINAL_ROLLED_BACK")
|
||||
if record.get("phase") == "FAILED":
|
||||
raise RuntimeError("OPERATION_TERMINAL_FAILED")
|
||||
prepared_at = _parse_utc(record.get("prepared_at"))
|
||||
if prepared_at >= _parse_utc(bindings["expires_at"]):
|
||||
raise RuntimeError("RECOVERY_AUTHORIZATION_INVALID")
|
||||
record = self._recover(record)
|
||||
if record["phase"] == "COMMITTED":
|
||||
self._ensure_closure(record, "SINGLE_OWNER_ACTIVATION_COMMITTED")
|
||||
return {"status": "ALREADY_COMMITTED", **record}
|
||||
else:
|
||||
validate_bindings(bindings)
|
||||
planned = self.plan(bindings)
|
||||
if supplied_plan_id != planned["plan_id"]:
|
||||
raise RuntimeError("PLAN_ID_MISMATCH")
|
||||
previous = self.adapter.read_current()
|
||||
target = dict(bindings["target_branch_protection"])
|
||||
if record is None:
|
||||
if calculate_snapshot_sha256(previous) != planned["previous_sha256"]:
|
||||
raise CASConflict("PREPARED_BOUNDARY_SNAPSHOT_CHANGED")
|
||||
prepared_at = _utc_now()
|
||||
if prepared_at >= _parse_utc(bindings["expires_at"]):
|
||||
raise RuntimeError("APPROVAL_EXPIRED")
|
||||
record = {
|
||||
"task_id": bindings["task_id"],
|
||||
"operation_id": operation_id,
|
||||
"transaction_id": operation_id,
|
||||
"payload_hash": payload_hash,
|
||||
"plan_id": supplied_plan_id,
|
||||
"phase": "PREPARED",
|
||||
"prepared_at": prepared_at.isoformat().replace("+00:00", "Z"),
|
||||
"previous": previous,
|
||||
"target": target,
|
||||
"previous_sha256": calculate_snapshot_sha256(previous),
|
||||
"target_sha256": calculate_snapshot_sha256(target),
|
||||
"activation_approval_merge_oid": bindings["activation_approval_merge_oid"],
|
||||
"evidence_closure_oid": bindings["evidence_closure_oid"],
|
||||
"owner_review_oid": bindings["owner_review_oid"],
|
||||
}
|
||||
self.journal.write(operation_id, record)
|
||||
atomic_write_json(self.journal.root / "current-snapshot.json", previous)
|
||||
atomic_write_json(self.journal.root / "rollback-snapshot.json", previous)
|
||||
crash_at = bindings.get("crash_at")
|
||||
if crash_at == "before_apply":
|
||||
raise InjectedCrash("before_apply")
|
||||
try:
|
||||
current_sha = calculate_snapshot_sha256(self.adapter.read_current())
|
||||
if current_sha == record["previous_sha256"]:
|
||||
applied = self.adapter.apply_atomic(record["previous_sha256"], record["target"])
|
||||
record["target"] = applied
|
||||
record["target_sha256"] = calculate_snapshot_sha256(applied)
|
||||
elif current_sha != record["target_sha256"]:
|
||||
raise CASConflict("CAS_CONFLICT")
|
||||
if crash_at == "during_apply":
|
||||
raise InjectedCrash("during_apply")
|
||||
record["phase"] = "APPLIED"
|
||||
self.journal.write(operation_id, record)
|
||||
if crash_at == "after_apply_before_receipt":
|
||||
raise InjectedCrash("after_apply_before_receipt")
|
||||
if not self.adapter.verify_applied(record["target_sha256"]):
|
||||
raise RuntimeError("POST_ACTIVATION_VERIFY_FAILED")
|
||||
record["phase"] = "COMMITTED"
|
||||
record["single_owner_policy_active"] = True
|
||||
record["break_glass_active"] = False
|
||||
self.journal.write(operation_id, record)
|
||||
if crash_at == "after_commit_wal":
|
||||
raise InjectedCrash("after_commit_wal")
|
||||
self._ensure_closure(record, "SINGLE_OWNER_ACTIVATION_COMMITTED")
|
||||
return {"status": "ACTIVATED", **record}
|
||||
except InjectedCrash:
|
||||
raise
|
||||
except Exception as exc:
|
||||
failure = {**record, "phase": "FAILED", "error": type(exc).__name__}
|
||||
current_sha = calculate_snapshot_sha256(self.adapter.read_current())
|
||||
if current_sha == record["target_sha256"]:
|
||||
self.adapter.rollback_atomic(record["target_sha256"], record["previous"])
|
||||
if not self.adapter.verify_rollback(record["previous_sha256"]):
|
||||
raise RuntimeError("ROLLBACK_VERIFY_FAILED") from exc
|
||||
failure["phase"] = "ROLLED_BACK"
|
||||
self.journal.write(operation_id, failure)
|
||||
self.journal.write_receipt(operation_id, failure)
|
||||
self.journal.audit.append(operation_id, {"event": failure["phase"], "error": type(exc).__name__})
|
||||
raise
|
||||
|
||||
def verify(self, bindings: Mapping[str, Any], operation_id: str) -> dict[str, Any]:
|
||||
record = self.journal.load(operation_id)
|
||||
if not record:
|
||||
raise RuntimeError("TRANSACTION_NOT_FOUND")
|
||||
if record.get("payload_hash") != binding_payload_hash(bindings):
|
||||
raise RuntimeError("OPERATION_ID_PAYLOAD_MISMATCH_DENIED")
|
||||
self.journal.audit.verify()
|
||||
current_sha = calculate_snapshot_sha256(self.adapter.read_current())
|
||||
passed = record.get("phase") == "COMMITTED" and current_sha == record.get("target_sha256")
|
||||
if not passed:
|
||||
raise RuntimeError("ACTIVATION_VERIFY_FAILED")
|
||||
return {"status": "VERIFIED", "operation_id": operation_id, "target_sha256": current_sha}
|
||||
|
||||
def rollback(
|
||||
self, bindings: Mapping[str, Any], operation_id: str, supplied_plan_id: str
|
||||
) -> dict[str, Any]:
|
||||
with exclusive_lock(self.lock_path):
|
||||
self.journal.audit.verify()
|
||||
record = self.journal.load(operation_id)
|
||||
if not record:
|
||||
raise RuntimeError("TRANSACTION_NOT_FOUND")
|
||||
if record.get("payload_hash") != binding_payload_hash(bindings):
|
||||
raise RuntimeError("OPERATION_ID_PAYLOAD_MISMATCH_DENIED")
|
||||
if record.get("plan_id") != supplied_plan_id:
|
||||
raise RuntimeError("PLAN_ID_MISMATCH")
|
||||
for field in (
|
||||
"activation_approval_merge_oid",
|
||||
"evidence_closure_oid",
|
||||
"owner_review_oid",
|
||||
):
|
||||
if str(record.get(field)) != str(bindings[field]):
|
||||
raise RuntimeError("ROLLBACK_AUTHORIZATION_MISMATCH_DENIED")
|
||||
current_sha = calculate_snapshot_sha256(self.adapter.read_current())
|
||||
if current_sha == record["previous_sha256"]:
|
||||
record["phase"] = "ROLLED_BACK"
|
||||
record["single_owner_policy_active"] = False
|
||||
self.journal.write(operation_id, record)
|
||||
self._ensure_closure(record, "ACTIVATION_ROLLED_BACK")
|
||||
return {"status": "ALREADY_ROLLED_BACK", "operation_id": operation_id}
|
||||
if current_sha != record["target_sha256"]:
|
||||
raise RuntimeError("MANUAL_INTERVENTION_REQUIRED")
|
||||
self.adapter.rollback_atomic(record["target_sha256"], record["previous"])
|
||||
if not self.adapter.verify_rollback(record["previous_sha256"]):
|
||||
raise RuntimeError("ROLLBACK_VERIFY_FAILED")
|
||||
if bindings.get("crash_at") == "after_rollback_cas":
|
||||
raise InjectedCrash("after_rollback_cas")
|
||||
record["phase"] = "ROLLED_BACK"
|
||||
record["single_owner_policy_active"] = False
|
||||
self.journal.write(operation_id, record)
|
||||
if bindings.get("crash_at") == "after_rollback_wal":
|
||||
raise InjectedCrash("after_rollback_wal")
|
||||
self._ensure_closure(record, "ACTIVATION_ROLLED_BACK")
|
||||
return {"status": "ROLLED_BACK", "operation_id": operation_id}
|
||||
|
||||
def status(self, operation_id: str | None = None) -> dict[str, Any]:
|
||||
audit_head = self.journal.audit.verify()
|
||||
if operation_id:
|
||||
record = self.journal.load(operation_id)
|
||||
if not record:
|
||||
raise RuntimeError("TRANSACTION_NOT_FOUND")
|
||||
return {"status": "OK", "audit_head": audit_head, "transaction": record}
|
||||
state_path = self.journal.root / "state.json"
|
||||
return {
|
||||
"status": "OK",
|
||||
"audit_head": audit_head,
|
||||
"state": read_json(state_path) if state_path.exists() else None,
|
||||
"transactions": list(self.journal.iter_transactions()),
|
||||
}
|
||||
|
||||
|
||||
def load_bindings(path: Path) -> dict[str, Any]:
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def adapter_from_bindings(bindings: Mapping[str, Any]) -> Adapter:
|
||||
backend = bindings.get("backend", "file")
|
||||
backend_path = Path(str(bindings.get("backend_path", "")))
|
||||
if not backend_path.is_absolute():
|
||||
raise RuntimeError("BACKEND_PATH_MUST_BE_ABSOLUTE")
|
||||
if backend == "file":
|
||||
return FileProtectedBranchAdapter(backend_path)
|
||||
if backend == "sqlite":
|
||||
return SQLiteProtectedBranchAdapter(
|
||||
backend_path,
|
||||
repo_id=int(bindings.get("repo_id", 1)),
|
||||
branch=str(bindings.get("branch", "governance/approvals")),
|
||||
expected_schema_sha256=str(bindings["expected_gitea_schema_sha256"]),
|
||||
)
|
||||
raise RuntimeError("UNKNOWN_PROTECTION_BACKEND")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
for name in ("plan", "activate", "verify", "rollback", "status"):
|
||||
action.add_argument(f"--{name}", action="store_true")
|
||||
parser.add_argument("--bindings", type=Path)
|
||||
parser.add_argument("--plan-id")
|
||||
parser.add_argument("--transaction")
|
||||
parser.add_argument("--state-root", type=Path, default=DEFAULT_STATE_ROOT)
|
||||
parser.add_argument("--lock-path", type=Path, default=DEFAULT_LOCK_PATH)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.status and args.bindings is None:
|
||||
# Status without bindings is intentionally limited to durable local state.
|
||||
journal = JournalStore(args.state_root)
|
||||
result = {
|
||||
"status": "OK",
|
||||
"audit_head": journal.audit.verify(),
|
||||
"transactions": list(journal.iter_transactions()),
|
||||
}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
if args.bindings is None:
|
||||
raise SystemExit("--bindings is required")
|
||||
bindings = load_bindings(args.bindings)
|
||||
engine = ActivationEngine(adapter_from_bindings(bindings), args.state_root, args.lock_path)
|
||||
if args.plan:
|
||||
result = engine.plan(bindings)
|
||||
elif args.activate:
|
||||
if not args.plan_id:
|
||||
raise SystemExit("--plan-id is required")
|
||||
result = engine.activate(bindings, args.plan_id)
|
||||
elif args.verify:
|
||||
result = engine.verify(bindings, args.transaction or str(bindings["operation_id"]))
|
||||
elif args.rollback:
|
||||
if not args.plan_id:
|
||||
raise SystemExit("--plan-id is required")
|
||||
result = engine.rollback(
|
||||
bindings, args.transaction or str(bindings["operation_id"]), args.plan_id
|
||||
)
|
||||
else:
|
||||
result = engine.status(args.transaction)
|
||||
print(json.dumps(redact(result), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceClosure:
|
||||
evidence: Mapping[str, str]
|
||||
closure_sha256: str
|
||||
|
||||
|
||||
class ActivationEvidenceClosureBuilder:
|
||||
def build(self, evidence: Mapping[str, str]) -> EvidenceClosure:
|
||||
required = {
|
||||
"activation_prerequisites_commit_oid",
|
||||
"activation_prerequisites_tree_oid",
|
||||
"artifact_sha256",
|
||||
"rollback_artifact_sha256",
|
||||
"validator_sha256",
|
||||
"state_machine_sha256",
|
||||
"branch_protection_adapter_sha256",
|
||||
"rollback_configuration_sha256",
|
||||
"test_evidence_sha256",
|
||||
"deployment_manifest_sha256",
|
||||
"dormant_unit_manifest_sha256",
|
||||
"dormant_deployment_evidence_sha256",
|
||||
}
|
||||
if set(evidence) != required:
|
||||
raise ValueError("evidence set mismatch")
|
||||
for value in evidence.values():
|
||||
if len(value) != 40 and len(value) != 64:
|
||||
raise ValueError("invalid evidence digest")
|
||||
return EvidenceClosure(
|
||||
dict(evidence), hashlib.sha256(canonical_json(evidence)).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
class ApprovalMirrorStatusVerifier:
|
||||
def verify(
|
||||
self,
|
||||
expected_oid: str,
|
||||
*,
|
||||
gitea_oid: str,
|
||||
quarantine_oid: str,
|
||||
authoritative_oid: str,
|
||||
controller_visible_oid: str,
|
||||
fsck_connectivity: str,
|
||||
) -> bool:
|
||||
return (
|
||||
len(expected_oid) == 40
|
||||
and {gitea_oid, quarantine_oid, authoritative_oid, controller_visible_oid}
|
||||
== {expected_oid}
|
||||
and fsck_connectivity == "PASS"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .models import ProtectionSnapshot
|
||||
from .protection import MemoryProtectionStore
|
||||
|
||||
|
||||
class DisposableGiteaFixture(AbstractContextManager):
|
||||
def __init__(self, initial: ProtectionSnapshot) -> None:
|
||||
self.root = Path(tempfile.mkdtemp(prefix="aios-governance-fixture-"))
|
||||
self.protection = MemoryProtectionStore(initial)
|
||||
self.destroyed = False
|
||||
|
||||
def __enter__(self) -> "DisposableGiteaFixture":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
resolved = self.root.resolve()
|
||||
if not resolved.name.startswith("aios-governance-fixture-"):
|
||||
raise RuntimeError("fixture cleanup path guard failed")
|
||||
shutil.rmtree(resolved)
|
||||
self.destroyed = True
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .journal import atomic_write_json, read_json, sha256_json
|
||||
|
||||
|
||||
class CASConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class UnsafePolicy(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def calculate_snapshot_sha256(snapshot: Mapping[str, Any]) -> str:
|
||||
return sha256_json(dict(snapshot))
|
||||
|
||||
|
||||
def validate_target(previous: Mapping[str, Any], target: Mapping[str, Any]) -> None:
|
||||
if set(previous) != set(target):
|
||||
raise UnsafePolicy("PROTECTION_ROW_SHAPE_CHANGED")
|
||||
for field in ("id", "repo_id", "branch_name"):
|
||||
if previous.get(field) != target.get(field):
|
||||
raise UnsafePolicy(f"PROTECTION_IDENTITY_CHANGED:{field}")
|
||||
if target.get("branch_name") != "governance/approvals":
|
||||
raise UnsafePolicy("BRANCH_BINDING_DENIED")
|
||||
if int(target.get("can_push", 1)) != 0:
|
||||
raise UnsafePolicy("DIRECT_PUSH_MUST_REMAIN_DISABLED")
|
||||
if int(target.get("required_approvals", 0)) != 1:
|
||||
raise UnsafePolicy("EXACTLY_ONE_APPROVAL_REQUIRED")
|
||||
if int(target.get("enable_approvals_whitelist", 0)) != 1:
|
||||
raise UnsafePolicy("APPROVAL_WHITELIST_MUST_REMAIN_ENABLED")
|
||||
if json.loads(target.get("approvals_whitelist_user_i_ds", "[]")) != [2]:
|
||||
raise UnsafePolicy("OWNER_APPROVAL_WHITELIST_REQUIRED")
|
||||
if int(target.get("enable_merge_whitelist", 0)) != 1:
|
||||
raise UnsafePolicy("MERGE_WHITELIST_MUST_REMAIN_ENABLED")
|
||||
if target.get("merge_whitelist_user_i_ds") != previous.get("merge_whitelist_user_i_ds"):
|
||||
raise UnsafePolicy("MERGE_EXECUTOR_WHITELIST_CHANGE_DENIED")
|
||||
if int(target.get("dismiss_stale_approvals", 0)) != 1:
|
||||
raise UnsafePolicy("DISMISS_STALE_APPROVALS_REQUIRED")
|
||||
if int(target.get("enable_status_check", 0)) != 1:
|
||||
raise UnsafePolicy("STATUS_CHECKS_REQUIRED")
|
||||
required_contexts = {
|
||||
"aios/validator",
|
||||
"aios/state-machine",
|
||||
"aios/branch-protection",
|
||||
"aios/secret-scan",
|
||||
"aios/evidence-closure",
|
||||
}
|
||||
try:
|
||||
contexts = set(json.loads(target.get("status_check_contexts", "[]")))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise UnsafePolicy("STATUS_CHECK_CONTEXTS_INVALID") from exc
|
||||
if contexts != required_contexts:
|
||||
raise UnsafePolicy("STATUS_CHECK_CONTEXTS_MISMATCH")
|
||||
if int(target.get("block_on_rejected_reviews", 0)) != 1:
|
||||
raise UnsafePolicy("REJECTED_REVIEWS_MUST_BLOCK")
|
||||
if int(target.get("block_on_outdated_branch", 0)) != 1:
|
||||
raise UnsafePolicy("OUTDATED_BRANCH_MUST_BLOCK")
|
||||
if target.get("require_signed_commits") != previous.get("require_signed_commits"):
|
||||
raise UnsafePolicy("SIGNATURE_POLICY_CHANGE_DENIED")
|
||||
|
||||
|
||||
class FileProtectedBranchAdapter:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
@staticmethod
|
||||
def calculate_snapshot_sha256(snapshot: Mapping[str, Any]) -> str:
|
||||
return calculate_snapshot_sha256(snapshot)
|
||||
|
||||
def read_current(self) -> dict[str, Any]:
|
||||
return read_json(self.path)
|
||||
|
||||
def compare_expected(self, expected_sha256: str) -> bool:
|
||||
return calculate_snapshot_sha256(self.read_current()) == expected_sha256
|
||||
|
||||
def apply_atomic(
|
||||
self, expected_sha256: str, target: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
previous = self.read_current()
|
||||
if calculate_snapshot_sha256(previous) != expected_sha256:
|
||||
raise CASConflict("CAS_CONFLICT")
|
||||
validate_target(previous, target)
|
||||
atomic_write_json(self.path, dict(target))
|
||||
applied = self.read_current()
|
||||
if calculate_snapshot_sha256(applied) != calculate_snapshot_sha256(target):
|
||||
raise CASConflict("CAS_VERIFY_FAILED")
|
||||
return applied
|
||||
|
||||
def verify_applied(self, expected_sha256: str) -> bool:
|
||||
return self.compare_expected(expected_sha256)
|
||||
|
||||
def rollback_atomic(
|
||||
self, expected_target_sha256: str, previous: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
current = self.read_current()
|
||||
if calculate_snapshot_sha256(current) != expected_target_sha256:
|
||||
raise CASConflict("ROLLBACK_CONFLICT")
|
||||
atomic_write_json(self.path, dict(previous))
|
||||
restored = self.read_current()
|
||||
if calculate_snapshot_sha256(restored) != calculate_snapshot_sha256(previous):
|
||||
raise CASConflict("ROLLBACK_VERIFY_FAILED")
|
||||
return restored
|
||||
|
||||
def verify_rollback(self, expected_sha256: str) -> bool:
|
||||
return self.compare_expected(expected_sha256)
|
||||
|
||||
|
||||
class SQLiteProtectedBranchAdapter:
|
||||
ALLOWED_UPDATE_FIELDS = (
|
||||
"can_push",
|
||||
"enable_merge_whitelist",
|
||||
"merge_whitelist_user_i_ds",
|
||||
"enable_status_check",
|
||||
"status_check_contexts",
|
||||
"enable_approvals_whitelist",
|
||||
"approvals_whitelist_user_i_ds",
|
||||
"required_approvals",
|
||||
"block_on_rejected_reviews",
|
||||
"block_on_official_review_requests",
|
||||
"block_on_outdated_branch",
|
||||
"dismiss_stale_approvals",
|
||||
"ignore_stale_approvals",
|
||||
"updated_unix",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path: Path,
|
||||
*,
|
||||
repo_id: int = 1,
|
||||
branch: str = "governance/approvals",
|
||||
expected_schema_sha256: str,
|
||||
) -> None:
|
||||
self.db_path = db_path
|
||||
self.repo_id = repo_id
|
||||
self.branch = branch
|
||||
self.expected_schema_sha256 = expected_schema_sha256
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=10)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def schema_sha256(self, connection: sqlite3.Connection | None = None) -> str:
|
||||
own = connection is None
|
||||
connection = connection or self._connect()
|
||||
try:
|
||||
rows = [dict(row) for row in connection.execute("PRAGMA table_info(protected_branch)")]
|
||||
return sha256_json(rows)
|
||||
finally:
|
||||
if own:
|
||||
connection.close()
|
||||
|
||||
def _check_schema(self, connection: sqlite3.Connection) -> None:
|
||||
if not isinstance(self.expected_schema_sha256, str) or len(self.expected_schema_sha256) != 64:
|
||||
raise RuntimeError("GITEA_SCHEMA_FINGERPRINT_REQUIRED")
|
||||
if self.schema_sha256(connection) != self.expected_schema_sha256:
|
||||
raise RuntimeError("GITEA_SCHEMA_FINGERPRINT_MISMATCH")
|
||||
|
||||
def _read(self, connection: sqlite3.Connection) -> dict[str, Any]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM protected_branch WHERE repo_id=? AND branch_name=?",
|
||||
(self.repo_id, self.branch),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("PROTECTED_BRANCH_ROW_NOT_FOUND")
|
||||
return dict(row)
|
||||
|
||||
def read_current(self) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
self._check_schema(connection)
|
||||
return self._read(connection)
|
||||
|
||||
@staticmethod
|
||||
def calculate_snapshot_sha256(snapshot: Mapping[str, Any]) -> str:
|
||||
return calculate_snapshot_sha256(snapshot)
|
||||
|
||||
def compare_expected(self, expected_sha256: str) -> bool:
|
||||
return calculate_snapshot_sha256(self.read_current()) == expected_sha256
|
||||
|
||||
def apply_atomic(
|
||||
self, expected_sha256: str, target: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._check_schema(connection)
|
||||
previous = self._read(connection)
|
||||
if calculate_snapshot_sha256(previous) != expected_sha256:
|
||||
raise CASConflict("CAS_CONFLICT")
|
||||
validate_target(previous, target)
|
||||
values = dict(target)
|
||||
values["updated_unix"] = int(values.get("updated_unix") or time.time())
|
||||
assignments = ",".join(f"{field}=?" for field in self.ALLOWED_UPDATE_FIELDS)
|
||||
parameters = [values[field] for field in self.ALLOWED_UPDATE_FIELDS]
|
||||
parameters.extend((self.repo_id, self.branch))
|
||||
cursor = connection.execute(
|
||||
f"UPDATE protected_branch SET {assignments} WHERE repo_id=? AND branch_name=?",
|
||||
parameters,
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise CASConflict("CAS_ROWCOUNT_INVALID")
|
||||
applied = self._read(connection)
|
||||
expected_target = dict(target)
|
||||
expected_target["updated_unix"] = values["updated_unix"]
|
||||
if calculate_snapshot_sha256(applied) != calculate_snapshot_sha256(expected_target):
|
||||
raise CASConflict("CAS_VERIFY_FAILED")
|
||||
connection.commit()
|
||||
return applied
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def verify_applied(self, expected_sha256: str) -> bool:
|
||||
return self.compare_expected(expected_sha256)
|
||||
|
||||
def rollback_atomic(
|
||||
self, expected_target_sha256: str, previous: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._check_schema(connection)
|
||||
current = self._read(connection)
|
||||
if calculate_snapshot_sha256(current) != expected_target_sha256:
|
||||
raise CASConflict("ROLLBACK_CONFLICT")
|
||||
for field in ("id", "repo_id", "branch_name"):
|
||||
if current.get(field) != previous.get(field):
|
||||
raise CASConflict("ROLLBACK_IDENTITY_MISMATCH")
|
||||
assignments = ",".join(f"{field}=?" for field in self.ALLOWED_UPDATE_FIELDS)
|
||||
parameters = [previous[field] for field in self.ALLOWED_UPDATE_FIELDS]
|
||||
parameters.extend((self.repo_id, self.branch))
|
||||
cursor = connection.execute(
|
||||
f"UPDATE protected_branch SET {assignments} WHERE repo_id=? AND branch_name=?",
|
||||
parameters,
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise CASConflict("ROLLBACK_ROWCOUNT_INVALID")
|
||||
restored = self._read(connection)
|
||||
if calculate_snapshot_sha256(restored) != calculate_snapshot_sha256(previous):
|
||||
raise CASConflict("ROLLBACK_VERIFY_FAILED")
|
||||
connection.commit()
|
||||
return restored
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def verify_rollback(self, expected_sha256: str) -> bool:
|
||||
return self.compare_expected(expected_sha256)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user