Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473b86a2f1 | ||
|
|
e5dc9185dd | ||
|
|
334354557a | ||
|
|
9917145a64 | ||
|
|
357041bc3c | ||
|
|
ba403c0959 | ||
|
|
aa2ffdb5a8 | ||
|
|
310ab2af90 | ||
|
|
71def340e7 | ||
|
|
e1e438564c | ||
|
|
fa038d17b2 | ||
|
|
112d995545 | ||
|
|
85426f4f57 |
@@ -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
|
||||
}
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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*"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SENSITIVE_FRAGMENTS = (
|
||||
"authorization",
|
||||
"cookie",
|
||||
"credential",
|
||||
"password",
|
||||
"private_key",
|
||||
"secret",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: object) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
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_state_directory(path: Path) -> None:
|
||||
if path.is_symlink():
|
||||
raise RuntimeError("STATE_DIRECTORY_SYMLINK_DENIED")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
path.chmod(0o750)
|
||||
mode = path.stat().st_mode & 0o777
|
||||
if mode != 0o750:
|
||||
raise RuntimeError("STATE_DIRECTORY_MODE_INVALID")
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: object) -> None:
|
||||
ensure_state_directory(path.parent)
|
||||
raw = json.dumps(redact(value), sort_keys=True, indent=2).encode("utf-8") + b"\n"
|
||||
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(0o640)
|
||||
os.replace(temporary, path)
|
||||
path.chmod(0o640)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise RuntimeError("STATE_DOCUMENT_NOT_OBJECT")
|
||||
return raw
|
||||
|
||||
|
||||
class AuditChain:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def _records(self) -> list[dict[str, Any]]:
|
||||
if not self.path.exists():
|
||||
return []
|
||||
records: list[dict[str, Any]] = []
|
||||
for line in self.path.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
record = json.loads(line)
|
||||
if not isinstance(record, dict):
|
||||
raise RuntimeError("AUDIT_RECORD_INVALID")
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
def verify(self) -> str:
|
||||
previous = "0" * 64
|
||||
for index, record in enumerate(self._records(), 1):
|
||||
payload = record.get("payload")
|
||||
if record.get("sequence") != index:
|
||||
raise RuntimeError("AUDIT_SEQUENCE_INVALID")
|
||||
if record.get("previous_event_hash") != previous:
|
||||
raise RuntimeError("AUDIT_PREVIOUS_HASH_INVALID")
|
||||
payload_hash = sha256_json(payload)
|
||||
if record.get("payload_hash") != payload_hash:
|
||||
raise RuntimeError("AUDIT_PAYLOAD_HASH_INVALID")
|
||||
material = {
|
||||
"sequence": index,
|
||||
"previous_event_hash": previous,
|
||||
"payload_hash": payload_hash,
|
||||
"transaction_id": record.get("transaction_id"),
|
||||
}
|
||||
event_hash = sha256_json(material)
|
||||
if record.get("event_hash") != event_hash:
|
||||
raise RuntimeError("AUDIT_EVENT_HASH_INVALID")
|
||||
previous = event_hash
|
||||
return previous
|
||||
|
||||
def append(self, transaction_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
ensure_state_directory(self.path.parent)
|
||||
previous = self.verify()
|
||||
sequence = len(self._records()) + 1
|
||||
clean_payload = redact(payload)
|
||||
payload_hash = sha256_json(clean_payload)
|
||||
material = {
|
||||
"sequence": sequence,
|
||||
"previous_event_hash": previous,
|
||||
"payload_hash": payload_hash,
|
||||
"transaction_id": transaction_id,
|
||||
}
|
||||
record = {
|
||||
**material,
|
||||
"payload": clean_payload,
|
||||
"event_hash": sha256_json(material),
|
||||
}
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o640)
|
||||
try:
|
||||
os.write(fd, canonical_json(record) + b"\n")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
self.path.chmod(0o640)
|
||||
return record
|
||||
|
||||
def has_event(self, transaction_id: str, event: str) -> bool:
|
||||
self.verify()
|
||||
return any(
|
||||
record.get("transaction_id") == transaction_id
|
||||
and isinstance(record.get("payload"), dict)
|
||||
and record["payload"].get("event") == event
|
||||
for record in self._records()
|
||||
)
|
||||
|
||||
|
||||
class JournalStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.wal = root / "wal"
|
||||
self.receipts = root / "receipts"
|
||||
ensure_state_directory(root)
|
||||
ensure_state_directory(self.wal)
|
||||
ensure_state_directory(self.receipts)
|
||||
self.audit = AuditChain(root / "audit.jsonl")
|
||||
|
||||
def transaction_path(self, operation_id: str) -> Path:
|
||||
if not operation_id or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for char in operation_id):
|
||||
raise RuntimeError("OPERATION_ID_INVALID")
|
||||
return self.wal / f"{operation_id}.json"
|
||||
|
||||
def receipt_path(self, operation_id: str) -> Path:
|
||||
return self.receipts / f"{operation_id}.json"
|
||||
|
||||
def load(self, operation_id: str) -> dict[str, Any] | None:
|
||||
path = self.transaction_path(operation_id)
|
||||
return read_json(path) if path.exists() else None
|
||||
|
||||
def write(self, operation_id: str, value: dict[str, Any]) -> None:
|
||||
atomic_write_json(self.transaction_path(operation_id), value)
|
||||
|
||||
def write_receipt(self, operation_id: str, value: dict[str, Any]) -> None:
|
||||
atomic_write_json(self.receipt_path(operation_id), value)
|
||||
|
||||
def iter_transactions(self) -> Iterable[dict[str, Any]]:
|
||||
for path in sorted(self.wal.glob("*.json")):
|
||||
yield read_json(path)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - exercised by Windows validation
|
||||
fcntl = None
|
||||
import msvcrt
|
||||
|
||||
|
||||
class MaintenanceLock:
|
||||
"""Process fence used only by fixture and later approved activation executors."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self._handle = None
|
||||
|
||||
def __enter__(self) -> "MaintenanceLock":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._handle = self.path.open("a+")
|
||||
if fcntl is not None:
|
||||
try:
|
||||
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
self._handle.close()
|
||||
self._handle = None
|
||||
raise
|
||||
else:
|
||||
self._handle.seek(0)
|
||||
try:
|
||||
msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError as exc:
|
||||
self._handle.close()
|
||||
self._handle = None
|
||||
raise BlockingIOError("maintenance lock held") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self._handle is not None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
|
||||
else:
|
||||
self._handle.seek(0)
|
||||
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
self._handle.close()
|
||||
self._handle = None
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
class GovernanceState(str, Enum):
|
||||
HUMAN_OWNER_APPROVAL_PENDING = "HUMAN_OWNER_APPROVAL_PENDING"
|
||||
OWNER_APPROVAL_VERIFIED = "OWNER_APPROVAL_VERIFIED"
|
||||
MACHINE_GATES_VERIFIED = "MACHINE_GATES_VERIFIED"
|
||||
AUTO_MERGE_READY = "AUTO_MERGE_READY"
|
||||
OWNER_APPROVED_EXECUTION_READY = "OWNER_APPROVED_EXECUTION_READY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Review:
|
||||
reviewer: str
|
||||
reviewer_id: int
|
||||
state: str
|
||||
head_oid: str
|
||||
reviewed_at: datetime
|
||||
stale: bool = False
|
||||
dismissed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reviewed_at.tzinfo is None:
|
||||
raise ValueError("reviewed_at must be timezone-aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineGate:
|
||||
context: str
|
||||
state: str
|
||||
head_oid: str
|
||||
observed_at: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.observed_at.tzinfo is None:
|
||||
raise ValueError("observed_at must be timezone-aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalContext:
|
||||
current_head_oid: str
|
||||
requester: str
|
||||
executor: str
|
||||
owner: str
|
||||
owner_id: int
|
||||
reviews: tuple[Review, ...]
|
||||
machine_gates: tuple[MachineGate, ...]
|
||||
required_machine_contexts: tuple[str, ...]
|
||||
expires_at: datetime
|
||||
wip: bool
|
||||
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at.tzinfo is None or self.now.tzinfo is None:
|
||||
raise ValueError("approval timestamps must be timezone-aware")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProtectionSnapshot:
|
||||
branch: str
|
||||
required_approvals: int
|
||||
approval_whitelist: tuple[str, ...]
|
||||
merge_whitelist: tuple[str, ...]
|
||||
dismiss_stale_approvals: bool
|
||||
require_status_checks: bool
|
||||
status_contexts: tuple[str, ...]
|
||||
direct_push: bool
|
||||
force_push: bool
|
||||
branch_deletion: bool
|
||||
version: int
|
||||
|
||||
def as_dict(self) -> Mapping[str, Any]:
|
||||
return {
|
||||
"branch": self.branch,
|
||||
"required_approvals": self.required_approvals,
|
||||
"approval_whitelist": list(self.approval_whitelist),
|
||||
"merge_whitelist": list(self.merge_whitelist),
|
||||
"dismiss_stale_approvals": self.dismiss_stale_approvals,
|
||||
"require_status_checks": self.require_status_checks,
|
||||
"status_contexts": list(self.status_contexts),
|
||||
"direct_push": self.direct_push,
|
||||
"force_push": self.force_push,
|
||||
"branch_deletion": self.branch_deletion,
|
||||
"version": self.version,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class OperationDenied(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class DormantOperationGuard:
|
||||
"""Fail-closed operation boundary for the prerequisite deployment."""
|
||||
|
||||
DENIED = frozenset({
|
||||
"protected_branch.direct_push",
|
||||
"governance.direct_push",
|
||||
"tag.direct_write",
|
||||
"git.force_push",
|
||||
"production.without_owner",
|
||||
"branch_protection.live_update",
|
||||
"single_owner_policy.activate",
|
||||
})
|
||||
|
||||
def authorize(self, operation: str, *, fixture: bool = False) -> None:
|
||||
if operation in self.DENIED or not fixture:
|
||||
raise OperationDenied(operation)
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Protocol
|
||||
|
||||
from .models import ProtectionSnapshot
|
||||
|
||||
|
||||
class CASConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DormantViolation(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def snapshot_sha256(snapshot: ProtectionSnapshot) -> str:
|
||||
raw = json.dumps(snapshot.as_dict(), sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
class ProtectionStore(Protocol):
|
||||
fixture: bool
|
||||
|
||||
def read(self, branch: str) -> ProtectionSnapshot: ...
|
||||
def compare_and_swap(
|
||||
self, branch: str, expected_sha256: str, target: ProtectionSnapshot
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProtectionPlan:
|
||||
branch: str
|
||||
expected_previous_sha256: str
|
||||
previous: ProtectionSnapshot
|
||||
target: ProtectionSnapshot
|
||||
target_sha256: str
|
||||
|
||||
|
||||
class BranchProtectionCASMigrationAdapter:
|
||||
"""Candidate adapter. Mutations are restricted to disposable fixtures."""
|
||||
|
||||
def plan(
|
||||
self,
|
||||
current: ProtectionSnapshot,
|
||||
target: ProtectionSnapshot,
|
||||
expected_previous_sha256: str,
|
||||
) -> ProtectionPlan:
|
||||
actual = snapshot_sha256(current)
|
||||
if actual != expected_previous_sha256:
|
||||
raise CASConflict("EXPECTED_PREVIOUS_SHA256_MISMATCH")
|
||||
if current.branch != target.branch:
|
||||
raise CASConflict("BRANCH_BINDING_MISMATCH")
|
||||
if target.direct_push or target.force_push or target.branch_deletion:
|
||||
raise DormantViolation("UNSAFE_TARGET_POLICY")
|
||||
return ProtectionPlan(
|
||||
current.branch, actual, current, target, snapshot_sha256(target)
|
||||
)
|
||||
|
||||
def apply_fixture(self, store: ProtectionStore, plan: ProtectionPlan) -> ProtectionSnapshot:
|
||||
if not store.fixture:
|
||||
raise DormantViolation("LIVE_PROTECTION_MUTATION_DENIED")
|
||||
if not store.compare_and_swap(
|
||||
plan.branch, plan.expected_previous_sha256, plan.target
|
||||
):
|
||||
raise CASConflict("CONCURRENT_PROTECTION_CHANGE")
|
||||
return store.read(plan.branch)
|
||||
|
||||
def rollback_fixture(self, store: ProtectionStore, plan: ProtectionPlan) -> ProtectionSnapshot:
|
||||
if not store.fixture:
|
||||
raise DormantViolation("LIVE_PROTECTION_ROLLBACK_DENIED")
|
||||
if not store.compare_and_swap(plan.branch, plan.target_sha256, plan.previous):
|
||||
raise CASConflict("ROLLBACK_CONFLICT")
|
||||
return store.read(plan.branch)
|
||||
|
||||
|
||||
class MemoryProtectionStore:
|
||||
fixture = True
|
||||
|
||||
def __init__(self, snapshot: ProtectionSnapshot) -> None:
|
||||
self._snapshot = snapshot
|
||||
|
||||
def read(self, branch: str) -> ProtectionSnapshot:
|
||||
if self._snapshot.branch != branch:
|
||||
raise KeyError(branch)
|
||||
return self._snapshot
|
||||
|
||||
def compare_and_swap(
|
||||
self, branch: str, expected_sha256: str, target: ProtectionSnapshot
|
||||
) -> bool:
|
||||
if self._snapshot.branch != branch:
|
||||
return False
|
||||
if snapshot_sha256(self._snapshot) != expected_sha256:
|
||||
return False
|
||||
self._snapshot = target
|
||||
return True
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from .models import ProtectionSnapshot
|
||||
from .protection import MemoryProtectionStore, ProtectionPlan, snapshot_sha256
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RollbackConfiguration:
|
||||
branch: str
|
||||
previous_sha256: str
|
||||
target_sha256: str
|
||||
payload: dict
|
||||
|
||||
|
||||
class RollbackConfigurationManager:
|
||||
def build(self, plan: ProtectionPlan) -> RollbackConfiguration:
|
||||
return RollbackConfiguration(
|
||||
plan.branch,
|
||||
plan.expected_previous_sha256,
|
||||
plan.target_sha256,
|
||||
{"previous": plan.previous.as_dict(), "target": plan.target.as_dict()},
|
||||
)
|
||||
|
||||
def install_fixture(self, root: Path, config: RollbackConfiguration) -> Path:
|
||||
if not root.name.startswith("aios-governance-fixture-"):
|
||||
raise PermissionError("rollback configuration may only be installed in fixture root")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
destination = root / "rollback-configuration.json"
|
||||
raw = json.dumps(config.payload, sort_keys=True, indent=2).encode() + b"\n"
|
||||
with tempfile.NamedTemporaryFile(dir=root, prefix=".rollback.", delete=False) as handle:
|
||||
handle.write(raw)
|
||||
handle.flush()
|
||||
temporary = Path(handle.name)
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
|
||||
def verify(self, config: RollbackConfiguration, plan: ProtectionPlan) -> bool:
|
||||
return (
|
||||
config.previous_sha256 == snapshot_sha256(plan.previous)
|
||||
and config.target_sha256 == snapshot_sha256(plan.target)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtectionCompensationSaga:
|
||||
adapter: object
|
||||
store: MemoryProtectionStore
|
||||
plan: ProtectionPlan
|
||||
applied: bool = False
|
||||
rolled_back: bool = False
|
||||
|
||||
def execute_fixture(self) -> ProtectionSnapshot:
|
||||
result = self.adapter.apply_fixture(self.store, self.plan)
|
||||
self.applied = True
|
||||
return result
|
||||
|
||||
def compensate_fixture(self) -> ProtectionSnapshot:
|
||||
if not self.applied:
|
||||
return self.store.read(self.plan.branch)
|
||||
result = self.adapter.rollback_fixture(self.store, self.plan)
|
||||
self.rolled_back = True
|
||||
return result
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
|
||||
def runtime_state(component: str, prior_invocations: int = 0) -> dict:
|
||||
return {
|
||||
"component": component,
|
||||
"deployment_mode": "DORMANT_SHADOW_ONLY",
|
||||
"enabled": False,
|
||||
"active": False,
|
||||
"production_routing": False,
|
||||
"production_mutation": False,
|
||||
"global_gitops_writes_enabled": False,
|
||||
"prior_invocations": prior_invocations,
|
||||
"restart_recovery": prior_invocations > 0,
|
||||
}
|
||||
|
||||
|
||||
def checkpoint_state(component: str, root: Path) -> dict:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
destination = root / f"{component}.json"
|
||||
prior = 0
|
||||
if destination.exists():
|
||||
previous = json.loads(destination.read_text(encoding="utf-8"))
|
||||
if previous.get("component") != component or previous.get("deployment_mode") != "DORMANT_SHADOW_ONLY":
|
||||
raise RuntimeError("checkpoint identity mismatch")
|
||||
prior = int(previous.get("invocations", 0))
|
||||
state = runtime_state(component, prior)
|
||||
checkpoint = {**state, "invocations": prior + 1}
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=root, delete=False) as handle:
|
||||
json.dump(checkpoint, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
temporary = Path(handle.name)
|
||||
temporary.replace(destination)
|
||||
return state
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--component", required=True, choices=("owner-review-adapter", "machine-status-producer"))
|
||||
parser.add_argument("--self-check", action="store_true")
|
||||
parser.add_argument("--activate", action="store_true")
|
||||
parser.add_argument("--checkpoint-dir", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.activate:
|
||||
parser.error("activation is not authorized by the prerequisites candidate")
|
||||
if os.environ.get("AIOS_SINGLE_OWNER_GOVERNANCE_ENABLED", "false").lower() != "false":
|
||||
parser.error("candidate must remain disabled")
|
||||
if os.environ.get("AIOS_SINGLE_OWNER_ACTIVATION_ACTIVE", "false").lower() != "false":
|
||||
parser.error("activation must remain inactive")
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
parser.error("production mutation must remain disabled")
|
||||
state = (
|
||||
checkpoint_state(args.component, args.checkpoint_dir)
|
||||
if args.checkpoint_dir is not None
|
||||
else runtime_state(args.component)
|
||||
)
|
||||
print(json.dumps(state, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from .models import GovernanceState
|
||||
|
||||
|
||||
class InvalidTransition(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
_NEXT = {
|
||||
GovernanceState.HUMAN_OWNER_APPROVAL_PENDING: GovernanceState.OWNER_APPROVAL_VERIFIED,
|
||||
GovernanceState.OWNER_APPROVAL_VERIFIED: GovernanceState.MACHINE_GATES_VERIFIED,
|
||||
GovernanceState.MACHINE_GATES_VERIFIED: GovernanceState.AUTO_MERGE_READY,
|
||||
GovernanceState.AUTO_MERGE_READY: GovernanceState.OWNER_APPROVED_EXECUTION_READY,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovernanceStateMachine:
|
||||
state: GovernanceState = GovernanceState.HUMAN_OWNER_APPROVAL_PENDING
|
||||
|
||||
def transition(self, target: GovernanceState) -> GovernanceState:
|
||||
if _NEXT.get(self.state) != target:
|
||||
raise InvalidTransition(f"{self.state.value}->{target.value}")
|
||||
self.state = target
|
||||
return self.state
|
||||
|
||||
def restore(self, checkpoint: GovernanceState) -> None:
|
||||
if checkpoint not in GovernanceState:
|
||||
raise InvalidTransition("unknown checkpoint")
|
||||
self.state = checkpoint
|
||||
|
||||
def save_fixture(self, path: Path) -> None:
|
||||
if not any(part.startswith("aios-governance-fixture-") for part in path.resolve().parts):
|
||||
raise PermissionError("state checkpoint writes are fixture-only")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
||||
json.dump({"state": self.state.value}, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
temporary = Path(handle.name)
|
||||
temporary.replace(path)
|
||||
|
||||
@classmethod
|
||||
def load_fixture(cls, path: Path) -> "GovernanceStateMachine":
|
||||
if not any(part.startswith("aios-governance-fixture-") for part in path.resolve().parts):
|
||||
raise PermissionError("state checkpoint reads are fixture-only")
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
return cls(GovernanceState(raw["state"]))
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class DormantStatusProducer(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusRecord:
|
||||
context: str
|
||||
state: str
|
||||
head_oid: str
|
||||
observed_at: str
|
||||
|
||||
|
||||
class MachineStatusProducer:
|
||||
def __init__(self, *, enabled: bool = False, fixture: bool = False) -> None:
|
||||
self.enabled = enabled
|
||||
self.fixture = fixture
|
||||
|
||||
def produce(self, context: str, state: str, head_oid: str) -> StatusRecord:
|
||||
if not self.fixture:
|
||||
raise DormantStatusProducer("LIVE_STATUS_PUBLICATION_DENIED")
|
||||
if self.enabled:
|
||||
raise DormantStatusProducer("DORMANT_CANDIDATE_MUST_NOT_BE_ENABLED")
|
||||
if context not in {
|
||||
"aios/machine-gates", "aios/secret-scan", "aios/evidence-closure"
|
||||
}:
|
||||
raise ValueError("unknown status context")
|
||||
if state not in {"success", "failure"} or len(head_oid) != 40:
|
||||
raise ValueError("invalid status record")
|
||||
return StatusRecord(
|
||||
context, state, head_oid,
|
||||
datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import ApprovalContext, Review
|
||||
|
||||
|
||||
class ValidationDenied(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationResult:
|
||||
owner_review: Review
|
||||
machine_contexts: tuple[str, ...]
|
||||
|
||||
|
||||
class SingleOwnerApprovalValidator:
|
||||
"""Pure candidate validator. It has no Gitea or production write path."""
|
||||
|
||||
def validate(self, context: ApprovalContext) -> ValidationResult:
|
||||
if context.now >= context.expires_at:
|
||||
raise ValidationDenied("APPROVAL_EXPIRED")
|
||||
if context.wip:
|
||||
raise ValidationDenied("WIP_BLOCKED")
|
||||
if context.requester == context.owner:
|
||||
raise ValidationDenied("REQUESTER_SELF_APPROVAL")
|
||||
if context.executor == context.owner:
|
||||
raise ValidationDenied("EXECUTOR_SELF_APPROVAL")
|
||||
|
||||
approved = [review for review in context.reviews if review.state == "APPROVED"]
|
||||
owner_reviews = [
|
||||
review for review in approved
|
||||
if review.reviewer == context.owner and review.reviewer_id == context.owner_id
|
||||
]
|
||||
if len(owner_reviews) != 1:
|
||||
raise ValidationDenied("OWNER_APPROVAL_REQUIRED")
|
||||
owner_review = owner_reviews[0]
|
||||
if owner_review.stale:
|
||||
raise ValidationDenied("STALE_OWNER_APPROVAL")
|
||||
if owner_review.dismissed:
|
||||
raise ValidationDenied("DISMISSED_OWNER_APPROVAL")
|
||||
if owner_review.head_oid != context.current_head_oid:
|
||||
raise ValidationDenied("OWNER_APPROVAL_NOT_LATEST_HEAD")
|
||||
if owner_review.reviewed_at >= context.expires_at:
|
||||
raise ValidationDenied("OWNER_APPROVAL_EXPIRED")
|
||||
if any(
|
||||
review.state == "APPROVED"
|
||||
and review.reviewer != context.owner
|
||||
and review.reviewer_id != context.owner_id
|
||||
for review in context.reviews
|
||||
):
|
||||
# Non-owner approvals may exist for legacy mechanics, but never
|
||||
# satisfy or replace the human authorization decision.
|
||||
pass
|
||||
|
||||
gates = {gate.context: gate for gate in context.machine_gates}
|
||||
if set(gates) != set(context.required_machine_contexts):
|
||||
raise ValidationDenied("MACHINE_GATE_SET_MISMATCH")
|
||||
for gate in gates.values():
|
||||
if gate.state != "success":
|
||||
raise ValidationDenied("MACHINE_GATE_FAILED")
|
||||
if gate.head_oid != context.current_head_oid:
|
||||
raise ValidationDenied("MACHINE_GATE_NOT_LATEST_HEAD")
|
||||
if gate.observed_at >= context.expires_at:
|
||||
raise ValidationDenied("MACHINE_GATE_EXPIRED")
|
||||
return ValidationResult(owner_review, tuple(sorted(gates)))
|
||||
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "aios-single-owner-governance"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
aios-single-owner-governance = "aios_single_owner_governance.activation:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["aios_single_owner_governance*"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user