Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120879118e | ||
|
|
d7f49cb3f7 | ||
|
|
3a5e795ea5 | ||
|
|
1968fb9ac9 | ||
|
|
525a0afad8 | ||
|
|
f4729c489d | ||
|
|
38fbaf51b4 | ||
|
|
d5c41d6408 | ||
|
|
09134f6817 | ||
|
|
d6f029265c | ||
|
|
eca6968e18 | ||
|
|
06576a2f3f | ||
|
|
aa2822c386 | ||
|
|
099f389fd7 | ||
|
|
331bd58204 | ||
|
|
92f197674c | ||
|
|
22e1288c54 | ||
|
|
875d41c310 | ||
|
|
870ac61dc2 | ||
|
|
009f276699 | ||
|
|
600c717e17 | ||
|
|
02be6d28ed | ||
|
|
b1e240dda3 | ||
|
|
2dfbe837f4 | ||
|
|
80df1e0d87 | ||
|
|
7ecf7cbfce | ||
|
|
bbada7c863 | ||
|
|
df099420d2 | ||
|
|
2d802f3d51 | ||
|
|
dd34dfaad2 | ||
|
|
346426b6ca | ||
|
|
967b90af91 | ||
|
|
2458fa5f28 | ||
|
|
40f2f3ebed | ||
|
|
687112d86c | ||
|
|
f0d1e8479b | ||
|
|
85b4610eb3 | ||
|
|
a0b5160534 | ||
|
|
cf57ef6bcc | ||
|
|
958e49e2cf | ||
|
|
3f6cabd4ac | ||
|
|
146d2123a5 | ||
|
|
ecd4ee1cc9 | ||
|
|
eaf6cd739f | ||
|
|
d4276e2cf5 | ||
|
|
f7a04ccec6 | ||
|
|
5cf628deca | ||
|
|
9914ebc9d4 | ||
|
|
39ffd23335 | ||
|
|
467015e3fc | ||
|
|
cdefcc18dd | ||
|
|
1594ac9a18 | ||
|
|
0ba2cdb63d | ||
|
|
a8e9786897 | ||
|
|
58511395c1 | ||
|
|
d23aafc04c | ||
|
|
312aeff1ab | ||
|
|
1673575b21 | ||
|
|
8b97275ff7 | ||
|
|
578f0a7e28 | ||
|
|
9cc04bf146 | ||
|
|
e0b48dae24 | ||
|
|
9893652270 | ||
|
|
57234854f7 | ||
|
|
ccd64610b0 | ||
|
|
bad1120d6d | ||
|
|
c8d2386677 | ||
|
|
08897d3968 | ||
|
|
8497848120 | ||
|
|
dad45ab32b | ||
|
|
238742150b | ||
|
|
04865315a3 | ||
|
|
8e72dc22d6 | ||
|
|
79ac5abac2 | ||
|
|
655c69fc4b | ||
|
|
63e0b8e705 | ||
|
|
edfad05d57 | ||
|
|
e40c05779c | ||
|
|
a3edc628a9 | ||
|
|
f4b6ac8609 | ||
|
|
83ab67f7e1 | ||
|
|
27a0ae126b | ||
|
|
54093d1543 | ||
|
|
3196d91b12 | ||
|
|
3812e1ab01 | ||
|
|
fc0b9f29bf |
@@ -5,8 +5,59 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(os.getenv('AIOS_GITOPS_WORKSPACE', '/data/aios-gitops/workspace')) / 'runtime' / 'agent-os'))
|
||||
try:
|
||||
from aios_gitops_write_guard import evaluate_git_write
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_gitops_write_guard import evaluate_git_write
|
||||
|
||||
from gitea_tag_publication import (
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
PublicationConflict,
|
||||
PublicationPending,
|
||||
PublicationManualOwnerReviewRequired,
|
||||
PublicationReconciliationRequired,
|
||||
PublicationRequest,
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
CapabilityAuthorizationError,
|
||||
SagaConflict,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
)
|
||||
try:
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
AuthoritativeGitNotFound,
|
||||
AuthoritativeGitOutputTooLarge,
|
||||
authoritative_branches,
|
||||
authoritative_diff,
|
||||
authoritative_commit,
|
||||
authoritative_tags,
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
AuthoritativeGitNotFound,
|
||||
AuthoritativeGitOutputTooLarge,
|
||||
authoritative_branches,
|
||||
authoritative_diff,
|
||||
authoritative_commit,
|
||||
authoritative_tags,
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -16,6 +67,19 @@ HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
||||
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
||||
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
||||
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
||||
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
||||
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
||||
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
||||
'/var/lib/aios-gitops/silent-release',
|
||||
))
|
||||
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
||||
)).lower() in {'1', 'true', 'yes', 'on'}
|
||||
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -24,7 +88,43 @@ BASE_MODELS = [
|
||||
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
|
||||
]
|
||||
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
||||
SECRET_VAL_RE = re.compile(r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})')
|
||||
SECRET_VAL_RE = re.compile(
|
||||
r'(?<![A-Za-z0-9_])'
|
||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||
)
|
||||
|
||||
READ_ONLY_GET_PATHS = frozenset({
|
||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||
'/api/base', '/api/baseline', '/base', '/api/ver', '/api/versions', '/ver',
|
||||
'/api/diff', '/diff', '/api/patch', '/api/patches', '/api/branches', '/patch',
|
||||
'/api/model', '/model', '/api/run', '/api/runtime-status', '/run',
|
||||
'/api/action-monitor', '/action-monitor', '/admin/ai-evolution',
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
})
|
||||
SILENT_RELEASE_POST_PATHS = frozenset({
|
||||
'/createReleaseCandidate',
|
||||
'/createReleaseIntent',
|
||||
'/submitSignedApproval',
|
||||
'/submitOwnerApproval',
|
||||
'/startDeployment',
|
||||
})
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if method == 'GET':
|
||||
return False
|
||||
if path in SILENT_RELEASE_POST_PATHS:
|
||||
return False
|
||||
decision = evaluate_git_write(WS)
|
||||
if decision.allowed:
|
||||
return False
|
||||
handler.sendj(decision.payload(), decision.status_code)
|
||||
return True
|
||||
|
||||
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
||||
@@ -36,6 +136,41 @@ def sh(args, check=False):
|
||||
def jload(p: Path, default):
|
||||
try: return json.loads(p.read_text(encoding='utf-8'))
|
||||
except Exception: return default
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest=hashlib.sha256()
|
||||
with path.open('rb') as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _build_silent_release_service() -> SilentReleaseActionService:
|
||||
verifier=None
|
||||
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
||||
verifier=SignedApprovalVerifier(
|
||||
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
|
||||
ApprovalRevocationStore(
|
||||
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
||||
),
|
||||
)
|
||||
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
||||
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
||||
return SilentReleaseActionService(
|
||||
SILENT_RELEASE_STATE_ROOT,
|
||||
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
||||
approval_verifier=verifier,
|
||||
expected_repository=REPOSITORY_IDENTITY,
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
|
||||
# Construct once during service startup. If production execution is enabled
|
||||
# without every trusted builder/orchestrator dependency, construction fails
|
||||
# closed before the HTTP server accepts traffic.
|
||||
_SILENT_RELEASE_SERVICE = _build_silent_release_service()
|
||||
|
||||
|
||||
def silent_release_service() -> SilentReleaseActionService:
|
||||
return _SILENT_RELEASE_SERVICE
|
||||
def relpath(path: str) -> str:
|
||||
x = str(path or '').replace('\\','/').strip()
|
||||
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
||||
@@ -109,9 +244,13 @@ def ensure_git():
|
||||
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
||||
def checkout(branch, base='main', create=False):
|
||||
ensure_git(); b=branch_name(branch)
|
||||
if create or sh(['rev-parse','--verify',b])['code'] != 0:
|
||||
sh(['checkout',base], True); sh(['checkout','-B',b], True)
|
||||
else: sh(['checkout',b], True)
|
||||
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
|
||||
if exists:
|
||||
sh(['checkout',b], True)
|
||||
elif create:
|
||||
sh(['checkout',base], True); sh(['checkout','-b',b], True)
|
||||
else:
|
||||
raise ValueError(f'branch does not exist: {b}')
|
||||
return b
|
||||
def write_file(path, content):
|
||||
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
||||
@@ -121,6 +260,19 @@ def sync_branch(branch: str) -> dict:
|
||||
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
||||
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
||||
if BARE_REPO.exists():
|
||||
common = sh(['rev-parse', '--path-format=absolute', '--git-common-dir'], True)['stdout']
|
||||
if Path(common).resolve() == BARE_REPO.resolve():
|
||||
head = sh(['rev-parse', 'HEAD'], True)['stdout']
|
||||
stored = subprocess.run(
|
||||
['git', '--git-dir', str(BARE_REPO), 'rev-parse', f'refs/heads/{branch}'],
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if stored.returncode != 0 or stored.stdout.strip() != head:
|
||||
raise RuntimeError(f'linked-worktree branch verification failed for {branch}')
|
||||
return {
|
||||
'status':'verified', 'target':'authoritative-bare',
|
||||
'branch':branch, 'oid':head,
|
||||
}
|
||||
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
||||
@@ -130,28 +282,68 @@ def sync_branch(branch: str) -> dict:
|
||||
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
|
||||
def sync_tag(tag: str) -> dict:
|
||||
if not tag: return {'status':'skipped','reason':'empty_tag'}
|
||||
if BARE_REPO.exists():
|
||||
ref=f'refs/tags/{tag}:refs/tags/{tag}'
|
||||
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag sync failed for {tag}')
|
||||
return {'status':'synced','target':'bare','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
p=subprocess.run(['git','push','origin',tag], cwd=str(WS), text=True, capture_output=True)
|
||||
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git tag push failed for {tag}')
|
||||
return {'status':'synced','target':'origin','tag':tag,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
||||
raise RuntimeError(
|
||||
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
|
||||
'target_oid, Gitea remote verification, and authenticated API verification'
|
||||
)
|
||||
|
||||
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
||||
oid = str(value or '').strip().lower()
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', oid):
|
||||
raise ValueError(f'{field} must be a full 40-character commit OID')
|
||||
result = sh(['rev-parse', '--verify', f'{oid}^{{commit}}'])
|
||||
if result['code'] != 0 or result['stdout'] != oid:
|
||||
raise ValueError(f'{field} does not resolve to the exact approved commit')
|
||||
return oid
|
||||
try:
|
||||
return authoritative_commit(oid, git_dir=AUTHORITATIVE_GIT_DIR)
|
||||
except (AuthoritativeGitError, ValueError) as exc:
|
||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||
|
||||
def existing_tag_target(tag: str) -> str | None:
|
||||
result = sh(['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'])
|
||||
return result['stdout'] if result['code'] == 0 else None
|
||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||
raise ValueError('invalid release version')
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||
check=False,
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
def publication_request_from_query(q: dict[str, list[str]]) -> PublicationRequest:
|
||||
required = ('version', 'target_oid', 'evidence_closure_oid', 'approval_plan_id')
|
||||
missing = [name for name in required if not (q.get(name) or [''])[0]]
|
||||
if missing:
|
||||
raise ValueError('missing release governance fields: ' + ', '.join(missing))
|
||||
return PublicationRequest(
|
||||
(q['version'])[0],
|
||||
(q['target_oid'])[0].lower(),
|
||||
(q['evidence_closure_oid'])[0].lower(),
|
||||
(q['approval_plan_id'])[0],
|
||||
)
|
||||
|
||||
def required_authoritative_git_dir() -> Path:
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not snapshot["repository_available"]:
|
||||
raise RuntimeError(str(snapshot["query_error"] or "AUTHORITATIVE_REPOSITORY_UNAVAILABLE"))
|
||||
return Path(str(snapshot["authoritative_git_dir"]))
|
||||
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
return TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient(
|
||||
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
|
||||
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
|
||||
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
|
||||
),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||
)
|
||||
|
||||
def commit(paths, msg):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
@@ -171,6 +363,16 @@ def patches():
|
||||
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
||||
return out
|
||||
|
||||
def runtime_release_info() -> dict[str, Any]:
|
||||
for candidate in (WS / 'manifest.json', WS.parent / 'manifest.json'):
|
||||
data = jload(candidate, {})
|
||||
if data.get('target_commit'):
|
||||
return {
|
||||
'runtime_release_commit': data.get('target_commit'),
|
||||
'runtime_release_path': str(WS),
|
||||
}
|
||||
return {'runtime_release_commit': None, 'runtime_release_path': str(WS)}
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
server_version='AIOSChatGPTGitOps/V1R1C0'
|
||||
def sendj(self, data, code=200):
|
||||
@@ -192,43 +394,236 @@ class H(BaseHTTPRequestHandler):
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path; q=urllib.parse.parse_qs(u.query)
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
try:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in ('/api/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {}); data.setdefault('git', sh(['rev-parse','--short','HEAD'])['stdout']); return self.sendj(data)
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
))
|
||||
return self.sendj(data)
|
||||
if path in ('/api/ver','/api/versions','/ver'):
|
||||
return self.sendj({'tags':sh(['tag','--list','V*'])['stdout'].splitlines(),'versions_dir':[p.name for p in (WS/'versions').glob('*')],'head':sh(['rev-parse','--short','HEAD'])['stdout']})
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not snapshot['repository_available']:
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': snapshot['query_error'],
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
}, 503)
|
||||
try:
|
||||
tags = authoritative_tags(git_dir=AUTHORITATIVE_GIT_DIR)
|
||||
except AuthoritativeGitError as exc:
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': str(exc),
|
||||
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
||||
'local_authoritative_tags': [],
|
||||
}, 503)
|
||||
registry = [p.name for p in (WS/'versions').glob('*')]
|
||||
return self.sendj({
|
||||
'registry_versions': registry,
|
||||
'local_authoritative_tags': tags,
|
||||
'repository_available': True,
|
||||
'query_error': None,
|
||||
'tags': [item['name'] for item in tags],
|
||||
'versions_dir': registry,
|
||||
'head': snapshot['authoritative_head_oid'],
|
||||
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
||||
})
|
||||
if path in ('/api/diff','/diff'):
|
||||
frm=(q.get('from') or ['HEAD~1'])[0]; to=(q.get('to') or ['HEAD'])[0]; return self.sendj({'from':frm,'to':to,'diff':sh(['diff',frm,to])})
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'): return self.sendj({'patches':patches(),'branches':sh(['branch','--format','%(refname:short)'])['stdout'].splitlines()})
|
||||
frm=(q.get('from') or [''])[0]
|
||||
to=(q.get('to') or [''])[0]
|
||||
try:
|
||||
diff=authoritative_diff(frm, to, git_dir=required_authoritative_git_dir())
|
||||
except ValueError as exc:
|
||||
return self.sendj({'error':redact(exc)},400)
|
||||
except AuthoritativeGitNotFound as exc:
|
||||
return self.sendj({'error':redact(exc)},404)
|
||||
except AuthoritativeGitOutputTooLarge as exc:
|
||||
return self.sendj({'error':redact(exc)},413)
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc)},503)
|
||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not snapshot['repository_available']:
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': snapshot['query_error'],
|
||||
'metadata_patches': patches(),
|
||||
'authoritative_branches': [],
|
||||
}, 503)
|
||||
try:
|
||||
branches = authoritative_branches(git_dir=AUTHORITATIVE_GIT_DIR)
|
||||
except AuthoritativeGitError as exc:
|
||||
return self.sendj({
|
||||
'repository_available': False,
|
||||
'query_error': str(exc),
|
||||
'metadata_patches': patches(),
|
||||
'authoritative_branches': [],
|
||||
}, 503)
|
||||
metadata = patches()
|
||||
branch_names = {item['name'] for item in branches}
|
||||
metadata_names = {
|
||||
item.get('branch') for item in metadata
|
||||
if isinstance(item, dict) and item.get('branch')
|
||||
}
|
||||
orphan = [
|
||||
item for item in metadata
|
||||
if item.get('branch') and item.get('branch') not in branch_names
|
||||
]
|
||||
missing = [
|
||||
item for item in branches
|
||||
if item['name'] not in metadata_names
|
||||
]
|
||||
return self.sendj({
|
||||
'metadata_patches': metadata,
|
||||
'authoritative_branches': branches,
|
||||
'orphan_metadata': orphan,
|
||||
'branches_without_metadata': missing,
|
||||
'repository_available': True,
|
||||
'query_error': None,
|
||||
'patches': metadata,
|
||||
'branches': [item['name'] for item in branches],
|
||||
})
|
||||
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
|
||||
if path in ('/api/run','/api/runtime-status','/run'):
|
||||
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
||||
return self.sendj({'service':'aios-gitops-model-api','status':svc.stdout.strip() or svc.stderr.strip(),'workspace_branch':sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']})
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
workspace_branch = None
|
||||
if (WS / '.git').exists():
|
||||
result = sh(['rev-parse','--abbrev-ref','HEAD'])
|
||||
workspace_branch = result['stdout'] or None
|
||||
payload = {
|
||||
'service':'aios-gitops-model-api',
|
||||
'status':svc.stdout.strip() or svc.stderr.strip(),
|
||||
'workspace_branch':workspace_branch,
|
||||
**runtime_release_info(),
|
||||
**snapshot,
|
||||
'writes_enabled': str(os.getenv('AIOS_GITOPS_WRITES_ENABLED','false')).lower() in {'1','true','yes','on'},
|
||||
}
|
||||
return self.sendj(payload, 200 if snapshot['repository_available'] else 503)
|
||||
if path in ('/api/action-monitor','/action-monitor'):
|
||||
latest=monitor.latest()
|
||||
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
|
||||
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
|
||||
if path == '/api/tag-publication-status':
|
||||
version=(q.get('version') or [''])[0]
|
||||
try:
|
||||
version=validate_version(version)
|
||||
status=publication_publisher().get_tag_publication_status(version)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'invalid release version'},400)
|
||||
return self.sendj(status)
|
||||
if path == '/api/release-consistency':
|
||||
request=publication_request_from_query(q)
|
||||
return self.sendj(publication_publisher().get_release_consistency(request))
|
||||
if path == '/api/release-acceptance':
|
||||
request=publication_request_from_query(q)
|
||||
return self.sendj(publication_publisher().run_release_acceptance(request))
|
||||
if path in (
|
||||
'/getReleaseCandidate',
|
||||
'/getReleasePlan',
|
||||
'/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope',
|
||||
'/getReleaseSaga',
|
||||
'/getCapabilityStatus',
|
||||
):
|
||||
candidate_id=(q.get('candidate_id') or [''])[0]
|
||||
if not candidate_id:
|
||||
return self.sendj({'error':'candidate_id is required'},400)
|
||||
service=silent_release_service()
|
||||
if path == '/getReleaseCandidate':
|
||||
return self.sendj(
|
||||
service.get_release_candidate(candidate_id)
|
||||
)
|
||||
if path == '/getApprovalEnvelope':
|
||||
return self.sendj(
|
||||
service.get_approval_envelope(candidate_id)
|
||||
)
|
||||
if path == '/getReleasePlan':
|
||||
return self.sendj(service.get_release_plan(candidate_id))
|
||||
if path == '/getReleaseAcceptance':
|
||||
return self.sendj(service.get_acceptance(candidate_id))
|
||||
if path == '/getCapabilityStatus':
|
||||
return self.sendj(
|
||||
service.get_capability_status(candidate_id)
|
||||
)
|
||||
return self.sendj(service.get_release_saga(candidate_id))
|
||||
if path == '/getRecoveryStatus':
|
||||
return self.sendj(
|
||||
silent_release_service().get_recovery_status()
|
||||
)
|
||||
# Admin AI Evolution Dashboard
|
||||
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
||||
return self.ai_evolution_page()
|
||||
if path in ('/admin/ai-evolution/api/data'):
|
||||
return self.ai_evolution_data()
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except FileNotFoundError:
|
||||
return self.sendj({'error':'release candidate not found'},404)
|
||||
except ValueError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
return self.sendj({'error':error},400)
|
||||
except Exception as e:
|
||||
monitor.record_failure('do_GET', str(e), {'path':path})
|
||||
return self.sendj({'error':str(e)},500)
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
return self.sendj({'error':error},500)
|
||||
def do_PUT(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'PUT', path): return
|
||||
data=self.body()
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/'); data=self.body()
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
)
|
||||
if path == '/createReleaseIntent':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_intent(data)
|
||||
)
|
||||
if path in ('/submitOwnerApproval','/submitSignedApproval'):
|
||||
a=self.need('release:approve')
|
||||
if not a: return
|
||||
return self.sendj(
|
||||
silent_release_service().submit_owner_approval(data)
|
||||
)
|
||||
if path == '/startDeployment':
|
||||
a=self.need('release:deploy')
|
||||
if not a: return
|
||||
return self.sendj(
|
||||
silent_release_service().start_deployment(
|
||||
str(data.get('candidate_id') or '')
|
||||
)
|
||||
)
|
||||
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
|
||||
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
||||
if path == '/api/commit': return self.commit(data)
|
||||
@@ -239,9 +634,30 @@ class H(BaseHTTPRequestHandler):
|
||||
if path == '/api/model-priority': return self.model_priority(data)
|
||||
if path == '/api/nl': return self.nl(data)
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},400)
|
||||
except CapabilityAuthorizationError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},403)
|
||||
except SagaConflict as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},409)
|
||||
except Exception as e:
|
||||
monitor.record_failure('do_POST', str(e), {'path':path})
|
||||
return self.sendj({'error':str(e)},500)
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_POST', error, {'path':path})
|
||||
return self.sendj({'error':error},500)
|
||||
def do_PATCH(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'PATCH', path): return
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_DELETE(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'DELETE', path): return
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def branch(self,data):
|
||||
a=self.need('branch:create');
|
||||
if not a: return
|
||||
@@ -265,33 +681,39 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||
return self.sendj({
|
||||
'error':'production_tag_evidence_required',
|
||||
'required':['version','target_oid','evidence_closure_oid','approval_plan_id'],
|
||||
},400)
|
||||
try:
|
||||
target_oid=resolve_commit_oid(data.get('target_oid'))
|
||||
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid') if data.get('evidence_closure_oid') else None
|
||||
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid')
|
||||
except ValueError as exc:
|
||||
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
|
||||
if evidence_oid and sh(['merge-base','--is-ancestor',target_oid,evidence_oid])['code'] != 0:
|
||||
return self.sendj({'error':'evidence_lineage_mismatch','target_oid':target_oid,'evidence_closure_oid':evidence_oid},409)
|
||||
if a['role'] != 'owner' or not data.get('execute'):
|
||||
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'request':req,'commit':c})
|
||||
current_target=existing_tag_target(ver)
|
||||
if current_target and current_target != target_oid:
|
||||
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'actual_target_oid':current_target,'approval_plan_id':data.get('approval_plan_id')})
|
||||
return self.sendj({'status':'tag_target_conflict','version':ver,'target_oid':current_target,'expected_target_oid':target_oid,'main_changed':False,'force_push':False},409)
|
||||
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
|
||||
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
||||
if current_target:
|
||||
status='tagged_existing'; out={'code':0,'stdout':'','stderr':'','cmd':['git','tag','existing']}
|
||||
else:
|
||||
status='tagged'; out=sh(['tag','-a',ver,target_oid,'-m',data.get('message') or f'release {ver}'], True)
|
||||
actual_target=existing_tag_target(ver)
|
||||
if actual_target != target_oid:
|
||||
raise RuntimeError(f'tag target verification failed: expected {target_oid}, got {actual_target}')
|
||||
tag_oid=sh(['rev-parse',f'refs/tags/{ver}'], True)['stdout']
|
||||
publisher=publication_publisher()
|
||||
try:
|
||||
publication=publisher.publish(request)
|
||||
except PublicationManualOwnerReviewRequired as exc:
|
||||
audit(a['account'],'tag_manual_owner_review_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':redact(exc)})
|
||||
return self.sendj({'status':'MANUAL_OWNER_REVIEW_REQUIRED','error':redact(exc),'main_changed':False,'force_push':False},409)
|
||||
except PublicationConflict as exc:
|
||||
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
||||
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},409)
|
||||
except PublicationPending as exc:
|
||||
audit(a['account'],'tag_pending',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
||||
return self.sendj({'status':'REMOTE_PUSH_PENDING','error':str(exc),'main_changed':False,'force_push':False},503)
|
||||
except PublicationReconciliationRequired as exc:
|
||||
audit(a['account'],'tag_reconciliation_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
||||
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},502)
|
||||
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
||||
if main_after != main_before:
|
||||
raise RuntimeError('main changed during tag operation')
|
||||
sync=sync_tag(ver)
|
||||
result={'status':status,'version':ver,'tag_name':ver,'tag_oid':tag_oid,'target_oid':actual_target,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'git':out,'sync':sync}
|
||||
result={'status':publication['state'],'version':ver,'tag_name':ver,'tag_oid':publication['tag_oid'],'target_oid':target_oid,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'publication':publication}
|
||||
audit(a['account'],'tag',ver,result)
|
||||
self.sendj(result)
|
||||
def rollback(self,data):
|
||||
@@ -388,7 +810,10 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
cmd_filter=(params.get('command') or [None])[0]
|
||||
bl=jload(WS/'baseline'/'current.json',{})
|
||||
current_ver=bl.get('current_baseline','V1R3C1')
|
||||
tags=sh(['tag','--list','V*'])['stdout'].splitlines() if WS.exists() else []
|
||||
try:
|
||||
tags=[item['name'] for item in authoritative_tags(git_dir=required_authoritative_git_dir()) if str(item['name']).startswith('V')]
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc),'repository_available':False},503)
|
||||
versions=[]
|
||||
if (WS/'baseline'/'promotions').exists():
|
||||
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
||||
@@ -426,7 +851,7 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed policy for in-process Git mutation handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitWriteDecision:
|
||||
allowed: bool
|
||||
status_code: int
|
||||
error: str
|
||||
reason: str
|
||||
workspace: str
|
||||
|
||||
def payload(self) -> dict[str, str]:
|
||||
return {
|
||||
"status": "allowed" if self.allowed else "blocked",
|
||||
"error": self.error,
|
||||
"reason": self.reason,
|
||||
"workspace": self.workspace,
|
||||
}
|
||||
|
||||
|
||||
def _real(path: Path) -> Path:
|
||||
return Path(os.path.realpath(os.path.abspath(str(path))))
|
||||
|
||||
|
||||
def evaluate_git_write(workspace: Path | str, env: Mapping[str, str] | None = None) -> GitWriteDecision:
|
||||
values = os.environ if env is None else env
|
||||
lexical = Path(os.path.abspath(str(workspace)))
|
||||
canonical = _real(lexical)
|
||||
shown = str(lexical)
|
||||
if str(values.get("AIOS_GITOPS_WRITES_ENABLED", "false")).strip().lower() not in _TRUE:
|
||||
return GitWriteDecision(False, 423, "gitops_writes_disabled", "AIOS_GITOPS_WRITES_ENABLED is false", shown)
|
||||
if lexical != canonical:
|
||||
return GitWriteDecision(False, 409, "workspace_symlink_forbidden", "workspace must be a canonical non-symlink path", shown)
|
||||
controller = _real(Path(values.get("AIOS_GITOPS_CONTROLLER_PATH", "/data/aios-gitops/worktrees/controller")))
|
||||
role = str(values.get("AIOS_GITOPS_WORKSPACE_ROLE", "")).strip().lower()
|
||||
if canonical == controller or role == "runtime-anchor":
|
||||
return GitWriteDecision(False, 423, "runtime_anchor_read_only", "controller/runtime-anchor is read-only", str(canonical))
|
||||
writer_root = _real(Path(values.get("AIOS_GITOPS_APPROVED_WRITER_ROOT", "/data/aios-gitops/worktrees")))
|
||||
try:
|
||||
canonical.relative_to(writer_root)
|
||||
except ValueError:
|
||||
return GitWriteDecision(False, 409, "workspace_not_approved_writer", "workspace is outside the approved writer root", str(canonical))
|
||||
return GitWriteDecision(True, 200, "", "approved isolated writer", str(canonical))
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Task-scoped hardened wrapper around the existing Action API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
import aios_isolated_writer_policy as policy
|
||||
from isolated_writer_authz import AuthorizationError, authorize
|
||||
from isolated_writer_commit_file import AtomicCommitFile, CommitFileError, OperationLedger
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
_reject = api.reject_non_read_http
|
||||
_write_file = api.write_file
|
||||
_sync_branch = api.sync_branch
|
||||
_original_do_get = api.H.do_GET
|
||||
api.OPENAPI = Path(os.getenv("AIOS_GITOPS_ISOLATED_WRITER_OPENAPI", str(api.OPENAPI)))
|
||||
|
||||
|
||||
def _registry() -> TokenRegistry:
|
||||
return TokenRegistry(Path(os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"]))
|
||||
|
||||
|
||||
def _atomic_writer() -> AtomicCommitFile:
|
||||
return AtomicCommitFile(
|
||||
workspace=api.WS,
|
||||
bare_repo=api.BARE_REPO,
|
||||
ledger=OperationLedger(Path(os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"])),
|
||||
remote=os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", ""),
|
||||
audit=policy.append_audit_record,
|
||||
)
|
||||
|
||||
|
||||
def _bearer(handler) -> str:
|
||||
value = handler.headers.get("Authorization", "")
|
||||
return value[7:].strip() if value.startswith("Bearer ") else ""
|
||||
|
||||
|
||||
def _authorize(handler, *, method: str, path: str, branch=None, target_path=None):
|
||||
grant = authorize(
|
||||
_registry(),
|
||||
_bearer(handler),
|
||||
method=method,
|
||||
path=path,
|
||||
source_ip=str(handler.client_address[0]),
|
||||
workspace=api.WS,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
grant.record["_source_ip"] = str(handler.client_address[0])
|
||||
return grant
|
||||
|
||||
|
||||
def _authorization_error(handler, exc: AuthorizationError):
|
||||
return handler.sendj({"error": exc.code}, exc.status)
|
||||
|
||||
|
||||
def _isolated_runtime_status(handler):
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
||||
return handler.sendj({
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
"status": "active",
|
||||
"runtime_role": os.getenv("AIOS_GITOPS_RUNTIME_ROLE"),
|
||||
"workspace_role": os.getenv("AIOS_GITOPS_WORKSPACE_ROLE"),
|
||||
"writes_enabled": os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").lower()
|
||||
in {"1", "true", "yes", "on"},
|
||||
"allowed_branch": policy.allowed_branch(),
|
||||
"approved_base_oid": policy.allowed_base_oid(),
|
||||
"workspace_branch": branch,
|
||||
"workspace_head_oid": head,
|
||||
"repository_identity": os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY"),
|
||||
"token_policy_version": 2,
|
||||
"atomic_commit_file": True,
|
||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
||||
})
|
||||
|
||||
|
||||
def _isolated_do_get(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path in {"/health", "/api/health"}:
|
||||
return self.sendj({
|
||||
"status": "ok",
|
||||
"service": os.getenv(
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE",
|
||||
"aios-isolated-writer.service",
|
||||
),
|
||||
})
|
||||
if path not in {"/api/runtime-status", "/api/baseline", "/api/patches"}:
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
_authorize(self, method="GET", path=path)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
if path == "/api/runtime-status":
|
||||
return _isolated_runtime_status(self)
|
||||
return _original_do_get(self)
|
||||
|
||||
|
||||
def _read_commit_file_body(handler):
|
||||
try:
|
||||
length = int(handler.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise CommitFileError("writer_content_length_rejected") from None
|
||||
if length <= 0:
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
if length > 1_100_000:
|
||||
raise CommitFileError("writer_request_too_large", 413)
|
||||
raw = handler.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise CommitFileError("writer_request_json_rejected") from None
|
||||
if not isinstance(value, dict):
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _isolated_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/api/commit-file":
|
||||
return self.sendj({"error": "isolated_writer_operation_forbidden"}, 403)
|
||||
try:
|
||||
data = _read_commit_file_body(self)
|
||||
branch = str(data.get("branch") or "")
|
||||
target_path = str(data.get("path") or "")
|
||||
grant = _authorize(
|
||||
self,
|
||||
method="POST",
|
||||
path=path,
|
||||
branch=branch,
|
||||
target_path=target_path,
|
||||
)
|
||||
if api.content_has_secret(str(data.get("content") or "")):
|
||||
raise CommitFileError("writer_secret_content_rejected", 403)
|
||||
result = _atomic_writer().execute(grant, data)
|
||||
return self.sendj(result)
|
||||
except AuthorizationError as exc:
|
||||
return _authorization_error(self, exc)
|
||||
except CommitFileError as exc:
|
||||
return self.sendj({"error": exc.code}, exc.status)
|
||||
except PermissionError:
|
||||
return self.sendj({"error": "isolated_writer_policy_rejected"}, 403)
|
||||
|
||||
|
||||
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
if _reject(handler, method, path):
|
||||
return True
|
||||
try:
|
||||
policy.validate_endpoint(method, path, api.WS, api.BARE_REPO)
|
||||
return False
|
||||
except PermissionError as exc:
|
||||
handler.sendj({"error": str(exc)}, 403)
|
||||
return True
|
||||
|
||||
|
||||
def checkout(branch, base="main", create=False):
|
||||
api.ensure_git()
|
||||
target = api.branch_name(branch)
|
||||
policy.validate_branch_request({"branch": target, "base": base})
|
||||
exists = api.sh(["rev-parse", "--verify", target])["code"] == 0
|
||||
if exists:
|
||||
if api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"] != target:
|
||||
api.sh(["checkout", target], True)
|
||||
elif create:
|
||||
api.sh(["checkout", "-b", target, base], True)
|
||||
else:
|
||||
raise PermissionError("isolated_writer_branch_missing")
|
||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
||||
return target
|
||||
|
||||
|
||||
def write_file(path, content):
|
||||
policy.validate_path(path, api.WS)
|
||||
return _write_file(path, content)
|
||||
|
||||
|
||||
def sync_branch(branch: str):
|
||||
policy.validate_sync_branch(branch)
|
||||
local = _sync_branch(branch)
|
||||
remote = os.getenv("AIOS_GITOPS_GITEA_PUSH_REMOTE", "").strip()
|
||||
if remote != "gitea-http":
|
||||
raise PermissionError("isolated_writer_gitea_remote_rejected")
|
||||
head = api.sh(["rev-parse", "HEAD"], True)["stdout"]
|
||||
pushed = subprocess.run(
|
||||
["git", "push", "--porcelain", remote, f"HEAD:refs/heads/{branch}"],
|
||||
cwd=api.WS,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if pushed.returncode != 0:
|
||||
raise RuntimeError("isolated_writer_gitea_push_failed")
|
||||
verified = subprocess.run(
|
||||
["git", "ls-remote", "--heads", remote, f"refs/heads/{branch}"],
|
||||
cwd=api.WS,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if verified.returncode != 0:
|
||||
raise RuntimeError("isolated_writer_gitea_verification_failed")
|
||||
fields = verified.stdout.strip().split()
|
||||
if len(fields) != 2 or fields[0] != head or fields[1] != f"refs/heads/{branch}":
|
||||
raise RuntimeError("isolated_writer_gitea_oid_mismatch")
|
||||
return {
|
||||
"status": "verified",
|
||||
"target": "authoritative-bare+gitea",
|
||||
"branch": branch,
|
||||
"oid": head,
|
||||
"local": local.get("status"),
|
||||
}
|
||||
|
||||
|
||||
def commit(paths, msg):
|
||||
policy.validate_commit_paths(paths, api.WS)
|
||||
api.ensure_git()
|
||||
selected = [api.relpath(p) for p in paths] if paths else ["."]
|
||||
api.sh(["add", *selected], True)
|
||||
policy.validate_staged_paths(api.WS)
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
if not api.sh(["status", "--short"])["stdout"]:
|
||||
return {"status": "no_changes", "branch": branch, "sync": sync_branch(branch)}
|
||||
out = api.sh(["commit", "-m", msg], True)
|
||||
branch = api.sh(["rev-parse", "--abbrev-ref", "HEAD"], True)["stdout"]
|
||||
return {
|
||||
"status": "committed",
|
||||
"branch": branch,
|
||||
"rev": api.sh(["rev-parse", "--short", "HEAD"], True)["stdout"],
|
||||
"git": out,
|
||||
"sync": sync_branch(branch),
|
||||
}
|
||||
|
||||
|
||||
def audit(actor, action, target, payload):
|
||||
policy.append_audit_record(
|
||||
{
|
||||
"at": api.now(),
|
||||
"actor": actor,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"payload": api.sanitize(payload),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_request(self, data, validate):
|
||||
try:
|
||||
validate(data)
|
||||
return True
|
||||
except PermissionError as exc:
|
||||
self.sendj({"error": str(exc)}, 403)
|
||||
return False
|
||||
|
||||
|
||||
def _checked_branch(self, data):
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _branch(self, data)
|
||||
|
||||
|
||||
def _checked_file(self, data):
|
||||
data = dict(data)
|
||||
data.setdefault("branch", policy.allowed_branch())
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
if not _validate_request(
|
||||
self,
|
||||
{"path": data.get("path", "")},
|
||||
lambda item: policy.validate_path(item["path"], api.WS),
|
||||
):
|
||||
return
|
||||
return _file(self, data)
|
||||
|
||||
|
||||
def _checked_commit(self, data):
|
||||
data = dict(data)
|
||||
data.setdefault("branch", policy.allowed_branch())
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _commit(self, data)
|
||||
|
||||
|
||||
def _checked_patch(self, data):
|
||||
data = dict(data)
|
||||
data["branch"] = policy.allowed_branch()
|
||||
if not _validate_request(self, data, policy.validate_branch_request):
|
||||
return
|
||||
return _patch(self, data)
|
||||
|
||||
|
||||
_branch, _file, _commit, _patch = api.H.branch, api.H.file, api.H.commit, api.H.patch
|
||||
api.reject_non_read_http = reject_non_read_http
|
||||
api.checkout, api.write_file, api.sync_branch, api.commit, api.audit = (
|
||||
checkout,
|
||||
write_file,
|
||||
sync_branch,
|
||||
commit,
|
||||
audit,
|
||||
)
|
||||
api.H.branch, api.H.file, api.H.commit, api.H.patch = (
|
||||
_checked_branch,
|
||||
_checked_file,
|
||||
_checked_commit,
|
||||
_checked_patch,
|
||||
)
|
||||
api.H.do_GET = _isolated_do_get
|
||||
api.H.do_POST = _isolated_do_post
|
||||
|
||||
if __name__ == "__main__":
|
||||
policy.require_service_invariants(api.WS, api.BARE_REPO)
|
||||
api.ThreadingHTTPServer((api.HOST, api.PORT), api.H).serve_forever()
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Fail-closed policy used only by the task-scoped Action writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import re
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import unquote
|
||||
|
||||
import isolated_writer_path_policy as path_policy
|
||||
|
||||
WRITE_ENDPOINTS = frozenset({"/api/commit-file"})
|
||||
ALLOWED_ROOTS = (
|
||||
"active-runtime",
|
||||
"docs",
|
||||
"patches",
|
||||
"reports",
|
||||
"scripts",
|
||||
"test",
|
||||
"tests",
|
||||
)
|
||||
DENIED_ROOTS = ("baseline", "config", "deploy", "deployments", "ops", "runtime", "services")
|
||||
_TRUE = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE = frozenset({"0", "false", "no", "off"})
|
||||
_DENY_FLAGS = (
|
||||
"AIOS_GITOPS_ALLOW_MAIN_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_TAG_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_BASELINE_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_REGISTRY_WRITE",
|
||||
"AIOS_GITOPS_ALLOW_ROLLBACK",
|
||||
"AIOS_GITOPS_ALLOW_DEPLOYMENT",
|
||||
)
|
||||
|
||||
|
||||
def enforcement_required() -> bool:
|
||||
return os.getenv("AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT", "") == "required"
|
||||
|
||||
|
||||
def allowed_branch() -> str:
|
||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "").strip()
|
||||
if not value:
|
||||
raise PermissionError("isolated_writer_missing_allowed_branch")
|
||||
return value
|
||||
|
||||
|
||||
def allowed_base_oid() -> str:
|
||||
value = os.getenv("AIOS_GITOPS_ALLOWED_BASE_OID", "").strip().lower()
|
||||
if len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise PermissionError("isolated_writer_invalid_allowed_base_oid")
|
||||
return value
|
||||
|
||||
|
||||
def _git(workspace: Path, *args: str) -> str:
|
||||
result = subprocess.run(["git", *args], cwd=workspace, text=True, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
raise PermissionError("isolated_writer_git_validation_failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def require_service_invariants(workspace: Path, bare_repo: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
if os.getenv("AIOS_GITOPS_WRITES_ENABLED", "").strip().lower() not in _TRUE:
|
||||
raise PermissionError("isolated_writer_write_gate_required")
|
||||
if os.getenv("AIOS_GITOPS_RUNTIME_ROLE", "") != "isolated-writer":
|
||||
raise PermissionError("isolated_writer_role_required")
|
||||
if os.getenv("AIOS_GITOPS_WORKSPACE_ROLE", "") != "isolated-writer":
|
||||
raise PermissionError("isolated_writer_workspace_role_required")
|
||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
||||
raise PermissionError("isolated_writer_token_policy_v2_required")
|
||||
if not os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", "").strip():
|
||||
raise PermissionError("isolated_writer_service_audience_required")
|
||||
if not os.getenv("AIOS_GITOPS_TOKEN_REGISTRY_V2", "").strip():
|
||||
raise PermissionError("isolated_writer_token_registry_required")
|
||||
if not os.getenv("AIOS_GITOPS_WRITER_OPERATION_LEDGER", "").strip():
|
||||
raise PermissionError("isolated_writer_operation_ledger_required")
|
||||
path_policy.configured_path_policy()
|
||||
for name in _DENY_FLAGS:
|
||||
if os.getenv(name, "").strip().lower() not in _FALSE:
|
||||
raise PermissionError("isolated_writer_forbidden_capability_flag")
|
||||
if os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "") != "aios/aios":
|
||||
raise PermissionError("isolated_writer_repository_identity_rejected")
|
||||
configured_bare_value = os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "").strip()
|
||||
if not configured_bare_value:
|
||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
||||
configured_bare = Path(configured_bare_value)
|
||||
if configured_bare.resolve(strict=True) != bare_repo.resolve(strict=True):
|
||||
raise PermissionError("isolated_writer_authoritative_repo_rejected")
|
||||
writer_root_value = os.getenv("AIOS_GITOPS_APPROVED_WRITER_ROOT", "").strip()
|
||||
if not writer_root_value:
|
||||
raise PermissionError("isolated_writer_workspace_outside_approved_root")
|
||||
writer_root = Path(writer_root_value).resolve(strict=True)
|
||||
lexical_workspace = Path(os.path.abspath(str(workspace)))
|
||||
canonical_workspace = lexical_workspace.resolve(strict=True)
|
||||
if lexical_workspace != canonical_workspace:
|
||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
||||
try:
|
||||
canonical_workspace.relative_to(writer_root)
|
||||
except (ValueError, FileNotFoundError):
|
||||
raise PermissionError("isolated_writer_workspace_outside_approved_root") from None
|
||||
if _git(workspace, "rev-parse", "--abbrev-ref", "HEAD") != allowed_branch():
|
||||
raise PermissionError("isolated_writer_current_branch_rejected")
|
||||
approved_base = allowed_base_oid()
|
||||
try:
|
||||
_git(workspace, "cat-file", "-e", f"{approved_base}^{{commit}}")
|
||||
subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", approved_base, "HEAD"],
|
||||
cwd=workspace,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (PermissionError, subprocess.CalledProcessError):
|
||||
raise PermissionError("isolated_writer_approved_base_not_ancestor") from None
|
||||
if _git(workspace, "merge-base", approved_base, "HEAD") != approved_base:
|
||||
raise PermissionError("isolated_writer_merge_base_rejected")
|
||||
common = Path(_git(workspace, "rev-parse", "--git-common-dir"))
|
||||
if not common.is_absolute():
|
||||
common = (workspace / common).resolve()
|
||||
if common.resolve() != bare_repo.resolve():
|
||||
raise PermissionError("isolated_writer_common_repo_rejected")
|
||||
|
||||
|
||||
def approved_audit_log_path() -> Path:
|
||||
root_value = os.getenv("AIOS_GITOPS_APPROVED_AUDIT_ROOT", "").strip()
|
||||
log_value = os.getenv("AIOS_GITOPS_AUDIT_LOG", "").strip()
|
||||
if not root_value or not log_value:
|
||||
raise PermissionError("isolated_writer_audit_path_missing")
|
||||
lexical_root = Path(os.path.abspath(root_value))
|
||||
lexical_log = Path(os.path.abspath(log_value))
|
||||
root = lexical_root.resolve(strict=True)
|
||||
if lexical_root != root or root.is_symlink() or not root.is_dir():
|
||||
raise PermissionError("isolated_writer_audit_root_rejected")
|
||||
try:
|
||||
lexical_log.relative_to(root)
|
||||
except ValueError:
|
||||
raise PermissionError("isolated_writer_audit_path_outside_approved_root") from None
|
||||
if lexical_log.parent.resolve(strict=True) != lexical_log.parent:
|
||||
raise PermissionError("isolated_writer_audit_parent_symlink_rejected")
|
||||
if lexical_log.exists() and lexical_log.is_symlink():
|
||||
raise PermissionError("isolated_writer_audit_log_symlink_rejected")
|
||||
os.chmod(root, 0o750)
|
||||
os.chmod(lexical_log.parent, 0o750)
|
||||
return lexical_log
|
||||
|
||||
|
||||
def append_audit_record(record: dict) -> None:
|
||||
path = approved_audit_log_path()
|
||||
flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
info = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise PermissionError("isolated_writer_audit_log_not_regular")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
line = json.dumps(
|
||||
_audit_sanitize(record),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8") + b"\n"
|
||||
os.write(descriptor, line)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
parent_fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _audit_sanitize(value):
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: (
|
||||
"[REDACTED]"
|
||||
if any(
|
||||
marker in str(key).lower()
|
||||
for marker in (
|
||||
"authorization",
|
||||
"credential",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"private_key",
|
||||
)
|
||||
)
|
||||
and str(key).lower() != "token_id"
|
||||
else _audit_sanitize(inner)
|
||||
)
|
||||
for key, inner in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_audit_sanitize(item) for item in value]
|
||||
if isinstance(value, str) and re.search(
|
||||
r"(?i)(authorization\s*:\s*bearer|(?:token|password|secret)\s*[:=])",
|
||||
value,
|
||||
):
|
||||
return "[REDACTED]"
|
||||
return value
|
||||
|
||||
|
||||
def validate_endpoint(method: str, path: str, workspace: Path, bare_repo: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
if method != "POST" or path not in WRITE_ENDPOINTS:
|
||||
raise PermissionError("isolated_writer_operation_forbidden")
|
||||
require_service_invariants(workspace, bare_repo)
|
||||
|
||||
|
||||
def validate_branch_request(data: dict) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
branch = allowed_branch() if "branch" not in data else str(data.get("branch") or "").strip()
|
||||
if branch != allowed_branch():
|
||||
raise PermissionError("isolated_writer_branch_rejected")
|
||||
if str(data.get("base", "main")).strip() != "main":
|
||||
raise PermissionError("isolated_writer_base_rejected")
|
||||
if data.get("force") or data.get("force_push"):
|
||||
raise PermissionError("isolated_writer_force_rejected")
|
||||
|
||||
|
||||
def validate_path(path: str, workspace: Path | None = None) -> str:
|
||||
if not isinstance(path, str):
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
value = path
|
||||
if not value or value != value.strip() or "\x00" in value or "\\" in value:
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
try:
|
||||
decoded = unquote(value, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise PermissionError("isolated_writer_path_rejected") from None
|
||||
if decoded != value:
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
windows = PureWindowsPath(value)
|
||||
posix = PurePosixPath(value)
|
||||
parts = value.split("/")
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise PermissionError("isolated_writer_path_rejected")
|
||||
first = parts[0]
|
||||
service_exact_path = False
|
||||
if first in DENIED_ROOTS:
|
||||
try:
|
||||
path_policy.require_service_path("commit_file.atomic", value)
|
||||
service_exact_path = True
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
if value != "README.md" and first not in ALLOWED_ROOTS and not service_exact_path:
|
||||
raise PermissionError("isolated_writer_path_outside_allowed_roots")
|
||||
if workspace is not None:
|
||||
lexical_root = Path(os.path.abspath(str(workspace)))
|
||||
root = lexical_root.resolve(strict=True)
|
||||
if lexical_root != root:
|
||||
raise PermissionError("isolated_writer_workspace_symlink_rejected")
|
||||
current = root
|
||||
for part in parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise PermissionError("isolated_writer_path_symlink_rejected")
|
||||
candidate = (root / value).resolve(strict=False)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError:
|
||||
raise PermissionError("isolated_writer_path_escapes_workspace") from None
|
||||
return value
|
||||
|
||||
|
||||
def validate_content_for_path(path: str, content: str) -> None:
|
||||
if path.split("/", 1)[0] not in DENIED_ROOTS:
|
||||
return
|
||||
try:
|
||||
path_policy.validate_content("commit_file.atomic", path, content)
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def configured_path_policy_sha256() -> str | None:
|
||||
try:
|
||||
return path_policy.configured_path_policy_sha256()
|
||||
except path_policy.PathPolicyError as exc:
|
||||
raise PermissionError(str(exc)) from None
|
||||
|
||||
|
||||
def validate_commit_paths(paths: list[str], workspace: Path | None = None) -> None:
|
||||
if enforcement_required():
|
||||
for path in paths:
|
||||
if str(path).strip() != ".":
|
||||
validate_path(str(path), workspace)
|
||||
|
||||
|
||||
def validate_staged_paths(workspace: Path) -> None:
|
||||
if not enforcement_required():
|
||||
return
|
||||
for path in _git(workspace, "diff", "--cached", "--name-only").splitlines():
|
||||
validate_path(path, workspace)
|
||||
|
||||
|
||||
def validate_sync_branch(branch: str) -> None:
|
||||
if enforcement_required() and branch != allowed_branch():
|
||||
raise PermissionError("isolated_writer_sync_branch_rejected")
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Fail-closed access to the authoritative bare Git repository."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
_ALLOWED_COMMANDS = frozenset(
|
||||
{
|
||||
"cat-file",
|
||||
"config",
|
||||
"diff",
|
||||
"for-each-ref",
|
||||
"ls-remote",
|
||||
"merge-base",
|
||||
"push",
|
||||
"remote",
|
||||
"rev-parse",
|
||||
"show-ref",
|
||||
"tag",
|
||||
}
|
||||
)
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_MAX_OUTPUT = 1_048_576
|
||||
_TIMEOUT = 8.0
|
||||
|
||||
|
||||
class AuthoritativeGitError(RuntimeError):
|
||||
"""A fail-closed authoritative repository or ref query error."""
|
||||
|
||||
|
||||
class AuthoritativeGitNotFound(AuthoritativeGitError):
|
||||
"""A validated revision does not exist in the authoritative repository."""
|
||||
|
||||
|
||||
class AuthoritativeGitOutputTooLarge(AuthoritativeGitError):
|
||||
"""A controlled Git query exceeded the response-size boundary."""
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
value = str(text or "")
|
||||
value = re.sub(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+", r"\1REDACTED", value)
|
||||
value = re.sub(r"(?i)((?:access_)?token\s*[:=]\s*)\S+", r"\1REDACTED", value)
|
||||
value = re.sub(r"(?i)(https?://[^/\s:@]+:)[^@\s/]+@", r"\1REDACTED@", value)
|
||||
return value
|
||||
|
||||
|
||||
def _configured_path(git_dir: str | Path | None = None) -> Path:
|
||||
raw = str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
||||
if not raw:
|
||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute() or ".." in path.parts:
|
||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE") from exc
|
||||
if not resolved.is_dir():
|
||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
||||
return resolved
|
||||
|
||||
|
||||
def _run(
|
||||
git_dir: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool,
|
||||
timeout: float = _TIMEOUT,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if not args or args[0] not in _ALLOWED_COMMANDS:
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(git_dir), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED") from exc
|
||||
if len(result.stdout) + len(result.stderr) > _MAX_OUTPUT:
|
||||
raise AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")
|
||||
if check and result.returncode != 0:
|
||||
detail = _redact(result.stderr.strip() or result.stdout.strip())
|
||||
raise AuthoritativeGitError(
|
||||
"GIT_REF_QUERY_FAILED" + (f": {detail[:160]}" if detail else "")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _repository_identity_from_remote(url: str) -> str:
|
||||
value = str(url or "").strip().split("?", 1)[0].rstrip("/")
|
||||
if not value:
|
||||
return ""
|
||||
if "://" in value:
|
||||
value = urllib.parse.urlparse(value).path
|
||||
elif ":" in value:
|
||||
value = value.rsplit(":", 1)[1]
|
||||
value = value.rsplit("@", 1)[-1].strip("/")
|
||||
value = re.sub(r"\.git$", "", value, flags=re.IGNORECASE)
|
||||
parts = [part for part in value.split("/") if part]
|
||||
if len(parts) >= 2:
|
||||
value = "/".join(parts[-2:])
|
||||
return value.strip("/").lower()
|
||||
|
||||
|
||||
def verify_repository(
|
||||
git_dir: str | Path | None = None,
|
||||
repository_identity: str | None = None,
|
||||
) -> tuple[Path, str]:
|
||||
path = _configured_path(git_dir)
|
||||
expected = str(
|
||||
repository_identity
|
||||
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
||||
).strip().strip("/")
|
||||
if not expected or expected.count("/") != 1:
|
||||
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
bare = _run(path, ["rev-parse", "--is-bare-repository"], check=True)
|
||||
if bare.stdout.strip().lower() != "true":
|
||||
raise AuthoritativeGitError("AUTHORITATIVE_REPOSITORY_UNAVAILABLE")
|
||||
remote = _run(path, ["config", "--get", "remote.gitea.url"], check=False)
|
||||
actual = _repository_identity_from_remote(remote.stdout)
|
||||
if actual != expected.lower():
|
||||
raise AuthoritativeGitError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
return path, expected
|
||||
|
||||
|
||||
def run_authoritative_git(
|
||||
args: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
git_dir: str | Path | None = None,
|
||||
timeout: float = _TIMEOUT,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
path, _ = verify_repository(git_dir=git_dir)
|
||||
return _run(path, list(args), check=check, timeout=timeout)
|
||||
|
||||
|
||||
def authoritative_head_oid(
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> str:
|
||||
result = run_authoritative_git(["rev-parse", "--verify", "refs/heads/main"], git_dir=git_dir)
|
||||
oid = result.stdout.strip().lower()
|
||||
if not _OID_RE.fullmatch(oid):
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
return oid
|
||||
|
||||
|
||||
def authoritative_tags(
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> list[dict[str, str | None]]:
|
||||
result = run_authoritative_git(
|
||||
[
|
||||
"for-each-ref",
|
||||
"refs/tags",
|
||||
"--format=%(refname:strip=2)\t%(objectname)\t%(objecttype)",
|
||||
],
|
||||
git_dir=git_dir,
|
||||
)
|
||||
tags: list[dict[str, str | None]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
name, oid, object_type = (line.split("\t", 2) + ["", ""])[:3]
|
||||
if not name or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
peeled_result = run_authoritative_git(
|
||||
["rev-parse", "--verify", f"refs/tags/{name}^{{commit}}"],
|
||||
git_dir=git_dir,
|
||||
)
|
||||
peeled = peeled_result.stdout.strip().lower()
|
||||
if not _OID_RE.fullmatch(peeled):
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
tags.append(
|
||||
{
|
||||
"name": name,
|
||||
"tag_oid": oid.lower(),
|
||||
"object_type": object_type,
|
||||
"peeled_target_oid": peeled,
|
||||
}
|
||||
)
|
||||
return tags
|
||||
|
||||
|
||||
def authoritative_branches(
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
result = run_authoritative_git(
|
||||
[
|
||||
"for-each-ref",
|
||||
"refs/heads",
|
||||
"--format=%(refname:strip=2)\t%(objectname)",
|
||||
],
|
||||
git_dir=git_dir,
|
||||
)
|
||||
branches: list[dict[str, str]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
name, oid = (line.split("\t", 1) + [""])[:2]
|
||||
if not name or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
branches.append({"name": name, "oid": oid.lower()})
|
||||
return branches
|
||||
|
||||
|
||||
def authoritative_commit(
|
||||
oid: str,
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> str:
|
||||
value = str(oid or "").strip().lower()
|
||||
if not _OID_RE.fullmatch(value):
|
||||
raise ValueError("commit OID must be a full 40-character value")
|
||||
result = run_authoritative_git(
|
||||
["rev-parse", "--verify", f"{value}^{{commit}}"],
|
||||
git_dir=git_dir,
|
||||
)
|
||||
resolved = result.stdout.strip().lower()
|
||||
if resolved != value:
|
||||
raise AuthoritativeGitError("GIT_REF_QUERY_FAILED")
|
||||
return resolved
|
||||
|
||||
|
||||
def authoritative_lineage(
|
||||
ancestor: str,
|
||||
descendant: str,
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> None:
|
||||
authoritative_commit(ancestor, git_dir=git_dir)
|
||||
authoritative_commit(descendant, git_dir=git_dir)
|
||||
result = run_authoritative_git(
|
||||
["merge-base", "--is-ancestor", ancestor, descendant],
|
||||
check=False,
|
||||
git_dir=git_dir,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthoritativeGitError("evidence lineage mismatch")
|
||||
|
||||
|
||||
_SAFE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
|
||||
|
||||
def _safe_ref_name(value: str) -> bool:
|
||||
return bool(
|
||||
_SAFE_REF_RE.fullmatch(value)
|
||||
and ".." not in value
|
||||
and "//" not in value
|
||||
and "@{" not in value
|
||||
and not value.endswith(("/", ".", ".lock"))
|
||||
and not any(part in {"", ".", ".."} for part in value.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def validate_git_revision(
|
||||
value: str,
|
||||
field: str,
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> str:
|
||||
revision = str(value or "").strip()
|
||||
if not revision or revision != str(value or "") or revision.startswith("-"):
|
||||
raise ValueError(f"invalid {field} revision")
|
||||
if _OID_RE.fullmatch(revision.lower()):
|
||||
try:
|
||||
return authoritative_commit(revision.lower(), git_dir=git_dir)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise AuthoritativeGitNotFound(f"{field} revision not found") from exc
|
||||
if not _safe_ref_name(revision) or any(token in revision for token in ("^", "~", ":", "\\")):
|
||||
raise ValueError(f"invalid {field} revision")
|
||||
if revision.startswith("refs/"):
|
||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
||||
raise ValueError(f"invalid {field} revision")
|
||||
candidates = [revision]
|
||||
else:
|
||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
||||
matches: list[str] = []
|
||||
for candidate in candidates:
|
||||
result = run_authoritative_git(
|
||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
||||
check=False,
|
||||
git_dir=git_dir,
|
||||
)
|
||||
oid = result.stdout.strip().lower()
|
||||
if result.returncode == 0 and _OID_RE.fullmatch(oid):
|
||||
matches.append(oid)
|
||||
if not matches:
|
||||
raise AuthoritativeGitNotFound(f"{field} revision not found")
|
||||
if len(set(matches)) != 1 or len(matches) != 1:
|
||||
raise ValueError(f"ambiguous {field} revision")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def authoritative_diff(
|
||||
from_revision: str,
|
||||
to_revision: str,
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
path, _ = verify_repository(git_dir=git_dir)
|
||||
from_oid = validate_git_revision(from_revision, "from", git_dir=path)
|
||||
to_oid = validate_git_revision(to_revision, "to", git_dir=path)
|
||||
args = ["diff", "--no-ext-diff", "--no-textconv", from_oid, to_oid]
|
||||
result = run_authoritative_git(args, git_dir=path)
|
||||
return {
|
||||
"code": 0,
|
||||
"stdout": result.stdout,
|
||||
"stderr": "",
|
||||
"cmd": ["git", f"--git-dir={path}", *args],
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
}
|
||||
|
||||
|
||||
def repository_snapshot(
|
||||
*,
|
||||
git_dir: str | Path | None = None,
|
||||
repository_identity: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
configured = bool(
|
||||
str(git_dir or os.getenv("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")).strip()
|
||||
)
|
||||
try:
|
||||
path, identity = verify_repository(
|
||||
git_dir=git_dir,
|
||||
repository_identity=repository_identity,
|
||||
)
|
||||
head = authoritative_head_oid(git_dir=path)
|
||||
return {
|
||||
"repository_available": True,
|
||||
"authoritative_git_dir_configured": configured,
|
||||
"authoritative_git_dir_readable": True,
|
||||
"authoritative_git_dir": str(path),
|
||||
"authoritative_head_oid": head,
|
||||
"repository_identity": identity,
|
||||
"query_error": None,
|
||||
}
|
||||
except (AuthoritativeGitError, ValueError) as exc:
|
||||
return {
|
||||
"repository_available": False,
|
||||
"authoritative_git_dir_configured": configured,
|
||||
"authoritative_git_dir_readable": False,
|
||||
"authoritative_git_dir": None,
|
||||
"authoritative_head_oid": None,
|
||||
"repository_identity": str(
|
||||
repository_identity
|
||||
or os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
||||
).strip() or None,
|
||||
"query_error": _redact(str(exc)),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
"""Fail-closed operation authorization for an isolated Git writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
|
||||
ROUTE_OPERATIONS = {
|
||||
("GET", "/api/runtime-status"): "runtime.status.read",
|
||||
("GET", "/api/baseline"): "baseline.read",
|
||||
("GET", "/api/patches"): "patches.read",
|
||||
("POST", "/api/commit-file"): "commit_file.atomic",
|
||||
}
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
|
||||
|
||||
class AuthorizationError(PermissionError):
|
||||
def __init__(self, code: str, status: int = 403):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizedGrant:
|
||||
record: dict[str, Any]
|
||||
operation: str
|
||||
use_count: int
|
||||
|
||||
@property
|
||||
def token_id(self) -> str:
|
||||
return str(self.record["token_id"])
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return str(self.record["task_id"])
|
||||
|
||||
|
||||
def operation_for(method: str, path: str) -> str:
|
||||
operation = ROUTE_OPERATIONS.get((method.upper(), path))
|
||||
if not operation:
|
||||
raise AuthorizationError("isolated_writer_operation_forbidden")
|
||||
return operation
|
||||
|
||||
|
||||
def _parse_time(value: Any) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
raise AuthorizationError("writer_token_time_invalid") from None
|
||||
if parsed.tzinfo is None:
|
||||
raise AuthorizationError("writer_token_time_invalid")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _git_is_ancestor(workspace: Path, base: str, branch: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", base, branch],
|
||||
cwd=workspace,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _validate_grant(
|
||||
grant: dict[str, Any],
|
||||
*,
|
||||
audience: str,
|
||||
source_ip: str,
|
||||
operation: str,
|
||||
branch: str | None,
|
||||
path: str | None,
|
||||
workspace: Path,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
if int(grant.get("schema_version", 0)) != 2:
|
||||
raise AuthorizationError("writer_token_policy_v2_required", 401)
|
||||
if str(grant.get("audience") or "") != audience:
|
||||
raise AuthorizationError("writer_token_audience_rejected")
|
||||
try:
|
||||
peer = ipaddress.ip_address(source_ip)
|
||||
networks = [ipaddress.ip_network(str(value), strict=True) for value in grant.get("source_cidrs", [])]
|
||||
except ValueError:
|
||||
raise AuthorizationError("writer_token_source_rejected") from None
|
||||
if not networks or not any(peer in network for network in networks):
|
||||
raise AuthorizationError("writer_token_source_rejected")
|
||||
created = _parse_time(grant.get("created_at"))
|
||||
not_before = _parse_time(grant.get("not_before"))
|
||||
expires = _parse_time(grant.get("expires_at"))
|
||||
if expires <= not_before or (expires - created).total_seconds() > 1800:
|
||||
raise AuthorizationError("writer_token_lifetime_rejected")
|
||||
if current_time < not_before:
|
||||
raise AuthorizationError("writer_token_not_yet_valid", 401)
|
||||
if current_time >= expires:
|
||||
raise AuthorizationError("writer_token_expired", 401)
|
||||
if bool(grant.get("revoked")):
|
||||
raise AuthorizationError("writer_token_revoked", 401)
|
||||
operations = grant.get("allowed_operations")
|
||||
if not isinstance(operations, list) or operation not in operations:
|
||||
raise AuthorizationError("writer_token_operation_rejected")
|
||||
expected_branch = str(grant.get("allowed_branch") or "")
|
||||
configured_branch = os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", "")
|
||||
if expected_branch != configured_branch:
|
||||
raise AuthorizationError("writer_token_branch_rejected")
|
||||
if branch is not None and branch != expected_branch:
|
||||
raise AuthorizationError("writer_token_branch_rejected")
|
||||
if path is not None:
|
||||
paths = grant.get("allowed_paths")
|
||||
if not isinstance(paths, list) or path not in paths:
|
||||
raise AuthorizationError("writer_token_path_rejected")
|
||||
base = str(grant.get("approved_base_oid") or "").lower()
|
||||
if not OID_RE.fullmatch(base):
|
||||
raise AuthorizationError("writer_token_base_rejected")
|
||||
if not _git_is_ancestor(workspace, base, expected_branch):
|
||||
raise AuthorizationError("writer_token_base_not_ancestor")
|
||||
|
||||
|
||||
def authorize(
|
||||
registry: TokenRegistry,
|
||||
token: str,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
source_ip: str,
|
||||
workspace: Path,
|
||||
branch: str | None = None,
|
||||
target_path: str | None = None,
|
||||
current_time: datetime | None = None,
|
||||
) -> AuthorizedGrant:
|
||||
if os.getenv("AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED", "") != "2":
|
||||
raise AuthorizationError("writer_token_policy_v2_not_required")
|
||||
if not token:
|
||||
raise AuthorizationError("unauthorized", 401)
|
||||
grant = registry.find(token)
|
||||
if grant is None:
|
||||
raise AuthorizationError("unauthorized", 401)
|
||||
operation = operation_for(method, path)
|
||||
_validate_grant(
|
||||
grant,
|
||||
audience=os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""),
|
||||
source_ip=source_ip,
|
||||
operation=operation,
|
||||
branch=branch,
|
||||
path=target_path,
|
||||
workspace=workspace,
|
||||
current_time=current_time or datetime.now(timezone.utc),
|
||||
)
|
||||
limits = grant.get("max_uses")
|
||||
if not isinstance(limits, dict):
|
||||
raise AuthorizationError("writer_token_use_limit_missing")
|
||||
try:
|
||||
used = registry.consume(str(grant["token_id"]), operation, int(limits[operation]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise AuthorizationError("writer_token_use_limit_missing") from None
|
||||
except PermissionError as exc:
|
||||
code = str(exc)
|
||||
raise AuthorizationError(code, 401 if code == "writer_token_revoked" else 403) from None
|
||||
return AuthorizedGrant(grant, operation, used)
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Atomic, idempotent commit-file Saga for the isolated writer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aios_isolated_writer_policy as policy
|
||||
|
||||
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
IDEMPOTENCY_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,127}")
|
||||
STATES = frozenset({
|
||||
"PENDING", "FILE_WRITTEN", "COMMITTED", "PUSHED", "VERIFIED", "COMPLETED",
|
||||
"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED",
|
||||
})
|
||||
_LOCKS: dict[str, threading.Lock] = {}
|
||||
_LOCKS_GUARD = threading.Lock()
|
||||
_LEDGER_INIT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class CommitFileError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
class SimulatedCrash(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_digest(data: dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"branch": data["branch"],
|
||||
"path": data["path"],
|
||||
"content_sha256": hashlib.sha256(data["content"].encode("utf-8")).hexdigest(),
|
||||
"message": data["message"],
|
||||
"expected_head_oid": data["expected_head_oid"],
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
class OperationLedger:
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
||||
raise PermissionError("writer_operation_ledger_symlink_rejected")
|
||||
with _LEDGER_INIT_LOCK:
|
||||
with self.connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS writer_operations (
|
||||
operation_id TEXT PRIMARY KEY,
|
||||
token_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
operation_digest TEXT NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
content_sha256 TEXT NOT NULL,
|
||||
pre_head_oid TEXT NOT NULL,
|
||||
commit_oid TEXT,
|
||||
push_status TEXT,
|
||||
verification_status TEXT,
|
||||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
error_code TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
return db
|
||||
|
||||
def get_or_create(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
||||
key = data["idempotency_key"]
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute(
|
||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
||||
).fetchone()
|
||||
if row:
|
||||
db.commit()
|
||||
record = dict(row)
|
||||
if record["operation_digest"] != digest:
|
||||
raise CommitFileError("writer_idempotency_conflict", 409)
|
||||
if (
|
||||
record["token_id"] != grant.token_id
|
||||
or record["task_id"] != grant.task_id
|
||||
):
|
||||
raise CommitFileError("writer_idempotency_owner_conflict", 409)
|
||||
return record
|
||||
operation_id = hashlib.sha256(
|
||||
f"{grant.task_id}\0{key}".encode("utf-8")
|
||||
).hexdigest()
|
||||
content_sha = hashlib.sha256(data["content"].encode("utf-8")).hexdigest()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO writer_operations(
|
||||
operation_id,token_id,task_id,idempotency_key,operation_digest,
|
||||
branch,path,content_sha256,pre_head_oid,state
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
operation_id, grant.token_id, grant.task_id, key, digest,
|
||||
data["branch"], data["path"], content_sha,
|
||||
data["expected_head_oid"], "PENDING",
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
return self.get(key)
|
||||
|
||||
def get(self, key: str) -> dict[str, Any]:
|
||||
with self.connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT * FROM writer_operations WHERE idempotency_key=?", (key,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(key)
|
||||
return dict(row)
|
||||
|
||||
def update(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
||||
if state not in STATES:
|
||||
raise ValueError("invalid writer operation state")
|
||||
allowed = {"commit_oid", "push_status", "verification_status", "error_code"}
|
||||
assignments = ["state=?", "updated_at=CURRENT_TIMESTAMP"]
|
||||
values: list[Any] = [state]
|
||||
for name, value in fields.items():
|
||||
if name not in allowed:
|
||||
raise ValueError("invalid ledger field")
|
||||
assignments.append(f"{name}=?")
|
||||
values.append(value)
|
||||
values.append(key)
|
||||
with self.connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute(
|
||||
f"UPDATE writer_operations SET {','.join(assignments)} WHERE idempotency_key=?",
|
||||
values,
|
||||
)
|
||||
db.commit()
|
||||
return self.get(key)
|
||||
|
||||
|
||||
class AtomicCommitFile:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: Path,
|
||||
bare_repo: Path,
|
||||
ledger: OperationLedger,
|
||||
remote: str,
|
||||
audit,
|
||||
crash_after_state: str | None = None,
|
||||
):
|
||||
self.workspace = workspace
|
||||
self.bare_repo = bare_repo
|
||||
self.ledger = ledger
|
||||
self.remote = remote
|
||||
self.audit = audit
|
||||
self.crash_after_state = crash_after_state
|
||||
|
||||
def _git(self, *args: str, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=self.workspace, text=True, capture_output=True
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise CommitFileError("writer_git_operation_failed", 500)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _transition(self, key: str, state: str, **fields: Any) -> dict[str, Any]:
|
||||
record = self.ledger.update(key, state, **fields)
|
||||
if self.crash_after_state == state:
|
||||
raise SimulatedCrash(state)
|
||||
return record
|
||||
|
||||
def _validate(self, data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise CommitFileError("writer_request_object_required")
|
||||
required = ("branch", "path", "content", "message", "expected_head_oid", "idempotency_key")
|
||||
if any(field not in data for field in required):
|
||||
raise CommitFileError("writer_request_incomplete")
|
||||
if data.get("force") or data.get("force_push"):
|
||||
raise CommitFileError("isolated_writer_force_rejected", 403)
|
||||
if set(data) != set(required):
|
||||
raise CommitFileError("writer_request_fields_rejected")
|
||||
item = {field: data[field] for field in required}
|
||||
if item["branch"] != policy.allowed_branch():
|
||||
raise CommitFileError("isolated_writer_branch_rejected", 403)
|
||||
try:
|
||||
item["path"] = policy.validate_path(item["path"], self.workspace)
|
||||
except PermissionError as exc:
|
||||
raise CommitFileError(str(exc), 403) from None
|
||||
if not isinstance(item["content"], str) or "\x00" in item["content"]:
|
||||
raise CommitFileError("writer_content_rejected")
|
||||
try:
|
||||
policy.validate_content_for_path(item["path"], item["content"])
|
||||
except PermissionError as exc:
|
||||
raise CommitFileError(str(exc), 403) from None
|
||||
if len(item["content"].encode("utf-8")) > 1_048_576:
|
||||
raise CommitFileError("writer_content_too_large", 413)
|
||||
message = item["message"]
|
||||
if (
|
||||
not isinstance(message, str)
|
||||
or not 1 <= len(message) <= 200
|
||||
or "\x00" in message
|
||||
or "\n" in message
|
||||
or "\r" in message
|
||||
):
|
||||
raise CommitFileError("writer_message_rejected")
|
||||
if not isinstance(item["expected_head_oid"], str) or not OID_RE.fullmatch(item["expected_head_oid"]):
|
||||
raise CommitFileError("writer_expected_head_rejected")
|
||||
if not isinstance(item["idempotency_key"], str) or not IDEMPOTENCY_RE.fullmatch(item["idempotency_key"]):
|
||||
raise CommitFileError("writer_idempotency_key_rejected")
|
||||
return item
|
||||
|
||||
def _atomic_write(self, relative: str, content: str) -> None:
|
||||
target = self.workspace / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix=".writer-", dir=target.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(content.encode("utf-8"))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
parent_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
def _verify_remote(self, branch: str, oid: str) -> None:
|
||||
bare = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo), "rev-parse", f"refs/heads/{branch}"],
|
||||
text=True, capture_output=True,
|
||||
)
|
||||
if bare.returncode != 0 or bare.stdout.strip() != oid:
|
||||
raise CommitFileError("writer_authoritative_bare_oid_mismatch", 500)
|
||||
remote = subprocess.run(
|
||||
["git", "ls-remote", "--heads", self.remote, f"refs/heads/{branch}"],
|
||||
cwd=self.workspace, text=True, capture_output=True,
|
||||
)
|
||||
if remote.returncode != 0:
|
||||
raise CommitFileError("writer_gitea_verification_failed", 503)
|
||||
fields = remote.stdout.strip().split()
|
||||
if len(fields) != 2:
|
||||
raise CommitFileError("writer_gitea_ref_missing", 503)
|
||||
if fields[0] != oid:
|
||||
raise CommitFileError("writer_gitea_oid_conflict", 409)
|
||||
|
||||
def execute(self, grant: Any, raw_data: Any) -> dict[str, Any]:
|
||||
data = self._validate(raw_data)
|
||||
digest = canonical_digest(data)
|
||||
branch = data["branch"]
|
||||
with _LOCKS_GUARD:
|
||||
lock = _LOCKS.setdefault(branch, threading.Lock())
|
||||
lock_path = self.ledger.path.parent / f"{hashlib.sha256(branch.encode()).hexdigest()}.lock"
|
||||
with lock, open(lock_path, "a+b") as branch_lock:
|
||||
fcntl.flock(branch_lock.fileno(), fcntl.LOCK_EX)
|
||||
return self._execute_locked(grant, data, digest)
|
||||
|
||||
def _execute_locked(self, grant: Any, data: dict[str, Any], digest: str) -> dict[str, Any]:
|
||||
key = data["idempotency_key"]
|
||||
record = self.ledger.get_or_create(grant, data, digest)
|
||||
if record["state"] == "COMPLETED":
|
||||
return self._result(record, idempotent=True)
|
||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != data["branch"]:
|
||||
record = self._transition(
|
||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_current_branch_conflict"
|
||||
)
|
||||
self._audit(grant, data, digest, record, "manual_review_required")
|
||||
raise CommitFileError("writer_current_branch_conflict", 409)
|
||||
head = self._git("rev-parse", "HEAD")
|
||||
commit_oid = record.get("commit_oid")
|
||||
if head != record["pre_head_oid"] and head != commit_oid:
|
||||
record = self._transition(
|
||||
key, "MANUAL_REVIEW_REQUIRED", error_code="writer_branch_head_conflict"
|
||||
)
|
||||
self._audit(grant, data, digest, record, "manual_review_required")
|
||||
raise CommitFileError("writer_branch_head_conflict", 409)
|
||||
try:
|
||||
state = record["state"]
|
||||
if state == "MANUAL_REVIEW_REQUIRED":
|
||||
raise CommitFileError("writer_manual_review_required", 409)
|
||||
if state == "FAILED_RECOVERABLE":
|
||||
if record.get("push_status") == "confirmed":
|
||||
state = "PUSHED"
|
||||
else:
|
||||
state = "COMMITTED" if commit_oid else "FILE_WRITTEN"
|
||||
if state == "PENDING":
|
||||
self._atomic_write(data["path"], data["content"])
|
||||
record = self._transition(key, "FILE_WRITTEN")
|
||||
state = record["state"]
|
||||
if state == "FILE_WRITTEN":
|
||||
actual_sha = hashlib.sha256(
|
||||
(self.workspace / data["path"]).read_bytes()
|
||||
).hexdigest()
|
||||
if actual_sha != record["content_sha256"]:
|
||||
raise CommitFileError("writer_file_content_conflict", 409)
|
||||
staged_before = self._git("diff", "--cached", "--name-only", check=False).splitlines()
|
||||
if staged_before:
|
||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
||||
self._git("add", "--", data["path"])
|
||||
staged = self._git("diff", "--cached", "--name-only").splitlines()
|
||||
if staged != [data["path"]]:
|
||||
raise CommitFileError("writer_extra_staged_files_rejected", 409)
|
||||
self._git("commit", "-m", data["message"])
|
||||
commit_oid = self._git("rev-parse", "HEAD")
|
||||
record = self._transition(key, "COMMITTED", commit_oid=commit_oid)
|
||||
state = record["state"]
|
||||
if state == "COMMITTED":
|
||||
commit_oid = str(record["commit_oid"])
|
||||
pushed = subprocess.run(
|
||||
["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{data['branch']}"],
|
||||
cwd=self.workspace, text=True, capture_output=True,
|
||||
)
|
||||
if pushed.returncode != 0:
|
||||
self._transition(
|
||||
key, "FAILED_RECOVERABLE",
|
||||
push_status="failed", error_code="writer_gitea_push_failed",
|
||||
)
|
||||
raise CommitFileError("writer_gitea_push_failed", 503)
|
||||
record = self._transition(key, "PUSHED", push_status="confirmed")
|
||||
state = record["state"]
|
||||
if state == "PUSHED":
|
||||
commit_oid = str(record["commit_oid"])
|
||||
self._verify_remote(data["branch"], commit_oid)
|
||||
record = self._transition(
|
||||
key, "VERIFIED", verification_status="bare+gitea"
|
||||
)
|
||||
state = record["state"]
|
||||
if state == "VERIFIED":
|
||||
record = self._transition(key, "COMPLETED", error_code=None)
|
||||
self._audit(grant, data, digest, record, "completed")
|
||||
return self._result(record, idempotent=False)
|
||||
except SimulatedCrash:
|
||||
raise
|
||||
except CommitFileError as exc:
|
||||
latest = self.ledger.get(key)
|
||||
if latest["state"] not in {"FAILED_RECOVERABLE", "MANUAL_REVIEW_REQUIRED"}:
|
||||
failure_state = (
|
||||
"MANUAL_REVIEW_REQUIRED"
|
||||
if exc.code in {
|
||||
"writer_gitea_oid_conflict",
|
||||
"writer_file_content_conflict",
|
||||
"writer_current_branch_conflict",
|
||||
}
|
||||
else "FAILED_RECOVERABLE"
|
||||
)
|
||||
latest = self._transition(key, failure_state, error_code=exc.code)
|
||||
self._audit(grant, data, digest, latest, "failed")
|
||||
raise
|
||||
|
||||
def _audit(self, grant: Any, data: dict[str, Any], digest: str, record: dict[str, Any], result: str) -> None:
|
||||
self.audit({
|
||||
"token_id": grant.token_id,
|
||||
"task_id": grant.task_id,
|
||||
"audience": grant.record["audience"],
|
||||
"source_ip": grant.record.get("_source_ip", "socket-peer"),
|
||||
"operation": grant.operation,
|
||||
"branch": data["branch"],
|
||||
"path": data["path"],
|
||||
"idempotency_key": data["idempotency_key"],
|
||||
"operation_digest": digest,
|
||||
"path_policy_sha256": policy.configured_path_policy_sha256(),
|
||||
"pre_head_oid": record["pre_head_oid"],
|
||||
"commit_oid": record.get("commit_oid"),
|
||||
"result": result,
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _result(record: dict[str, Any], *, idempotent: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "completed",
|
||||
"state": record["state"],
|
||||
"operation_id": record["operation_id"],
|
||||
"branch": record["branch"],
|
||||
"path": record["path"],
|
||||
"commit_oid": record.get("commit_oid"),
|
||||
"push_status": record.get("push_status"),
|
||||
"verification_status": record.get("verification_status"),
|
||||
"idempotent": idempotent,
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Strict service-level exact-path policy for isolated Git writers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import grp
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
MAX_POLICY_BYTES = 65_536
|
||||
POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"policy_id",
|
||||
"audience",
|
||||
"repository_identity",
|
||||
"allowed_branch",
|
||||
"allowed_operations",
|
||||
}
|
||||
)
|
||||
OPERATION_FIELDS = frozenset({"exact_paths", "max_content_bytes", "content_profile"})
|
||||
SUPPORTED_OPERATIONS = frozenset({"commit_file.atomic"})
|
||||
SUPPORTED_CONTENT_PROFILES = frozenset({"systemd-codex-review-worker-v1"})
|
||||
_SECRET_RE = re.compile(
|
||||
r"(?i)(authorization\s*:|bearer\s+|(?:token|password|secret)\s*[:=])"
|
||||
)
|
||||
_SYSTEMD_NAME_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*\Z")
|
||||
_SYSTEMD_MAX_LINE_BYTES = 4096
|
||||
_SYSTEMD_ALLOWED_DIRECTIVES = {
|
||||
"Unit": frozenset({"Description", "After", "Wants"}),
|
||||
"Service": frozenset(
|
||||
{
|
||||
"Type",
|
||||
"User",
|
||||
"Group",
|
||||
"WorkingDirectory",
|
||||
"Environment",
|
||||
"ExecStart",
|
||||
"Restart",
|
||||
"RestartSec",
|
||||
"NoNewPrivileges",
|
||||
"PrivateTmp",
|
||||
"PrivateDevices",
|
||||
"ProtectSystem",
|
||||
"ProtectHome",
|
||||
"ProtectKernelTunables",
|
||||
"ProtectKernelModules",
|
||||
"ProtectControlGroups",
|
||||
"LockPersonality",
|
||||
"RestrictSUIDSGID",
|
||||
"RestrictRealtime",
|
||||
"MemoryDenyWriteExecute",
|
||||
"RuntimeDirectory",
|
||||
"StateDirectory",
|
||||
"CacheDirectory",
|
||||
"ReadOnlyPaths",
|
||||
"ReadWritePaths",
|
||||
"UMask",
|
||||
}
|
||||
),
|
||||
"Install": frozenset({"WantedBy"}),
|
||||
}
|
||||
_SYSTEMD_MULTI_DIRECTIVES = frozenset(
|
||||
{
|
||||
("Service", "Environment"),
|
||||
("Service", "ReadOnlyPaths"),
|
||||
("Service", "ReadWritePaths"),
|
||||
}
|
||||
)
|
||||
_SYSTEMD_EXACT_SINGLE_VALUES = {
|
||||
("Unit", "Description"): "AIOS Codex Review/Test Worker",
|
||||
("Unit", "After"): "network-online.target",
|
||||
("Unit", "Wants"): "network-online.target",
|
||||
("Service", "Type"): "simple",
|
||||
("Service", "User"): "aiman",
|
||||
("Service", "Group"): "gitops",
|
||||
(
|
||||
"Service",
|
||||
"WorkingDirectory",
|
||||
): "/usr/libexec/aios-codex-review-test-worker/current",
|
||||
(
|
||||
"Service",
|
||||
"ExecStart",
|
||||
): "/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
("Service", "Restart"): "on-failure",
|
||||
("Service", "RestartSec"): "2",
|
||||
("Service", "NoNewPrivileges"): "true",
|
||||
("Service", "PrivateTmp"): "true",
|
||||
("Service", "PrivateDevices"): "true",
|
||||
("Service", "ProtectSystem"): "strict",
|
||||
("Service", "ProtectHome"): "true",
|
||||
("Service", "ProtectKernelTunables"): "true",
|
||||
("Service", "ProtectKernelModules"): "true",
|
||||
("Service", "ProtectControlGroups"): "true",
|
||||
("Service", "LockPersonality"): "true",
|
||||
("Service", "RestrictSUIDSGID"): "true",
|
||||
("Service", "RestrictRealtime"): "true",
|
||||
("Service", "MemoryDenyWriteExecute"): "true",
|
||||
("Service", "RuntimeDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "StateDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "CacheDirectory"): "aios-codex-review-test-worker",
|
||||
("Service", "UMask"): "0077",
|
||||
("Install", "WantedBy"): "multi-user.target",
|
||||
}
|
||||
_SYSTEMD_EXACT_MULTI_VALUES = {
|
||||
("Service", "Environment"): (
|
||||
"AIOS_CODEX_WORKER_MODE=shadow",
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=false",
|
||||
"PYTHONDONTWRITEBYTECODE=1",
|
||||
),
|
||||
("Service", "ReadOnlyPaths"): (
|
||||
"/data/aios-gitops/repo.git",
|
||||
"/usr/libexec/aios-codex-review-test-worker",
|
||||
),
|
||||
("Service", "ReadWritePaths"): (
|
||||
"/run/aios-codex-review-test-worker",
|
||||
"/var/lib/aios-codex-review-test-worker",
|
||||
"/var/cache/aios-codex-review-test-worker",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PathPolicyError(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperationPolicy:
|
||||
exact_paths: tuple[str, ...]
|
||||
max_content_bytes: int
|
||||
content_profile: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServicePathPolicy:
|
||||
schema_version: int
|
||||
policy_id: str
|
||||
audience: str
|
||||
repository_identity: str
|
||||
allowed_branch: str
|
||||
allowed_operations: dict[str, OperationPolicy]
|
||||
sha256: str
|
||||
|
||||
def operation(self, name: str) -> OperationPolicy:
|
||||
try:
|
||||
return self.allowed_operations[name]
|
||||
except KeyError:
|
||||
raise PathPolicyError("isolated_writer_service_operation_rejected") from None
|
||||
|
||||
def require_exact_path(self, operation: str, path: str) -> OperationPolicy:
|
||||
selected = self.operation(operation)
|
||||
if path not in selected.exact_paths:
|
||||
raise PathPolicyError("isolated_writer_service_path_rejected")
|
||||
return selected
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def validate_relative_path_syntax(path: str) -> str:
|
||||
if not isinstance(path, str):
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
if not path or path != path.strip() or "\x00" in path or "\\" in path:
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
try:
|
||||
decoded = unquote(path, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise PathPolicyError("isolated_writer_path_rejected") from None
|
||||
if decoded != path:
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
posix = PurePosixPath(path)
|
||||
windows = PureWindowsPath(path)
|
||||
parts = path.split("/")
|
||||
if (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_rejected")
|
||||
return path
|
||||
|
||||
|
||||
def _require_fields(value: dict[str, Any], allowed: frozenset[str], code: str) -> None:
|
||||
if set(value) != allowed:
|
||||
raise PathPolicyError(code)
|
||||
|
||||
|
||||
def _parse_policy(document: Any, canonical: bytes) -> ServicePathPolicy:
|
||||
if not isinstance(document, dict):
|
||||
raise PathPolicyError("isolated_writer_path_policy_object_required")
|
||||
_require_fields(document, POLICY_FIELDS, "isolated_writer_path_policy_fields_rejected")
|
||||
if document["schema_version"] != 1:
|
||||
raise PathPolicyError("isolated_writer_path_policy_schema_rejected")
|
||||
text_fields = (
|
||||
"policy_id",
|
||||
"audience",
|
||||
"repository_identity",
|
||||
"allowed_branch",
|
||||
)
|
||||
if any(
|
||||
not isinstance(document[field], str) or not document[field].strip()
|
||||
for field in text_fields
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_value_rejected")
|
||||
raw_operations = document["allowed_operations"]
|
||||
if not isinstance(raw_operations, dict) or not raw_operations:
|
||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
||||
if not set(raw_operations).issubset(SUPPORTED_OPERATIONS):
|
||||
raise PathPolicyError("isolated_writer_path_policy_operations_rejected")
|
||||
operations: dict[str, OperationPolicy] = {}
|
||||
for name, raw in raw_operations.items():
|
||||
if not isinstance(raw, dict):
|
||||
raise PathPolicyError("isolated_writer_path_policy_operation_rejected")
|
||||
_require_fields(
|
||||
raw,
|
||||
OPERATION_FIELDS,
|
||||
"isolated_writer_path_policy_operation_fields_rejected",
|
||||
)
|
||||
paths = raw["exact_paths"]
|
||||
if (
|
||||
not isinstance(paths, list)
|
||||
or not paths
|
||||
or any(not isinstance(item, str) for item in paths)
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
||||
validated = tuple(validate_relative_path_syntax(item) for item in paths)
|
||||
if len(set(validated)) != len(validated):
|
||||
raise PathPolicyError("isolated_writer_path_policy_paths_rejected")
|
||||
maximum = raw["max_content_bytes"]
|
||||
if not isinstance(maximum, int) or isinstance(maximum, bool) or not 1 <= maximum <= 1_048_576:
|
||||
raise PathPolicyError("isolated_writer_path_policy_size_rejected")
|
||||
profile = raw["content_profile"]
|
||||
if profile not in SUPPORTED_CONTENT_PROFILES:
|
||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
||||
operations[name] = OperationPolicy(validated, maximum, profile)
|
||||
return ServicePathPolicy(
|
||||
schema_version=1,
|
||||
policy_id=document["policy_id"],
|
||||
audience=document["audience"],
|
||||
repository_identity=document["repository_identity"],
|
||||
allowed_branch=document["allowed_branch"],
|
||||
allowed_operations=operations,
|
||||
sha256=hashlib.sha256(canonical).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def load_path_policy(
|
||||
path: Path,
|
||||
*,
|
||||
expected_owner_uid: int = 0,
|
||||
expected_group_gid: int | None = None,
|
||||
expected_parent_mode: int = 0o750,
|
||||
expected_file_mode: int = 0o640,
|
||||
) -> ServicePathPolicy:
|
||||
lexical = Path(os.path.abspath(str(path)))
|
||||
if lexical.is_symlink():
|
||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
||||
try:
|
||||
parent_info = lexical.parent.stat(follow_symlinks=False)
|
||||
file_info = lexical.stat(follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
raise PathPolicyError("isolated_writer_path_policy_missing") from None
|
||||
if stat.S_ISLNK(parent_info.st_mode) or not stat.S_ISDIR(parent_info.st_mode):
|
||||
raise PathPolicyError("isolated_writer_path_policy_parent_rejected")
|
||||
if stat.S_ISLNK(file_info.st_mode) or not stat.S_ISREG(file_info.st_mode):
|
||||
raise PathPolicyError("isolated_writer_path_policy_file_rejected")
|
||||
if lexical.resolve(strict=True) != lexical:
|
||||
raise PathPolicyError("isolated_writer_path_policy_symlink_rejected")
|
||||
if (
|
||||
parent_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(parent_info.st_mode) != expected_parent_mode
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_parent_permissions_rejected")
|
||||
if (
|
||||
file_info.st_uid != expected_owner_uid
|
||||
or stat.S_IMODE(file_info.st_mode) != expected_file_mode
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_permissions_rejected")
|
||||
if expected_group_gid is not None and (
|
||||
parent_info.st_gid != expected_group_gid or file_info.st_gid != expected_group_gid
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected")
|
||||
if file_info.st_size <= 0 or file_info.st_size > MAX_POLICY_BYTES:
|
||||
raise PathPolicyError("isolated_writer_path_policy_file_size_rejected")
|
||||
raw = lexical.read_bytes()
|
||||
try:
|
||||
document = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise PathPolicyError("isolated_writer_path_policy_json_rejected") from None
|
||||
canonical = _canonical_json(document)
|
||||
if raw != canonical:
|
||||
raise PathPolicyError("isolated_writer_path_policy_not_canonical")
|
||||
return _parse_policy(document, canonical)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def configured_path_policy() -> ServicePathPolicy | None:
|
||||
configured = os.getenv("AIOS_GITOPS_PATH_POLICY_FILE", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
if not Path(configured).is_absolute():
|
||||
raise PathPolicyError("isolated_writer_path_policy_path_rejected")
|
||||
try:
|
||||
gitops_gid = grp.getgrnam("gitops").gr_gid
|
||||
except KeyError:
|
||||
raise PathPolicyError("isolated_writer_path_policy_group_rejected") from None
|
||||
loaded = load_path_policy(Path(configured), expected_group_gid=gitops_gid)
|
||||
if loaded.audience != os.getenv("AIOS_GITOPS_SERVICE_AUDIENCE", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_audience_rejected")
|
||||
if loaded.repository_identity != os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_repository_rejected")
|
||||
if loaded.allowed_branch != os.getenv("AIOS_GITOPS_ALLOWED_BRANCH", ""):
|
||||
raise PathPolicyError("isolated_writer_path_policy_branch_rejected")
|
||||
return loaded
|
||||
|
||||
|
||||
def clear_path_policy_cache() -> None:
|
||||
configured_path_policy.cache_clear()
|
||||
|
||||
|
||||
def require_service_path(operation: str, path: str) -> OperationPolicy:
|
||||
loaded = configured_path_policy()
|
||||
if loaded is None:
|
||||
raise PathPolicyError("isolated_writer_service_path_policy_required")
|
||||
return loaded.require_exact_path(operation, path)
|
||||
|
||||
|
||||
def configured_path_policy_sha256() -> str | None:
|
||||
loaded = configured_path_policy()
|
||||
return None if loaded is None else loaded.sha256
|
||||
|
||||
|
||||
def validate_content(operation: str, path: str, content: str) -> None:
|
||||
selected = require_service_path(operation, path)
|
||||
try:
|
||||
encoded = content.encode("utf-8", errors="strict")
|
||||
except (AttributeError, UnicodeEncodeError):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
||||
if len(encoded) > selected.max_content_bytes:
|
||||
raise PathPolicyError("isolated_writer_service_content_too_large")
|
||||
if selected.content_profile == "systemd-codex-review-worker-v1":
|
||||
_validate_systemd_codex_review_worker(content)
|
||||
return
|
||||
raise PathPolicyError("isolated_writer_path_policy_profile_rejected")
|
||||
|
||||
|
||||
def _validate_systemd_codex_review_worker(content: str) -> None:
|
||||
if not isinstance(content, str):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
try:
|
||||
content.encode("utf-8", errors="strict")
|
||||
except UnicodeEncodeError:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected") from None
|
||||
if (
|
||||
"\x00" in content
|
||||
or "\r" in content
|
||||
or _SECRET_RE.search(content)
|
||||
or not content.endswith("\n")
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
|
||||
sections: set[str] = set()
|
||||
values: dict[tuple[str, str], list[str]] = {}
|
||||
section: str | None = None
|
||||
for line in content.splitlines():
|
||||
if len(line.encode("utf-8")) > _SYSTEMD_MAX_LINE_BYTES:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if not line:
|
||||
continue
|
||||
if line[0].isspace() or line.startswith(("#", ";")):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
candidate = line[1:-1]
|
||||
if (
|
||||
not _SYSTEMD_NAME_RE.fullmatch(candidate)
|
||||
or candidate not in _SYSTEMD_ALLOWED_DIRECTIVES
|
||||
or candidate in sections
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
sections.add(candidate)
|
||||
section = candidate
|
||||
continue
|
||||
if section is None or "=" not in line:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
directive, value = line.split("=", 1)
|
||||
if (
|
||||
not _SYSTEMD_NAME_RE.fullmatch(directive)
|
||||
or directive not in _SYSTEMD_ALLOWED_DIRECTIVES[section]
|
||||
or not value
|
||||
or value != value.strip()
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
key = (section, directive)
|
||||
if key in values and key not in _SYSTEMD_MULTI_DIRECTIVES:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
values.setdefault(key, []).append(value)
|
||||
|
||||
if sections != set(_SYSTEMD_ALLOWED_DIRECTIVES):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
if set(values) != set(_SYSTEMD_EXACT_SINGLE_VALUES) | set(
|
||||
_SYSTEMD_EXACT_MULTI_VALUES
|
||||
):
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
for key, expected in _SYSTEMD_EXACT_SINGLE_VALUES.items():
|
||||
if values.get(key) != [expected]:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
for key, expected in _SYSTEMD_EXACT_MULTI_VALUES.items():
|
||||
observed = values.get(key, [])
|
||||
if len(observed) != len(set(observed)) or tuple(observed) != expected:
|
||||
raise PathPolicyError("isolated_writer_systemd_profile_rejected")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Digest-only, task-scoped token registry for isolated writers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import grp
|
||||
import pwd
|
||||
import secrets
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REGISTRY_INIT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class TokenRegistry:
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self._prepare()
|
||||
|
||||
def _prepare(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.parent.is_symlink() or self.path.is_symlink():
|
||||
raise PermissionError("writer_token_registry_symlink_rejected")
|
||||
with _REGISTRY_INIT_LOCK:
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS token_grants (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
token_digest TEXT NOT NULL UNIQUE,
|
||||
grant_json TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS token_uses (
|
||||
token_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(token_id, operation),
|
||||
FOREIGN KEY(token_id) REFERENCES token_grants(token_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
db = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
return db
|
||||
|
||||
def issue(self, grant: dict[str, Any], token: str | None = None) -> tuple[str, dict[str, Any]]:
|
||||
value = token or secrets.token_urlsafe(32)
|
||||
if len(value.encode("utf-8")) < 32:
|
||||
raise ValueError("writer_token_entropy_insufficient")
|
||||
record = dict(grant)
|
||||
record["schema_version"] = 2
|
||||
record["token_digest"] = token_digest(value)
|
||||
record["revoked"] = False
|
||||
record.setdefault("created_at", utc_now())
|
||||
required = (
|
||||
"token_id",
|
||||
"audience",
|
||||
"task_id",
|
||||
"source_cidrs",
|
||||
"allowed_branch",
|
||||
"approved_base_oid",
|
||||
"allowed_operations",
|
||||
"allowed_paths",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"max_uses",
|
||||
"issued_by",
|
||||
)
|
||||
if any(not record.get(field) for field in required):
|
||||
raise ValueError("writer_token_grant_incomplete")
|
||||
encoded = json.dumps(record, sort_keys=True, separators=(",", ":"))
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO token_grants(token_id, token_digest, grant_json, revoked, created_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
str(record["token_id"]),
|
||||
str(record["token_digest"]),
|
||||
encoded,
|
||||
0,
|
||||
str(record["created_at"]),
|
||||
),
|
||||
)
|
||||
db.execute("COMMIT")
|
||||
except Exception:
|
||||
db.execute("ROLLBACK")
|
||||
raise
|
||||
return value, record
|
||||
|
||||
def find(self, token: str) -> dict[str, Any] | None:
|
||||
candidate = token_digest(token)
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT token_digest, grant_json, revoked FROM token_grants"
|
||||
).fetchall()
|
||||
match = None
|
||||
for row in rows:
|
||||
if hmac.compare_digest(str(row["token_digest"]), candidate):
|
||||
match = dict(json.loads(str(row["grant_json"])))
|
||||
match["revoked"] = bool(row["revoked"])
|
||||
return match
|
||||
|
||||
def consume(self, token_id: str, operation: str, maximum: int) -> int:
|
||||
if maximum < 1:
|
||||
raise PermissionError("writer_token_operation_exhausted")
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT revoked FROM token_grants WHERE token_id=?",
|
||||
(token_id,),
|
||||
).fetchone()
|
||||
if row is None or bool(row["revoked"]):
|
||||
raise PermissionError("writer_token_revoked")
|
||||
used_row = db.execute(
|
||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
||||
(token_id, operation),
|
||||
).fetchone()
|
||||
used = int(used_row["use_count"]) if used_row else 0
|
||||
if used >= maximum:
|
||||
raise PermissionError("writer_token_operation_exhausted")
|
||||
current = used + 1
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO token_uses(token_id, operation, use_count)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(token_id, operation)
|
||||
DO UPDATE SET use_count=excluded.use_count
|
||||
""",
|
||||
(token_id, operation, current),
|
||||
)
|
||||
db.execute("COMMIT")
|
||||
return current
|
||||
except Exception:
|
||||
db.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def revoke(self, token_id: str) -> bool:
|
||||
with self._connect() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
changed = db.execute(
|
||||
"UPDATE token_grants SET revoked=1 WHERE token_id=? AND revoked=0",
|
||||
(token_id,),
|
||||
).rowcount
|
||||
db.execute("COMMIT")
|
||||
return bool(changed)
|
||||
|
||||
def usage(self, token_id: str, operation: str) -> int:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT use_count FROM token_uses WHERE token_id=? AND operation=?",
|
||||
(token_id, operation),
|
||||
).fetchone()
|
||||
return int(row["use_count"]) if row else 0
|
||||
|
||||
|
||||
def _write_secret_file(
|
||||
path: Path,
|
||||
token: str,
|
||||
*,
|
||||
owner: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise PermissionError("writer_token_file_symlink_rejected")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o600)
|
||||
try:
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise PermissionError("writer_token_file_not_regular")
|
||||
if owner is not None or group is not None:
|
||||
uid = pwd.getpwnam(owner).pw_uid if owner is not None else -1
|
||||
gid = grp.getgrnam(group).gr_gid if group is not None else -1
|
||||
os.fchown(fd, uid, gid)
|
||||
os.write(fd, token.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--registry", required=True)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
issue = sub.add_parser("issue")
|
||||
issue.add_argument("--grant-file", required=True)
|
||||
issue.add_argument("--token-file", required=True)
|
||||
issue.add_argument("--owner")
|
||||
issue.add_argument("--group")
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("--token-id", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
registry = TokenRegistry(args.registry)
|
||||
if args.command == "issue":
|
||||
grant = json.loads(Path(args.grant_file).read_text(encoding="utf-8"))
|
||||
token, record = registry.issue(grant)
|
||||
_write_secret_file(
|
||||
Path(args.token_file),
|
||||
token,
|
||||
owner=args.owner,
|
||||
group=args.group,
|
||||
)
|
||||
print(json.dumps({"token_id": record["token_id"], "created": True}, sort_keys=True))
|
||||
return 0
|
||||
changed = registry.revoke(args.token_id)
|
||||
print(json.dumps({"token_id": args.token_id, "revoked": changed}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execution-readiness adapters and the fail-closed Shadow controller service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
)
|
||||
|
||||
from silent_release_controller import _sanitize, utc_now
|
||||
from silent_release_hardening import (
|
||||
BuilderAttestation,
|
||||
CapabilityAuthorizationError,
|
||||
ReleaseRecoveryWorker,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TrustedCandidateBundle,
|
||||
_canonical_json,
|
||||
_durable_json,
|
||||
_safe_child,
|
||||
)
|
||||
|
||||
|
||||
class BuilderSigner(Protocol):
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
...
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class LocalTestBuilderSigner:
|
||||
"""Ephemeral in-memory signer. It is forbidden for production execution."""
|
||||
|
||||
test_only = True
|
||||
|
||||
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
|
||||
self._key_id = str(key_id)
|
||||
self._private_key = private_key
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls, key_id: str = "shadow-builder-test-key"
|
||||
) -> "LocalTestBuilderSigner":
|
||||
return cls(key_id, Ed25519PrivateKey.generate())
|
||||
|
||||
@property
|
||||
def public_key(self):
|
||||
return self._private_key.public_key()
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
return self._private_key.sign(payload)
|
||||
|
||||
|
||||
class ExternalKmsBuilderSigner:
|
||||
"""Signer facade whose private operation remains in KMS/HSM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_id: str,
|
||||
credential_reference: str,
|
||||
sign_callback: Callable[[str, bytes], bytes],
|
||||
):
|
||||
if not str(credential_reference).startswith(
|
||||
("kms://", "hsm://", "secret-manager://")
|
||||
):
|
||||
raise ValueError("production builder requires a KMS/HSM reference")
|
||||
self._key_id = str(key_id)
|
||||
self.credential_reference = str(credential_reference)
|
||||
self._sign_callback = sign_callback
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
return bytes(self._sign_callback(self.credential_reference, payload))
|
||||
|
||||
|
||||
class SignedTrustedBuilderAdapter:
|
||||
"""Signs evidence produced by a trusted collector, never caller evidence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
collector: Callable[[Mapping[str, Any]], TrustedCandidateBundle],
|
||||
signer: BuilderSigner,
|
||||
):
|
||||
self.collector = collector
|
||||
self.signer = signer
|
||||
|
||||
def build(self, intent: Mapping[str, Any]) -> TrustedCandidateBundle:
|
||||
forbidden = {
|
||||
"artifact_sha256",
|
||||
"canary",
|
||||
"checks",
|
||||
"manifest_sha256",
|
||||
"tests",
|
||||
"tree_oid",
|
||||
}
|
||||
if forbidden.intersection(intent):
|
||||
raise CapabilityAuthorizationError(
|
||||
"trusted builder rejects caller-supplied evidence"
|
||||
)
|
||||
unsigned = self.collector(dict(intent))
|
||||
if unsigned.attestation is not None:
|
||||
raise CapabilityAuthorizationError(
|
||||
"collector cannot self-attest its evidence"
|
||||
)
|
||||
candidate = unsigned.candidate
|
||||
test_sha = hashlib.sha256(
|
||||
_canonical_json(_sanitize(candidate.tests))
|
||||
).hexdigest()
|
||||
canary_sha = hashlib.sha256(
|
||||
_canonical_json(_sanitize(candidate.canary))
|
||||
).hexdigest()
|
||||
attestation_id = "builder-attestation-" + hashlib.sha256(
|
||||
(
|
||||
candidate.candidate_id
|
||||
+ "\0"
|
||||
+ candidate.source_commit
|
||||
+ "\0"
|
||||
+ candidate.artifact_sha256
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
attestation = BuilderAttestation(
|
||||
builder_attestation_id=attestation_id,
|
||||
builder_key_id=self.signer.key_id,
|
||||
source_commit=candidate.source_commit,
|
||||
tree_oid=candidate.tree_oid,
|
||||
artifact_sha256=candidate.artifact_sha256,
|
||||
manifest_sha256=candidate.manifest_sha256,
|
||||
package_closure_sha256=(
|
||||
unsigned.release_plan.deployment.package_closure_sha256
|
||||
),
|
||||
test_evidence_sha256=test_sha,
|
||||
canary_evidence_sha256=canary_sha,
|
||||
issued_at=utc_now(),
|
||||
signature="",
|
||||
)
|
||||
attestation = dataclasses.replace(
|
||||
attestation,
|
||||
signature=base64.b64encode(
|
||||
self.signer.sign(attestation.canonical_bytes())
|
||||
).decode("ascii"),
|
||||
)
|
||||
return dataclasses.replace(unsigned, attestation=attestation)
|
||||
|
||||
|
||||
class ApprovalBackendAdapter(Protocol):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
...
|
||||
|
||||
def verify(
|
||||
self,
|
||||
approval: SignedReleasePlanApproval,
|
||||
*,
|
||||
candidate,
|
||||
release_plan,
|
||||
policy_sha256: str,
|
||||
) -> SignedReleasePlanApproval:
|
||||
...
|
||||
|
||||
|
||||
class LocalTestApprovalSigner:
|
||||
"""Test-only approval signer used solely for Shadow acceptance."""
|
||||
|
||||
test_only = True
|
||||
|
||||
def __init__(self, key_id: str, private_key: Ed25519PrivateKey):
|
||||
self.key_id = str(key_id)
|
||||
self.private_key = private_key
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls, key_id: str = "shadow-owner-test-key"
|
||||
) -> "LocalTestApprovalSigner":
|
||||
return cls(key_id, Ed25519PrivateKey.generate())
|
||||
|
||||
def sign(
|
||||
self, approval: SignedReleasePlanApproval
|
||||
) -> SignedReleasePlanApproval:
|
||||
unsigned = dataclasses.replace(
|
||||
approval, key_id=self.key_id, signature=""
|
||||
)
|
||||
return dataclasses.replace(
|
||||
unsigned,
|
||||
signature=base64.b64encode(
|
||||
self.private_key.sign(unsigned.canonical_bytes())
|
||||
).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
class ProductionApprovalBackendAdapter:
|
||||
"""Verification-only production adapter configured by a secret/KMS ref."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SignedApprovalVerifier,
|
||||
credential_reference: str,
|
||||
):
|
||||
if not str(credential_reference).startswith(
|
||||
("kms://", "hsm://", "secret-manager://")
|
||||
):
|
||||
raise ValueError(
|
||||
"production approval backend requires a KMS/secret reference"
|
||||
)
|
||||
self.verifier = verifier
|
||||
self.credential_reference = str(credential_reference)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
def verify(
|
||||
self,
|
||||
approval: SignedReleasePlanApproval,
|
||||
*,
|
||||
candidate,
|
||||
release_plan,
|
||||
policy_sha256: str,
|
||||
) -> SignedReleasePlanApproval:
|
||||
return self.verifier.verify_release_plan(
|
||||
approval, candidate, release_plan, policy_sha256
|
||||
)
|
||||
|
||||
|
||||
class UnavailableProductionApprovalBackend:
|
||||
credential_reference = None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return False
|
||||
|
||||
def verify(self, *args, **kwargs):
|
||||
raise CapabilityAuthorizationError(
|
||||
"production approval backend is not configured"
|
||||
)
|
||||
|
||||
|
||||
class LeaderLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, 0o700)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
except BlockingIOError as exc:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
raise RuntimeError("silent release leader is already active") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self.handle is not None:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
self.handle.close()
|
||||
self.handle = None
|
||||
|
||||
|
||||
class PersistentSilentReleaseController:
|
||||
"""Idempotent main-event controller with durable startup/recovery reports."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
service: SilentReleaseActionService,
|
||||
*,
|
||||
main_probe: Callable[[], str],
|
||||
recovery_worker: ReleaseRecoveryWorker | None = None,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.service = service
|
||||
self.main_probe = main_probe
|
||||
self.recovery_worker = recovery_worker
|
||||
self.leader_path = self.root / "controller.leader.lock"
|
||||
self.status_path = self.root / "controller-status.json"
|
||||
|
||||
def startup_verify(self) -> dict[str, Any]:
|
||||
checked = 0
|
||||
failed = 0
|
||||
for journal in sorted(self.service.sagas.root.glob("*.events.jsonl")):
|
||||
candidate_id = journal.name[: -len(".events.jsonl")]
|
||||
saga = self.service.sagas.load(candidate_id)
|
||||
checked += 1
|
||||
if not saga.journal_integrity:
|
||||
failed += 1
|
||||
status = {
|
||||
"checked_sagas": checked,
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"failed_closed": failed,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"status": "READY" if failed == 0 else "FAIL_CLOSED",
|
||||
}
|
||||
_durable_json(self.status_path, status)
|
||||
return status
|
||||
|
||||
def run_once(self) -> dict[str, Any]:
|
||||
with LeaderLock(self.leader_path):
|
||||
startup = self.startup_verify()
|
||||
if startup["status"] != "READY":
|
||||
result = {
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": None,
|
||||
"notification": None,
|
||||
"recovery": None,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
"status": "FAIL_CLOSED",
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
source_commit = str(self.main_probe())
|
||||
event_id = f"main-{source_commit}"
|
||||
intent = self.service.create_release_intent(
|
||||
{"event_id": event_id, "source_commit": source_commit}
|
||||
)
|
||||
notification = None
|
||||
if intent.get("candidate_id"):
|
||||
notification = self.service.ensure_owner_notification(
|
||||
str(intent["candidate_id"])
|
||||
)
|
||||
recovery = (
|
||||
self.recovery_worker.run_once()
|
||||
if self.recovery_worker is not None
|
||||
else self.service.get_recovery_status()
|
||||
)
|
||||
result = {
|
||||
"execution_enabled": self.service.execution_enabled,
|
||||
"intent": intent,
|
||||
"notification": notification,
|
||||
"recovery": recovery,
|
||||
"shadow_mode": not self.service.execution_enabled,
|
||||
"side_effects_executed": False,
|
||||
"startup": startup,
|
||||
}
|
||||
_durable_json(self.status_path, result)
|
||||
return _sanitize(result)
|
||||
|
||||
|
||||
class ShadowActionHandler(BaseHTTPRequestHandler):
|
||||
service: SilentReleaseActionService
|
||||
controller: PersistentSilentReleaseController
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
def _json(self, payload: Mapping[str, Any], status=HTTPStatus.OK):
|
||||
body = json.dumps(_sanitize(dict(payload)), sort_keys=True).encode()
|
||||
self.send_response(int(status))
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if size > 1024 * 1024:
|
||||
raise ValueError("request body is too large")
|
||||
return json.loads(self.rfile.read(size) or b"{}")
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
query = parse_qs(parsed.query)
|
||||
candidate_id = str((query.get("candidate_id") or [""])[0])
|
||||
try:
|
||||
if parsed.path == "/healthz":
|
||||
return self._json(
|
||||
{
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "ok",
|
||||
}
|
||||
)
|
||||
routes = {
|
||||
"/getReleaseCandidate": self.service.get_release_candidate,
|
||||
"/getReleasePlan": self.service.get_release_plan,
|
||||
"/getReleaseAcceptance": self.service.get_acceptance,
|
||||
"/getReleaseSaga": self.service.get_release_saga,
|
||||
"/getCapabilityStatus": self.service.get_capability_status,
|
||||
}
|
||||
if parsed.path in routes:
|
||||
return self._json(routes[parsed.path](candidate_id))
|
||||
if parsed.path == "/getRecoveryStatus":
|
||||
return self._json(self.service.get_recovery_status())
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
self._json(
|
||||
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
def do_POST(self):
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
body = self._body()
|
||||
if parsed.path == "/createReleaseIntent":
|
||||
return self._json(self.service.create_release_intent(body))
|
||||
if parsed.path in {
|
||||
"/submitSignedApproval",
|
||||
"/submitOwnerApproval",
|
||||
}:
|
||||
return self._json(self.service.submit_owner_approval(body))
|
||||
if parsed.path == "/startDeployment":
|
||||
return self._json(
|
||||
self.service.start_deployment(
|
||||
str(body.get("candidate_id") or "")
|
||||
)
|
||||
)
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
except (ValueError, FileNotFoundError, CapabilityAuthorizationError) as exc:
|
||||
self._json(
|
||||
{"error": type(exc).__name__}, HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
def git_main_probe(repository: Path) -> Callable[[], str]:
|
||||
repository = Path(repository)
|
||||
|
||||
def probe() -> str:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={repository}",
|
||||
"rev-parse",
|
||||
"refs/heads/main",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
return probe
|
||||
|
||||
|
||||
def serve_shadow(
|
||||
*,
|
||||
state_root: Path,
|
||||
repository: Path,
|
||||
host: str,
|
||||
port: int,
|
||||
poll_seconds: float,
|
||||
) -> None:
|
||||
service = SilentReleaseActionService.shadow(state_root)
|
||||
controller = PersistentSilentReleaseController(
|
||||
state_root,
|
||||
service,
|
||||
main_probe=git_main_probe(repository),
|
||||
)
|
||||
controller.run_once()
|
||||
stop = threading.Event()
|
||||
|
||||
def controller_loop() -> None:
|
||||
while not stop.wait(poll_seconds):
|
||||
try:
|
||||
controller.run_once()
|
||||
except Exception as exc: # fail closed and keep the API available
|
||||
_durable_json(
|
||||
controller.status_path,
|
||||
{
|
||||
"error": type(exc).__name__,
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "FAIL_CLOSED",
|
||||
},
|
||||
)
|
||||
|
||||
handler = type(
|
||||
"ConfiguredShadowActionHandler",
|
||||
(ShadowActionHandler,),
|
||||
{"service": service, "controller": controller},
|
||||
)
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
api_thread = threading.Thread(
|
||||
target=server.serve_forever,
|
||||
name="silent-release-shadow-api",
|
||||
daemon=False,
|
||||
)
|
||||
controller_thread = threading.Thread(
|
||||
target=controller_loop,
|
||||
name="silent-release-shadow-controller",
|
||||
daemon=True,
|
||||
)
|
||||
api_thread.start()
|
||||
controller_thread.start()
|
||||
try:
|
||||
while api_thread.is_alive():
|
||||
api_thread.join(timeout=1)
|
||||
finally:
|
||||
stop.set()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
controller_thread.join(timeout=max(1.0, poll_seconds + 1.0))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--state-root", required=True, type=Path)
|
||||
parser.add_argument("--repository", required=True, type=Path)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", default=18891, type=int)
|
||||
parser.add_argument("--poll-seconds", default=30.0, type=float)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.poll_seconds <= 0:
|
||||
raise SystemExit("poll seconds must be positive")
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() != "false":
|
||||
raise SystemExit("GitOps writes must remain disabled")
|
||||
if (
|
||||
os.environ.get(
|
||||
"AIOS_SILENT_RELEASE_EXECUTION_ENABLED", "false"
|
||||
).lower()
|
||||
!= "false"
|
||||
):
|
||||
raise SystemExit("Silent Release execution must remain disabled")
|
||||
serve_shadow(
|
||||
state_root=arguments.state_root,
|
||||
repository=arguments.repository,
|
||||
host=arguments.host,
|
||||
port=arguments.port,
|
||||
poll_seconds=arguments.poll_seconds,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
"""Task Plane runtime package."""
|
||||
|
||||
from .models import (
|
||||
EvidenceEnvelope,
|
||||
ProbeEvidence,
|
||||
ProbeOutcome,
|
||||
TaskPlan,
|
||||
TaskRecord,
|
||||
TaskStatus,
|
||||
TaskStateTransitionError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EvidenceEnvelope",
|
||||
"ProbeEvidence",
|
||||
"ProbeOutcome",
|
||||
"TaskPlan",
|
||||
"TaskRecord",
|
||||
"TaskStatus",
|
||||
"TaskStateTransitionError",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Adapters for executing tasks via worker backends."""
|
||||
|
||||
from .base import WorkerAdapter
|
||||
from .openclaw import OpenClawAdapter
|
||||
from .codex import CodexAdapter
|
||||
from .hermes import HermesAdapter
|
||||
from .a2a import A2AAdapter
|
||||
from .systemd_worker import SystemdWorkerAdapter
|
||||
|
||||
__all__ = [
|
||||
"WorkerAdapter",
|
||||
"OpenClawAdapter",
|
||||
"CodexAdapter",
|
||||
"HermesAdapter",
|
||||
"A2AAdapter",
|
||||
"SystemdWorkerAdapter",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import WorkerAdapter
|
||||
|
||||
|
||||
class A2AAdapter(WorkerAdapter):
|
||||
worker_name = "a2a"
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
return ["task.exchange", "agent.handoff"]
|
||||
|
||||
def claim(self, task):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
||||
|
||||
def execute(self, task, claim):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
||||
|
||||
def heartbeat(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
||||
|
||||
def report_progress(self, claim, progress):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
||||
|
||||
def probe(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
||||
|
||||
def cancel(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
||||
|
||||
def collect_result(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Base worker adapter contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
|
||||
|
||||
class WorkerAdapter(abc.ABC):
|
||||
worker_name = "base"
|
||||
|
||||
@abc.abstractmethod
|
||||
def capabilities(self) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def claim(self, task):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def execute(self, task, claim):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def heartbeat(self, claim):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def report_progress(self, claim, progress):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def probe(self, claim):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def cancel(self, claim):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def collect_result(self, claim):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import WorkerAdapter
|
||||
|
||||
|
||||
class CodexAdapter(WorkerAdapter):
|
||||
worker_name = "codex"
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
return ["code.read", "code.write.patch", "test.python", "review.security"]
|
||||
|
||||
def claim(self, task):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
||||
|
||||
def execute(self, task, claim):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
||||
|
||||
def heartbeat(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
||||
|
||||
def report_progress(self, claim, progress):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
||||
|
||||
def probe(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
||||
|
||||
def cancel(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
||||
|
||||
def collect_result(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import WorkerAdapter
|
||||
|
||||
|
||||
class HermesAdapter(WorkerAdapter):
|
||||
worker_name = "hermes"
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
return ["architecture.review", "policy.review", "threat.model"]
|
||||
|
||||
def claim(self, task):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
||||
|
||||
def execute(self, task, claim):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
||||
|
||||
def heartbeat(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
||||
|
||||
def report_progress(self, claim, progress):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
||||
|
||||
def probe(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
||||
|
||||
def cancel(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
||||
|
||||
def collect_result(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import WorkerAdapter
|
||||
|
||||
|
||||
class OpenClawAdapter(WorkerAdapter):
|
||||
worker_name = "openclaw"
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
return ["code.read", "code.write.patch", "test.execute", "runtime.shadow"]
|
||||
|
||||
def claim(self, task):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
||||
|
||||
def execute(self, task, claim):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
||||
|
||||
def heartbeat(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
||||
|
||||
def report_progress(self, claim, progress):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
||||
|
||||
def probe(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
||||
|
||||
def cancel(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
||||
|
||||
def collect_result(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import WorkerAdapter
|
||||
|
||||
|
||||
class SystemdWorkerAdapter(WorkerAdapter):
|
||||
worker_name = "systemd_worker"
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
return ["systemd.exec", "runtime.shell"]
|
||||
|
||||
def claim(self, task):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "claimed"}
|
||||
|
||||
def execute(self, task, claim):
|
||||
return {"worker": self.worker_name, "task_id": task.task_id, "status": "SHADOW_VERIFIED", "claim_id": claim.claim_id, "execution_mode": "shadow", "production_side_effects": False}
|
||||
|
||||
def heartbeat(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "alive"}
|
||||
|
||||
def report_progress(self, claim, progress):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "progress": progress}
|
||||
|
||||
def probe(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": claim.status}
|
||||
|
||||
def cancel(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "status": "cancelled"}
|
||||
|
||||
def collect_result(self, claim):
|
||||
return {"worker": self.worker_name, "claim_id": claim.claim_id, "result": "not_collected"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Agent registry abstraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import NotFoundError
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
def __init__(self):
|
||||
self._agents: dict[str, dict] = {}
|
||||
|
||||
def register(self, agent_id: str, metadata: dict) -> None:
|
||||
self._agents[agent_id] = {"agent_id": agent_id, **metadata}
|
||||
|
||||
def unregister(self, agent_id: str) -> None:
|
||||
if agent_id not in self._agents:
|
||||
raise NotFoundError("agent")
|
||||
del self._agents[agent_id]
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
return list(self._agents.values())
|
||||
|
||||
def get(self, agent_id: str) -> dict:
|
||||
try:
|
||||
return self._agents[agent_id]
|
||||
except KeyError:
|
||||
raise NotFoundError("agent")
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Task Plane HTTP API implementation (stdlib-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any, Callable
|
||||
|
||||
from .delegation import DelegationEngine
|
||||
from .errors import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
PayloadTooLargeError,
|
||||
TaskPlaneHTTPError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
)
|
||||
from .evidence import EvidenceService
|
||||
from .journal import TaskJournal
|
||||
from .lease import LeaseManager
|
||||
from .models import (
|
||||
EvidenceEnvelope,
|
||||
ResultEnvelope,
|
||||
TaskPlan,
|
||||
TaskRecord,
|
||||
iso8601_now,
|
||||
contains_secret_text,
|
||||
validate_identifier,
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .store import TaskPlaneStore
|
||||
from .worker_registry import WorkerRegistry
|
||||
|
||||
|
||||
def _json_payload(data: dict[str, Any], status: int = 200) -> tuple[int, bytes]:
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
return status, body
|
||||
|
||||
|
||||
def _bounded_int(value: Any, name: str, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"invalid {name}") from exc
|
||||
if not minimum <= number <= maximum:
|
||||
raise ValidationError(f"invalid {name}")
|
||||
return number
|
||||
|
||||
|
||||
class TaskPlaneService:
|
||||
def __init__(self, store: TaskPlaneStore, policy: PolicyEngine | None = None):
|
||||
self.store = store
|
||||
self.policy = policy or PolicyEngine(
|
||||
execution_enabled=False,
|
||||
identity_capabilities={
|
||||
"anon": {"task.creator", "task.read", "task.audit"}
|
||||
},
|
||||
)
|
||||
self.journal = TaskJournal(store)
|
||||
self.lease = LeaseManager(store)
|
||||
self.workers = WorkerRegistry(store)
|
||||
self.evidence = EvidenceService(store)
|
||||
self.delegation = DelegationEngine(store)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
|
||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||
if body.get("parent_task_id"):
|
||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||
issuer = body.get("issuer", {})
|
||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||
self.policy.assert_allowed("create", actor)
|
||||
try:
|
||||
plan = TaskPlan.from_dict(body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid task plan") from exc
|
||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||
return task.to_dict()
|
||||
|
||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor)
|
||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||
|
||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
return self.store.get_task(task_id).to_dict()
|
||||
|
||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor)
|
||||
return self.workers.get(worker_id).to_dict()
|
||||
|
||||
def list_workers(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor)
|
||||
return [w.to_dict() for w in self.workers.list()]
|
||||
|
||||
def register_worker(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = str(body.get("actor_identity") or body.get("identity") or "")
|
||||
is_admin = actor in self.policy.admin_identities
|
||||
try:
|
||||
existing = self.store.get_worker(str(body.get("worker_id") or ""))
|
||||
except NotFoundError:
|
||||
existing = None
|
||||
if existing is not None and str(body.get("identity") or "") != existing.identity:
|
||||
raise ConflictError("worker identity takeover rejected")
|
||||
self.policy.assert_readonly_worker_registration(actor, body)
|
||||
self.policy.assert_allowed(
|
||||
"worker.admin" if is_admin else "worker.register", actor
|
||||
)
|
||||
try:
|
||||
worker = self.workers.register(
|
||||
body,
|
||||
actor_identity=actor,
|
||||
is_admin=is_admin,
|
||||
expected_revision=body.get("expected_revision"),
|
||||
)
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise ValidationError("invalid worker registration") from exc
|
||||
return worker.to_dict()
|
||||
|
||||
def worker_heartbeat(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
interval = payload.get("heartbeat_interval_seconds")
|
||||
actor = str(payload.get("actor_identity") or "")
|
||||
self.policy.assert_readonly_worker_binding(actor, worker_id)
|
||||
self.policy.assert_allowed("worker.register", actor)
|
||||
return self.workers.heartbeat(
|
||||
worker_id,
|
||||
heartbeat_interval_seconds=interval,
|
||||
actor_identity=actor,
|
||||
expected_revision=payload.get("expected_revision"),
|
||||
).to_dict()
|
||||
|
||||
def disable_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = str(payload.get("actor_identity") or "")
|
||||
self.policy.assert_allowed("worker.admin", actor)
|
||||
return self.store.disable_worker(
|
||||
worker_id,
|
||||
actor_identity=actor,
|
||||
expected_revision=int(payload.get("expected_revision")),
|
||||
reason=str(payload.get("reason") or "disabled by administrator"),
|
||||
).to_dict()
|
||||
|
||||
def restore_worker(self, worker_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = str(payload.get("actor_identity") or "")
|
||||
self.policy.assert_allowed("worker.admin", actor)
|
||||
return self.store.restore_worker(
|
||||
worker_id,
|
||||
actor_identity=actor,
|
||||
expected_revision=int(payload.get("expected_revision")),
|
||||
).to_dict()
|
||||
|
||||
def claim(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
self.policy.assert_allowed("claim", actor)
|
||||
worker_id = body.get("worker_id")
|
||||
if not worker_id:
|
||||
raise ValidationError("worker_id required")
|
||||
|
||||
task = self.store.get_task(task_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
||||
self.policy.assert_allowed("claim", actor, task=task)
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
claim = self.lease.claim(
|
||||
task_id=task_id,
|
||||
worker_id=worker_id,
|
||||
worker_identity=actor,
|
||||
lease_seconds=lease_seconds,
|
||||
expected_revision=task.revision,
|
||||
operation_digest=task.task_plan.operation_digest(),
|
||||
)
|
||||
return {"claim_id": claim.claim_id, **claim.to_dict()}
|
||||
|
||||
def heartbeat(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
task = self.store.get_task(task_id)
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("heartbeat", actor, task=task)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
claim = self.lease.heartbeat(task_id, claim_id, lease_seconds)
|
||||
return {"claim_id": claim_id, "lease_until": claim.lease_until}
|
||||
|
||||
def progress(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
progress = body.get("progress")
|
||||
stage = body.get("stage", "RUNNING")
|
||||
message = body.get("message")
|
||||
if progress is None:
|
||||
raise ValidationError("progress required")
|
||||
task = self.store.get_task(task_id)
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("progress", actor, task=task)
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if message not in {None, ""} or contains_secret_text(stage):
|
||||
raise ValidationError(
|
||||
"credential-secret progress must contain metadata only"
|
||||
)
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
claim = self.store.record_progress(
|
||||
task_id,
|
||||
claim_id,
|
||||
actor,
|
||||
progress=progress,
|
||||
stage=str(stage),
|
||||
message=None if message is None else str(message),
|
||||
lease_seconds=lease_seconds,
|
||||
)
|
||||
return {"claim_id": claim.claim_id, "status": claim.status, "progress": progress}
|
||||
|
||||
def complete(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
task = self.store.get_task(task_id)
|
||||
|
||||
status = body.get("status", "COMPLETED")
|
||||
outputs = body.get("outputs", [])
|
||||
evidences = body.get("evidence", [])
|
||||
if not isinstance(outputs, list):
|
||||
raise ValidationError("outputs must be list")
|
||||
if not isinstance(evidences, list):
|
||||
raise ValidationError("evidence must be list")
|
||||
if any(not isinstance(ev, dict) for ev in evidences):
|
||||
raise ValidationError("evidence items must be objects")
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("result", actor, task=task)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
allowed_result_keys = {
|
||||
"kind",
|
||||
"hash",
|
||||
"sha256",
|
||||
"count",
|
||||
"status",
|
||||
"error_code",
|
||||
"signature",
|
||||
"attestation_ref",
|
||||
}
|
||||
for entry in [*outputs, *evidences]:
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or set(entry) - allowed_result_keys
|
||||
or contains_secret_text(entry)
|
||||
):
|
||||
raise ValidationError(
|
||||
"credential-secret result must contain hash/count/status only"
|
||||
)
|
||||
|
||||
result = ResultEnvelope(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
worker_identity=actor,
|
||||
status=status,
|
||||
outputs=outputs,
|
||||
evidence=evidences,
|
||||
started_at=str(body.get("started_at") or iso8601_now()),
|
||||
completed_at=str(body.get("completed_at") or iso8601_now()),
|
||||
result_digest=body.get("result_digest"),
|
||||
signature=body.get("signature"),
|
||||
attestation_ref=body.get("attestation_ref"),
|
||||
operation_digest=claim.operation_digest,
|
||||
)
|
||||
evidence_decision = self.policy.evidence_verifier.verify(task, evidences)
|
||||
if not evidence_decision.allowed:
|
||||
raise ForbiddenError(evidence_decision.reason)
|
||||
result_decision = self.policy.verify_result(result, task)
|
||||
if not result_decision.allowed:
|
||||
raise ForbiddenError(result_decision.reason)
|
||||
result.result_digest = result.computed_digest()
|
||||
previous = self.store._last_evidence_hash(task_id)
|
||||
evidence_entries = []
|
||||
for evidence in evidences:
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task_id,
|
||||
claim_id=claim_id,
|
||||
kind=str(evidence.get("kind") or evidence.get("type") or "generic"),
|
||||
worker_identity=actor,
|
||||
payload=evidence,
|
||||
previous_evidence_hash=previous,
|
||||
signature=evidence.get("signature"),
|
||||
attestation_ref=evidence.get("attestation_ref"),
|
||||
)
|
||||
evidence_entries.append(envelope)
|
||||
previous = envelope.evidence_digest
|
||||
self.store.complete_claim_with_evidence_and_event(
|
||||
task_id,
|
||||
claim_id,
|
||||
status
|
||||
if status
|
||||
in {"COMPLETED", "FAILED_FINAL", "FAILED_RECOVERABLE", "CANCELLED"}
|
||||
else "COMPLETED",
|
||||
result.to_dict(),
|
||||
evidence_entries,
|
||||
)
|
||||
return result.to_dict()
|
||||
|
||||
def fail(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
claim_id = body.get("claim_id")
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
reason = body.get("reason", "failed")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
self.policy.assert_allowed("fail", actor)
|
||||
task = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("fail", actor, task=task)
|
||||
claim = self.store.get_claim(claim_id)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
status = "FAILED_RECOVERABLE" if body.get("recoverable", False) else "FAILED_FINAL"
|
||||
self.store.fail_claim_with_event(task_id, claim_id, status, str(reason))
|
||||
return {"task_id": task_id, "status": status, "reason": reason}
|
||||
|
||||
def cancel(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
||||
self.policy.assert_allowed("cancel", actor)
|
||||
reason = body.get("reason", "cancelled")
|
||||
task = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("cancel", actor, task=task)
|
||||
updated = self.store.cancel_task_with_event(
|
||||
task_id, task.revision, reason=reason
|
||||
)
|
||||
return updated.to_dict()
|
||||
|
||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or "owner"
|
||||
self.policy.assert_allowed("delegate", actor)
|
||||
parent = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||
if "task" in body:
|
||||
try:
|
||||
child_plan = TaskPlan.from_dict(body["task"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
else:
|
||||
plan_body = dict(body)
|
||||
plan_body.pop("actor_identity", None)
|
||||
try:
|
||||
child_plan = TaskPlan.from_dict(plan_body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
child_plan.parent_task_id = task_id
|
||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||
return child.to_dict()
|
||||
|
||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
return self.store.list_events(task_id)
|
||||
|
||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
return self.evidence.list(task_id)
|
||||
|
||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
return self.store.list_children(task_id)
|
||||
|
||||
|
||||
class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
service: TaskPlaneService | None = None
|
||||
authenticator: Callable[[str], str | None] | None = None
|
||||
max_body_bytes = 1_000_000
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# BaseHTTPRequestHandler logs the raw request target; suppress it so
|
||||
# attacker-controlled paths or query strings cannot leak credentials.
|
||||
return
|
||||
|
||||
def _json(self) -> tuple[str | None, dict[str, Any]]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid content length") from exc
|
||||
if length < 0:
|
||||
raise ValidationError("invalid content length")
|
||||
if length > self.max_body_bytes:
|
||||
raise PayloadTooLargeError("payload exceeds limit")
|
||||
raw = self.rfile.read(length) if length > 0 else b""
|
||||
if not raw:
|
||||
return None, {}
|
||||
try:
|
||||
decoded = raw.decode("utf-8")
|
||||
payload = json.loads(decoded)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValidationError("JSON payload must be an object")
|
||||
return decoded, payload
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValidationError("invalid JSON payload") from exc
|
||||
|
||||
def _response(self, status: int, payload: Any) -> None:
|
||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _authenticated_identity(self) -> str:
|
||||
authenticator = type(self).authenticator
|
||||
if authenticator is None:
|
||||
raise UnauthorizedError("authentication unavailable")
|
||||
header = self.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
raise UnauthorizedError("bearer authentication required")
|
||||
token = header[7:]
|
||||
if not token or any(ord(ch) < 33 for ch in token):
|
||||
raise UnauthorizedError("invalid bearer token")
|
||||
identity = authenticator(token)
|
||||
if not identity:
|
||||
raise UnauthorizedError("invalid credentials")
|
||||
return str(identity)
|
||||
|
||||
def _handle(self, handler):
|
||||
try:
|
||||
if self.service is None:
|
||||
raise TaskPlaneHTTPError("service not configured")
|
||||
result = handler()
|
||||
if isinstance(result, tuple):
|
||||
status, payload = result
|
||||
else:
|
||||
status, payload = 200, result
|
||||
self._response(status, payload)
|
||||
except TaskPlaneHTTPError as exc:
|
||||
self._response(exc.status_code, exc.to_payload())
|
||||
except Exception as exc: # defensive
|
||||
if isinstance(exc, TaskPlaneHTTPError):
|
||||
raise
|
||||
self._response(
|
||||
500,
|
||||
{"error": "internal_error", "message": "internal server error"},
|
||||
)
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
def route():
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
parts = [p for p in path.split("/") if p]
|
||||
for part in parts[1:]:
|
||||
if part not in {"events", "evidence", "children", "heartbeat"}:
|
||||
try:
|
||||
validate_identifier(part, "resource identifier")
|
||||
except ValueError as exc:
|
||||
raise ValidationError("invalid resource identifier") from exc
|
||||
identity = self._authenticated_identity()
|
||||
|
||||
if len(parts) == 1 and parts[0] == "tasks":
|
||||
return 200, self.service.list_tasks(identity)
|
||||
if len(parts) == 2 and parts[0] == "tasks":
|
||||
task_id = parts[1]
|
||||
return 200, self.service.get_task(task_id, identity)
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "events":
|
||||
return 200, self.service.get_events(parts[1], identity)
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "evidence":
|
||||
return 200, self.service.get_evidence(parts[1], identity)
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "children":
|
||||
return 200, self.service.get_children(parts[1], identity)
|
||||
if len(parts) == 1 and parts[0] == "workers":
|
||||
return 200, self.service.list_workers(identity)
|
||||
if len(parts) == 2 and parts[0] == "workers":
|
||||
return 200, self.service.get_worker(parts[1], identity)
|
||||
raise NotFoundError("unknown endpoint")
|
||||
|
||||
self._handle(route)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
def route():
|
||||
body_text, body = self._json()
|
||||
identity = self._authenticated_identity()
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
parts = [p for p in path.split("/") if p]
|
||||
headers = {k.lower(): v for k, v in self.headers.items()}
|
||||
|
||||
if len(parts) == 1 and parts[0] == "tasks":
|
||||
issuer = body.get("issuer")
|
||||
if issuer and (
|
||||
not isinstance(issuer, dict) or issuer.get("id") != identity
|
||||
):
|
||||
raise ForbiddenError("issuer identity mismatch")
|
||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||
if body.get("actor_identity") not in {None, identity}:
|
||||
raise ForbiddenError("actor identity mismatch")
|
||||
body["actor_identity"] = identity
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "claim":
|
||||
return 200, self.service.claim(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "heartbeat":
|
||||
return 200, self.service.heartbeat(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "progress":
|
||||
return 200, self.service.progress(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "result":
|
||||
return 200, self.service.complete(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "fail":
|
||||
return 200, self.service.fail(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "cancel":
|
||||
return 200, self.service.cancel(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "tasks" and parts[2] == "delegate":
|
||||
return 200, self.service.delegate(parts[1], body or {})
|
||||
|
||||
if len(parts) == 2 and parts[0] == "workers" and parts[1] == "register":
|
||||
if (
|
||||
body.get("identity") != identity
|
||||
and identity not in self.service.policy.admin_identities
|
||||
):
|
||||
raise ForbiddenError("worker identity mismatch")
|
||||
return 201, self.service.register_worker(body or {})
|
||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "heartbeat":
|
||||
return 200, self.service.worker_heartbeat(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "disable":
|
||||
return 200, self.service.disable_worker(parts[1], body or {})
|
||||
if len(parts) == 3 and parts[0] == "workers" and parts[2] == "restore":
|
||||
return 200, self.service.restore_worker(parts[1], body or {})
|
||||
raise NotFoundError("unknown endpoint")
|
||||
|
||||
self._handle(route)
|
||||
|
||||
|
||||
def run_server(
|
||||
host: str,
|
||||
port: int,
|
||||
service: TaskPlaneService,
|
||||
*,
|
||||
authenticator: Callable[[str], str | None] | None = None,
|
||||
):
|
||||
handler = TaskPlaneRequestHandler
|
||||
handler.service = service
|
||||
handler.authenticator = authenticator
|
||||
server = ThreadingHTTPServer((host, port), handler)
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Capability matcher helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fnmatch import fnmatch
|
||||
|
||||
|
||||
def _normalize_capability(capability: str) -> str:
|
||||
return capability.strip().lower()
|
||||
|
||||
|
||||
def capability_matches(granted: str, required: str) -> bool:
|
||||
granted = _normalize_capability(granted)
|
||||
required = _normalize_capability(required)
|
||||
if granted == required:
|
||||
return True
|
||||
# A granted wildcard such as "test.*" should match required "test.unit"
|
||||
if "*" in granted:
|
||||
return fnmatch(required, granted)
|
||||
# A required wildcard is treated as a suffix/prefix requirement for callers.
|
||||
if "*" in required:
|
||||
return fnmatch(granted, required)
|
||||
return False
|
||||
|
||||
|
||||
def has_capabilities(granted_caps: list[str], required_caps: list[str]) -> bool:
|
||||
granted = [_normalize_capability(c) for c in granted_caps]
|
||||
for required in required_caps:
|
||||
required = _normalize_capability(required)
|
||||
if not any(capability_matches(g, required) for g in granted):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def normalize_capabilities(caps: list[str]) -> list[str]:
|
||||
return sorted({_normalize_capability(cap) for cap in caps})
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Delegation guardrails."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import ConflictError, ForbiddenError, NotFoundError
|
||||
from .models import Budget, TaskPlan, TaskRecord
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
class DelegationEngine:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def can_delegate(self, parent_id: str, child_plan: TaskPlan) -> bool:
|
||||
parent = self.store.get_task(parent_id)
|
||||
if parent.status == "COMPLETED":
|
||||
raise ConflictError("cannot delegate completed task")
|
||||
if parent.task_plan.max_delegation_depth <= 0:
|
||||
raise ConflictError("max delegation depth not configured")
|
||||
|
||||
depth = self._depth(parent_id)
|
||||
if depth >= parent.task_plan.max_delegation_depth:
|
||||
raise ConflictError("max delegation depth reached")
|
||||
|
||||
if child_plan.parent_task_id not in {None, parent_id}:
|
||||
raise ConflictError("delegation parent mismatch")
|
||||
current = parent_id
|
||||
while current:
|
||||
if child_plan.task_id and current == child_plan.task_id:
|
||||
raise ConflictError("delegation cycle")
|
||||
current = self.store.parent_of(current)
|
||||
return True
|
||||
|
||||
def delegate(self, parent_id: str, child_plan: TaskPlan, *, caller: str) -> TaskRecord:
|
||||
self.can_delegate(parent_id, child_plan)
|
||||
parent = self.store.get_task(parent_id)
|
||||
if child_plan.required_capabilities and not set(child_plan.required_capabilities).issubset(
|
||||
set(parent.task_plan.required_capabilities or [])
|
||||
):
|
||||
raise ForbiddenError("child capabilities exceed parent constraints")
|
||||
risk_rank = {"low": 1, "medium": 2, "high": 3}
|
||||
if risk_rank.get(child_plan.risk, 99) > risk_rank.get(parent.task_plan.risk, -1):
|
||||
raise ForbiddenError("child risk exceeds parent constraints")
|
||||
child_plan.parent_task_id = parent_id
|
||||
child_plan.correlation_id = parent.task_plan.correlation_id or parent.task_id
|
||||
child_plan.preferred_workers = parent.task_plan.preferred_workers
|
||||
child_plan.max_delegation_depth = min(
|
||||
child_plan.max_delegation_depth,
|
||||
parent.task_plan.max_delegation_depth,
|
||||
)
|
||||
child_plan.approval_policy = parent.task_plan.approval_policy
|
||||
|
||||
parent_budget = self.store.get_task_budget(parent_id)
|
||||
required = Budget()
|
||||
for name in ("time_seconds", "attempts", "tokens", "cost"):
|
||||
available = getattr(parent_budget, name)
|
||||
requested = getattr(child_plan.budgets, name)
|
||||
if name == "attempts" and requested is None:
|
||||
requested = 1
|
||||
if available is not None:
|
||||
requested = 0 if requested is None else requested
|
||||
if requested > available:
|
||||
raise ConflictError(f"budget exceeded for {name}")
|
||||
setattr(child_plan.budgets, name, requested)
|
||||
setattr(required, name, requested)
|
||||
|
||||
# A retried identical delegation is a read, not a second budget charge.
|
||||
if child_plan.task_id:
|
||||
try:
|
||||
existing = self.store.get_task(child_plan.task_id)
|
||||
except NotFoundError:
|
||||
existing = None
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.task_plan.parent_task_id == parent_id
|
||||
and existing.task_plan.operation_digest()
|
||||
== child_plan.operation_digest()
|
||||
):
|
||||
return existing
|
||||
raise ConflictError("delegation task_id conflict")
|
||||
|
||||
return self.store.delegate_task_with_budget_and_events(
|
||||
parent_id, child_plan, caller=caller
|
||||
)
|
||||
|
||||
def _depth(self, task_id: str) -> int:
|
||||
return self.store._compute_depth(task_id)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Error types for task plane operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TaskPlaneError(Exception):
|
||||
"""Base exception for API-compatible failures."""
|
||||
|
||||
status_code = 500
|
||||
error_code = "task_plane_error"
|
||||
|
||||
def __init__(self, message: str, *, code: str | None = None, details: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
if code:
|
||||
self.error_code = code
|
||||
|
||||
def to_payload(self) -> dict:
|
||||
return {"error": self.error_code, "message": self.message, "details": self.details}
|
||||
|
||||
|
||||
class TaskPlaneHTTPError(TaskPlaneError):
|
||||
"""Base class with explicit HTTP status."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class ValidationError(TaskPlaneHTTPError):
|
||||
status_code = 400
|
||||
error_code = "validation_error"
|
||||
|
||||
|
||||
class UnauthorizedError(TaskPlaneHTTPError):
|
||||
status_code = 401
|
||||
error_code = "unauthorized"
|
||||
|
||||
|
||||
class ForbiddenError(TaskPlaneHTTPError):
|
||||
status_code = 403
|
||||
error_code = "forbidden"
|
||||
|
||||
|
||||
class NotFoundError(TaskPlaneHTTPError):
|
||||
status_code = 404
|
||||
error_code = "not_found"
|
||||
|
||||
|
||||
class ConflictError(TaskPlaneHTTPError):
|
||||
status_code = 409
|
||||
error_code = "conflict"
|
||||
|
||||
|
||||
class PayloadTooLargeError(TaskPlaneHTTPError):
|
||||
status_code = 413
|
||||
error_code = "payload_too_large"
|
||||
|
||||
|
||||
class FailedDependencyError(TaskPlaneHTTPError):
|
||||
status_code = 424
|
||||
error_code = "failed_dependency"
|
||||
|
||||
|
||||
class LockedError(TaskPlaneHTTPError):
|
||||
status_code = 423
|
||||
error_code = "locked"
|
||||
|
||||
|
||||
class ServiceUnavailableError(TaskPlaneHTTPError):
|
||||
status_code = 503
|
||||
error_code = "service_unavailable"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CloudEvents-compatible event envelope and durable outbox helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class CloudEvent:
|
||||
specversion: str = "1.0"
|
||||
id: str = ""
|
||||
type: str = ""
|
||||
source: str = "spiffe://aios/task-plane"
|
||||
subject: str = ""
|
||||
datacontenttype: str = "application/json"
|
||||
data: dict | None = None
|
||||
|
||||
@property
|
||||
def time(self) -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"specversion": self.specversion,
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"source": self.source,
|
||||
"subject": self.subject,
|
||||
"time": self.time,
|
||||
"datacontenttype": self.datacontenttype,
|
||||
"data": self.data or {},
|
||||
}
|
||||
|
||||
|
||||
NOT_DELIVERED = "NOT_DELIVERED"
|
||||
DELIVERED = "DELIVERED"
|
||||
FAILED_DELIVERY = "FAILED"
|
||||
|
||||
|
||||
class NotificationAdapter(Protocol):
|
||||
name: str
|
||||
|
||||
def deliver(self, envelope: dict) -> str | None:
|
||||
"""Return a provider receipt, or None when delivery is unconfirmed."""
|
||||
|
||||
|
||||
class CallbackNotificationAdapter:
|
||||
"""Adapter seam for webhook, NATS, GPT, dashboard, and WeChat transports."""
|
||||
|
||||
def __init__(self, name: str, callback: Callable[[dict], str | None]):
|
||||
self.name = name
|
||||
self._callback = callback
|
||||
|
||||
def deliver(self, envelope: dict) -> str | None:
|
||||
return self._callback(envelope)
|
||||
|
||||
|
||||
class WebhookAdapter(CallbackNotificationAdapter):
|
||||
def __init__(self, callback: Callable[[dict], str | None]):
|
||||
super().__init__("webhook", callback)
|
||||
|
||||
|
||||
class NatsJetStreamAdapter(CallbackNotificationAdapter):
|
||||
def __init__(self, callback: Callable[[dict], str | None]):
|
||||
super().__init__("nats-jetstream", callback)
|
||||
|
||||
|
||||
class GPTResultCallbackAdapter(CallbackNotificationAdapter):
|
||||
def __init__(self, callback: Callable[[dict], str | None]):
|
||||
super().__init__("gpt-result-callback", callback)
|
||||
|
||||
|
||||
class DashboardAdapter(CallbackNotificationAdapter):
|
||||
def __init__(self, callback: Callable[[dict], str | None]):
|
||||
super().__init__("dashboard", callback)
|
||||
|
||||
|
||||
class WeChatGatewayAdapter(CallbackNotificationAdapter):
|
||||
def __init__(self, callback: Callable[[dict], str | None]):
|
||||
super().__init__("wechat-gateway", callback)
|
||||
|
||||
|
||||
class DurableOutboxDispatcher:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def dispatch(self, message_id: str, adapter: NotificationAdapter) -> dict:
|
||||
row = self.store.get_outbox_message(message_id)
|
||||
import json
|
||||
|
||||
envelope = json.loads(row["payload"])
|
||||
try:
|
||||
receipt = adapter.deliver(envelope)
|
||||
except Exception:
|
||||
return self.store.record_outbox_delivery(
|
||||
message_id,
|
||||
receipt=None,
|
||||
error=f"{adapter.name} delivery failed",
|
||||
)
|
||||
return self.store.record_outbox_delivery(
|
||||
message_id,
|
||||
receipt=str(receipt) if receipt else None,
|
||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Evidence helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
def evidence_digest(payload: dict) -> str:
|
||||
body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class EvidenceService:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def add(self, task_id: str, kind: str, payload: dict, worker_identity: str) -> str:
|
||||
return self.store.add_evidence(task_id, kind, payload, worker_identity)
|
||||
|
||||
def list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_evidence(task_id)
|
||||
|
||||
def verify(self, task_id: str, required_kinds: list[str]) -> bool:
|
||||
evidences = self.list(task_id)
|
||||
observed = {e["kind"] for e in evidences}
|
||||
return set(required_kinds).issubset(
|
||||
observed
|
||||
) and self.store.verify_evidence_integrity(task_id)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Event journaling helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import ValidationError
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
class TaskJournal:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def append(self, task_id: str, event_type: str, data: dict, *, subject: str | None = None) -> None:
|
||||
if not event_type:
|
||||
raise ValidationError("event_type required")
|
||||
self.store.append_event(task_id, event_type, data, subject=subject)
|
||||
|
||||
def list(self, task_id: str) -> list[dict]:
|
||||
return self.store.list_events(task_id)
|
||||
|
||||
def check_chain(self, task_id: str) -> bool:
|
||||
return self.store.verify_event_chain(task_id)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Claim and lease management helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import LockedError
|
||||
from .models import ProbeEvidence
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
class LeaseManager:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def claim(self, task_id: str, worker_id: str, worker_identity: str, lease_seconds: int, expected_revision: int, operation_digest: str):
|
||||
return self.store.claim_task(task_id, worker_id, worker_identity, lease_seconds, expected_revision, operation_digest)
|
||||
|
||||
def heartbeat(self, task_id: str, claim_id: str, lease_seconds: int):
|
||||
claim = self.store.heartbeat_claim(task_id, claim_id, lease_seconds)
|
||||
return claim
|
||||
|
||||
def probe(self, claim_id: str):
|
||||
claim = self.store.get_claim(claim_id)
|
||||
if claim.status != "ACTIVE":
|
||||
raise LockedError("claim not active")
|
||||
return claim
|
||||
|
||||
def recover(self, task_id: str, claim_id: str, probe: ProbeEvidence):
|
||||
return self.store.recover_expired_claim(
|
||||
task_id,
|
||||
claim_id,
|
||||
probe,
|
||||
)
|
||||
@@ -0,0 +1,829 @@
|
||||
"""Core data models for the task plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from enum import StrEnum
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"authorization",
|
||||
"private_key",
|
||||
"client_secret",
|
||||
"credential",
|
||||
}
|
||||
IDENTIFIER_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
||||
TYPE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
|
||||
RISK_LEVELS = {"low", "medium", "high"}
|
||||
DATA_CLASSIFICATIONS = {
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"credential-secret",
|
||||
}
|
||||
SECRET_TEXT_PATTERNS = (
|
||||
re.compile(r"(?i)\bAuthorization\s*:\s*Bearer\s+\S+"),
|
||||
re.compile(r"(?i)\b(?:password|passwd|api[_-]?key|access[_-]?token|secret)\s*[:=]\s*\S+"),
|
||||
re.compile(r"\bsk-[A-Za-z0-9_-]{12,}\b"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
TASK_PLAN_KEYS = {
|
||||
"schema_version",
|
||||
"task_id",
|
||||
"task_type",
|
||||
"issuer",
|
||||
"intent",
|
||||
"required_capabilities",
|
||||
"preferred_workers",
|
||||
"risk",
|
||||
"approval_policy",
|
||||
"dependencies",
|
||||
"max_delegation_depth",
|
||||
"budgets",
|
||||
"evidence_requirements",
|
||||
"parent_task_id",
|
||||
"correlation_id",
|
||||
"metadata",
|
||||
"data_classification",
|
||||
"execution_zone",
|
||||
"network_policy",
|
||||
"logging_policy",
|
||||
"allowed_input_paths",
|
||||
"allowed_output_paths",
|
||||
"output_policy",
|
||||
}
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def compute_sha256_hex(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def contains_secret_text(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return any(pattern.search(value) for pattern in SECRET_TEXT_PATTERNS)
|
||||
if isinstance(value, dict):
|
||||
return any(contains_secret_text(item) for item in value.values())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(contains_secret_text(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def validate_relative_paths(values: Any, name: str) -> list[str]:
|
||||
paths = _normalize_list(values)
|
||||
for value in paths:
|
||||
decoded = urllib.parse.unquote(value)
|
||||
parts = value.replace("\\", "/").split("/")
|
||||
if (
|
||||
decoded != value
|
||||
or "\\" in value
|
||||
or value.startswith("/")
|
||||
or re.match(r"^[A-Za-z]:", value)
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return paths
|
||||
|
||||
|
||||
def validate_identifier(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if (
|
||||
not IDENTIFIER_RE.fullmatch(text)
|
||||
or urllib.parse.unquote(text) != text
|
||||
or any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
|
||||
):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def validate_type(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not TYPE_RE.fullmatch(text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def redact_value(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
redacted = {}
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if lowered in SENSITIVE_KEYS or lowered.endswith(
|
||||
("_password", "_secret", "_access_token", "_refresh_token", "_private_key")
|
||||
):
|
||||
redacted[key] = "[REDACTED]"
|
||||
else:
|
||||
redacted[key] = redact_value(inner)
|
||||
return redacted
|
||||
if isinstance(value, list):
|
||||
return [redact_value(v) for v in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(redact_value(v) for v in value)
|
||||
return value
|
||||
|
||||
|
||||
class TaskStateTransitionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskStatus(StrEnum):
|
||||
CREATED = "CREATED"
|
||||
QUEUED = "QUEUED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
WAITING_CHILD = "WAITING_CHILD"
|
||||
VERIFYING = "VERIFYING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED_RECOVERABLE = "FAILED_RECOVERABLE"
|
||||
FAILED_FINAL = "FAILED_FINAL"
|
||||
CANCELLED = "CANCELLED"
|
||||
MANUAL_REVIEW_REQUIRED = "MANUAL_REVIEW_REQUIRED"
|
||||
|
||||
|
||||
TASK_STATES = {
|
||||
status.value for status in TaskStatus
|
||||
}
|
||||
|
||||
|
||||
TASK_NEXT_STATES = {
|
||||
"CREATED": {"QUEUED", "CLAIMED", "CANCELLED", "MANUAL_REVIEW_REQUIRED"},
|
||||
"QUEUED": {"CLAIMED", "CANCELLED", "FAILED_RECOVERABLE", "FAILED_FINAL"},
|
||||
"CLAIMED": {"RUNNING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
||||
"RUNNING": {"VERIFYING", "WAITING_CHILD", "COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
||||
"WAITING_CHILD": {"RUNNING", "VERIFYING", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
||||
"VERIFYING": {"COMPLETED", "FAILED_RECOVERABLE", "FAILED_FINAL", "CANCELLED"},
|
||||
"COMPLETED": set(),
|
||||
"FAILED_RECOVERABLE": {"CLAIMED", "RUNNING", "CANCELLED"},
|
||||
"FAILED_FINAL": set(),
|
||||
"CANCELLED": set(),
|
||||
"MANUAL_REVIEW_REQUIRED": {"RUNNING", "CANCELLED"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Budget:
|
||||
time_seconds: int | None = None
|
||||
attempts: int | None = None
|
||||
tokens: int | None = None
|
||||
cost: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "Budget":
|
||||
if not data:
|
||||
return cls()
|
||||
if not isinstance(data, dict) or set(data) - {
|
||||
"time_seconds",
|
||||
"attempts",
|
||||
"tokens",
|
||||
"cost",
|
||||
}:
|
||||
raise ValueError("invalid budget")
|
||||
for name in ("time_seconds", "attempts", "tokens", "cost"):
|
||||
value = data.get(name)
|
||||
if value is not None:
|
||||
try:
|
||||
if float(value) < 0:
|
||||
raise ValueError("budget values must be non-negative")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("invalid budget value") from exc
|
||||
return cls(
|
||||
time_seconds=_to_positive_int(data.get("time_seconds")),
|
||||
attempts=_to_positive_int(data.get("attempts")),
|
||||
tokens=_to_positive_int(data.get("tokens")),
|
||||
cost=_to_float(data.get("cost")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"time_seconds": self.time_seconds,
|
||||
"attempts": self.attempts,
|
||||
"tokens": self.tokens,
|
||||
"cost": self.cost,
|
||||
}
|
||||
|
||||
def merge(self, other: "Budget") -> "Budget":
|
||||
return Budget(
|
||||
time_seconds=_first_non_none(self.time_seconds, other.time_seconds),
|
||||
attempts=_first_non_none(self.attempts, other.attempts),
|
||||
tokens=_first_non_none(self.tokens, other.tokens),
|
||||
cost=_first_non_none(self.cost, other.cost),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskPlan:
|
||||
schema_version: int = 1
|
||||
task_id: str | None = None
|
||||
task_type: str = "code.change"
|
||||
issuer: dict[str, Any] = field(default_factory=dict)
|
||||
intent: str | None = None
|
||||
required_capabilities: list[str] = field(default_factory=list)
|
||||
preferred_workers: list[str] = field(default_factory=list)
|
||||
risk: str = "medium"
|
||||
approval_policy: str = "patch-only"
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
max_delegation_depth: int = 5
|
||||
budgets: Budget = field(default_factory=Budget)
|
||||
evidence_requirements: list[str] = field(default_factory=list)
|
||||
parent_task_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
data_classification: str = "internal"
|
||||
execution_zone: str = "isolated-development"
|
||||
network_policy: str = "controlled"
|
||||
logging_policy: str = "redacted"
|
||||
allowed_input_paths: list[str] = field(default_factory=list)
|
||||
allowed_output_paths: list[str] = field(default_factory=list)
|
||||
output_policy: str = "standard"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "TaskPlan":
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("task plan payload must be an object")
|
||||
unknown = set(data) - TASK_PLAN_KEYS
|
||||
if unknown:
|
||||
raise ValueError("unknown task plan fields")
|
||||
if "task_id" not in data or data["task_id"] is None:
|
||||
data = dict(data)
|
||||
data["task_id"] = f"task-{uuid.uuid4()}"
|
||||
risk = str(data.get("risk", "medium"))
|
||||
if risk not in RISK_LEVELS:
|
||||
raise ValueError("invalid risk")
|
||||
issuer = data.get("issuer", {})
|
||||
if not isinstance(issuer, dict):
|
||||
raise ValueError("issuer must be an object")
|
||||
schema_version = int(data.get("schema_version", 1))
|
||||
if schema_version != 1:
|
||||
raise ValueError("unsupported schema_version")
|
||||
max_depth = _to_positive_int(data.get("max_delegation_depth"), default=5)
|
||||
if max_depth is None or max_depth > 32:
|
||||
raise ValueError("invalid max_delegation_depth")
|
||||
classification = str(data.get("data_classification", "internal"))
|
||||
if classification not in DATA_CLASSIFICATIONS:
|
||||
raise ValueError("invalid data_classification")
|
||||
execution_zone = validate_type(
|
||||
data.get("execution_zone", "isolated-development"), "execution_zone"
|
||||
)
|
||||
network_policy = validate_type(
|
||||
data.get("network_policy", "controlled"), "network_policy"
|
||||
)
|
||||
logging_policy = validate_type(
|
||||
data.get("logging_policy", "redacted"), "logging_policy"
|
||||
)
|
||||
output_policy = validate_type(
|
||||
data.get("output_policy", "standard"), "output_policy"
|
||||
)
|
||||
if contains_secret_text(
|
||||
{
|
||||
"intent": data.get("intent"),
|
||||
"metadata": data.get("metadata", {}),
|
||||
}
|
||||
):
|
||||
raise ValueError("task content contains secret material")
|
||||
if classification == "credential-secret":
|
||||
if execution_zone != "secure-file-transformer":
|
||||
raise ValueError("credential-secret requires secure-file-transformer")
|
||||
if network_policy != "denied":
|
||||
raise ValueError("credential-secret requires network denied")
|
||||
if logging_policy != "metadata-only":
|
||||
raise ValueError("credential-secret requires metadata-only logging")
|
||||
if output_policy != "hash-count-status-only":
|
||||
raise ValueError("credential-secret requires restricted output")
|
||||
return cls(
|
||||
schema_version=schema_version,
|
||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
||||
issuer=issuer,
|
||||
intent=data.get("intent"),
|
||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
||||
preferred_workers=_normalize_list(data.get("preferred_workers", [])),
|
||||
risk=risk,
|
||||
approval_policy=validate_type(
|
||||
data.get("approval_policy", "patch-only"), "approval_policy"
|
||||
),
|
||||
dependencies=_normalize_list(data.get("dependencies", [])),
|
||||
max_delegation_depth=max_depth,
|
||||
budgets=Budget.from_dict(data.get("budgets")),
|
||||
evidence_requirements=_normalize_list(data.get("evidence_requirements", [])),
|
||||
parent_task_id=data.get("parent_task_id"),
|
||||
correlation_id=data.get("correlation_id"),
|
||||
metadata=data.get("metadata", {}) if isinstance(data.get("metadata", {}), dict) else {},
|
||||
data_classification=classification,
|
||||
execution_zone=execution_zone,
|
||||
network_policy=network_policy,
|
||||
logging_policy=logging_policy,
|
||||
allowed_input_paths=validate_relative_paths(
|
||||
data.get("allowed_input_paths", []), "allowed_input_paths"
|
||||
),
|
||||
allowed_output_paths=validate_relative_paths(
|
||||
data.get("allowed_output_paths", []), "allowed_output_paths"
|
||||
),
|
||||
output_policy=output_policy,
|
||||
)
|
||||
|
||||
def to_dict(self, *, redact: bool = False) -> dict[str, Any]:
|
||||
payload = {
|
||||
"schema_version": self.schema_version,
|
||||
"task_id": self.task_id,
|
||||
"task_type": self.task_type,
|
||||
"issuer": self.issuer,
|
||||
"intent": self.intent,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"preferred_workers": list(self.preferred_workers),
|
||||
"risk": self.risk,
|
||||
"approval_policy": self.approval_policy,
|
||||
"dependencies": list(self.dependencies),
|
||||
"max_delegation_depth": self.max_delegation_depth,
|
||||
"budgets": self.budgets.to_dict(),
|
||||
"evidence_requirements": list(self.evidence_requirements),
|
||||
"parent_task_id": self.parent_task_id,
|
||||
"correlation_id": self.correlation_id,
|
||||
"metadata": dict(self.metadata),
|
||||
"data_classification": self.data_classification,
|
||||
"execution_zone": self.execution_zone,
|
||||
"network_policy": self.network_policy,
|
||||
"logging_policy": self.logging_policy,
|
||||
"allowed_input_paths": list(self.allowed_input_paths),
|
||||
"allowed_output_paths": list(self.allowed_output_paths),
|
||||
"output_policy": self.output_policy,
|
||||
}
|
||||
if redact:
|
||||
payload = redact_value(payload)
|
||||
return payload
|
||||
|
||||
def operation_digest(self) -> str:
|
||||
return compute_sha256_hex(self.to_dict())
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRecord:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
task_plan: TaskPlan
|
||||
created_at: float
|
||||
updated_at: float
|
||||
root_task_id: str
|
||||
idempotency_key: str | None = None
|
||||
failure_reason: str | None = None
|
||||
|
||||
def with_status(self, status: str) -> "TaskRecord":
|
||||
allowed = TASK_NEXT_STATES.get(self.status, set())
|
||||
if status != self.status and (not allowed and self.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}):
|
||||
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
|
||||
if status not in TASK_STATES:
|
||||
raise TaskStateTransitionError(f"Invalid status {status}")
|
||||
if status != self.status and self.status in TASK_NEXT_STATES and status not in TASK_NEXT_STATES[self.status]:
|
||||
raise TaskStateTransitionError(f"Cannot transition {self.status} -> {status}")
|
||||
return TaskRecord(
|
||||
task_id=self.task_id,
|
||||
status=status,
|
||||
revision=self.revision + 1,
|
||||
task_plan=self.task_plan,
|
||||
created_at=self.created_at,
|
||||
updated_at=_now_ts(),
|
||||
root_task_id=self.root_task_id,
|
||||
idempotency_key=self.idempotency_key,
|
||||
failure_reason=self.failure_reason,
|
||||
)
|
||||
|
||||
def to_dict(self, *, include_task=True, redact_payload: bool = True) -> dict[str, Any]:
|
||||
payload = {
|
||||
"task_id": self.task_id,
|
||||
"status": self.status,
|
||||
"revision": self.revision,
|
||||
"root_task_id": self.root_task_id,
|
||||
"parent_task_id": self.task_plan.parent_task_id,
|
||||
"created_at": _fmt_ts(self.created_at),
|
||||
"updated_at": _fmt_ts(self.updated_at),
|
||||
"failure_reason": self.failure_reason,
|
||||
}
|
||||
if include_task:
|
||||
payload["plan"] = self.task_plan.to_dict(redact=redact_payload)
|
||||
if self.idempotency_key:
|
||||
payload["idempotency_key"] = self.idempotency_key
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerRegistration:
|
||||
worker_id: str
|
||||
worker_type: str
|
||||
identity: str
|
||||
capabilities: list[str]
|
||||
trust_zone: str
|
||||
max_concurrency: int
|
||||
heartbeat_interval_seconds: int
|
||||
supported_protocols: list[str]
|
||||
registered_at: float
|
||||
last_heartbeat: float
|
||||
status: str = "active"
|
||||
disabled: bool = False
|
||||
owner_identity: str = ""
|
||||
registration_revision: int = 1
|
||||
registration_policy: str = "self"
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
disabled_reason: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "WorkerRegistration":
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("worker registration payload must be an object")
|
||||
now = _now_ts()
|
||||
worker_id = validate_identifier(data.get("worker_id"), "worker_id")
|
||||
worker_type = validate_type(data.get("worker_type"), "worker_type")
|
||||
identity = str(data.get("identity") or "").strip()
|
||||
if not identity or any(ord(ch) < 32 for ch in identity):
|
||||
raise ValueError("invalid identity")
|
||||
max_concurrency = _to_positive_int(data.get("max_concurrency"), default=1)
|
||||
heartbeat_interval = _to_positive_int(
|
||||
data.get("heartbeat_interval_seconds"), default=15
|
||||
)
|
||||
if not max_concurrency:
|
||||
raise ValueError("max_concurrency must be positive")
|
||||
if not heartbeat_interval:
|
||||
raise ValueError("heartbeat_interval_seconds must be positive")
|
||||
return cls(
|
||||
worker_id=worker_id,
|
||||
worker_type=worker_type,
|
||||
identity=identity,
|
||||
capabilities=_normalize_list(data.get("capabilities", [])),
|
||||
trust_zone=str(data.get("trust_zone") or "default"),
|
||||
max_concurrency=max_concurrency,
|
||||
heartbeat_interval_seconds=heartbeat_interval,
|
||||
supported_protocols=_normalize_list(
|
||||
data.get("supported_protocols", ["aios-task-v1"])
|
||||
),
|
||||
registered_at=float(data.get("registered_at") or now),
|
||||
last_heartbeat=float(data.get("last_heartbeat") or now),
|
||||
status=str(data.get("status") or "active"),
|
||||
disabled=bool(data.get("disabled", False)),
|
||||
owner_identity=str(data.get("owner_identity") or identity),
|
||||
registration_revision=int(data.get("registration_revision") or 1),
|
||||
registration_policy=str(data.get("registration_policy") or "self"),
|
||||
created_at=float(data.get("created_at") or data.get("registered_at") or now),
|
||||
updated_at=float(data.get("updated_at") or now),
|
||||
disabled_reason=data.get("disabled_reason"),
|
||||
)
|
||||
|
||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
||||
payload = {
|
||||
"worker_id": self.worker_id,
|
||||
"worker_type": self.worker_type,
|
||||
"identity": self.identity,
|
||||
"capabilities": list(self.capabilities),
|
||||
"trust_zone": self.trust_zone,
|
||||
"max_concurrency": self.max_concurrency,
|
||||
"heartbeat_interval_seconds": self.heartbeat_interval_seconds,
|
||||
"supported_protocols": list(self.supported_protocols),
|
||||
"registered_at": _fmt_ts(self.registered_at),
|
||||
"last_heartbeat": _fmt_ts(self.last_heartbeat),
|
||||
"status": self.status,
|
||||
"disabled": self.disabled,
|
||||
"owner_identity": self.owner_identity,
|
||||
"registration_revision": self.registration_revision,
|
||||
"registration_policy": self.registration_policy,
|
||||
"created_at": _fmt_ts(self.created_at or self.registered_at),
|
||||
"updated_at": _fmt_ts(self.updated_at or self.last_heartbeat),
|
||||
"disabled_reason": self.disabled_reason,
|
||||
}
|
||||
if redact_payload:
|
||||
payload = redact_value(payload)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceEnvelope:
|
||||
schema_version: int
|
||||
evidence_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
kind: str
|
||||
worker_identity: str
|
||||
payload: dict[str, Any]
|
||||
created_at: float
|
||||
previous_evidence_hash: str
|
||||
evidence_digest: str
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
task_id: str,
|
||||
claim_id: str,
|
||||
kind: str,
|
||||
worker_identity: str,
|
||||
payload: dict[str, Any],
|
||||
previous_evidence_hash: str,
|
||||
signature: str | None = None,
|
||||
attestation_ref: str | None = None,
|
||||
evidence_id: str | None = None,
|
||||
created_at: float | None = None,
|
||||
) -> "EvidenceEnvelope":
|
||||
envelope = cls(
|
||||
schema_version=1,
|
||||
evidence_id=evidence_id or f"evd-{uuid.uuid4()}",
|
||||
task_id=validate_identifier(task_id, "task_id"),
|
||||
claim_id=validate_identifier(claim_id, "claim_id"),
|
||||
kind=validate_type(kind, "evidence kind"),
|
||||
worker_identity=str(worker_identity),
|
||||
payload=redact_value(payload),
|
||||
created_at=float(created_at or _now_ts()),
|
||||
previous_evidence_hash=str(previous_evidence_hash or ""),
|
||||
evidence_digest="",
|
||||
signature=signature,
|
||||
attestation_ref=attestation_ref,
|
||||
)
|
||||
return dataclass_replace(
|
||||
envelope, evidence_digest=compute_sha256_hex(envelope.digest_payload())
|
||||
)
|
||||
|
||||
def digest_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"evidence_id": self.evidence_id,
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"kind": self.kind,
|
||||
"worker_identity": self.worker_identity,
|
||||
"payload": self.payload,
|
||||
"created_at": self.created_at,
|
||||
"previous_evidence_hash": self.previous_evidence_hash,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
}
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
expected_task_id: str,
|
||||
expected_claim_id: str,
|
||||
expected_previous_hash: str,
|
||||
attestation_verifier: Any = None,
|
||||
) -> bool:
|
||||
if (
|
||||
self.schema_version != 1
|
||||
or self.task_id != expected_task_id
|
||||
or self.claim_id != expected_claim_id
|
||||
or self.previous_evidence_hash != expected_previous_hash
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
**self.digest_payload(),
|
||||
"evidence_digest": self.evidence_digest,
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
|
||||
class ProbeOutcome(StrEnum):
|
||||
NOT_APPLIED = "NOT_APPLIED"
|
||||
APPLIED_VERIFIED = "APPLIED_VERIFIED"
|
||||
INDETERMINATE = "INDETERMINATE"
|
||||
CONFLICT = "CONFLICT"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeEvidence:
|
||||
schema_version: int
|
||||
probe_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_id: str
|
||||
worker_identity: str
|
||||
operation_digest: str
|
||||
outcome: ProbeOutcome
|
||||
observed_at: float
|
||||
expires_at: float
|
||||
probe_executor_identity: str
|
||||
evidence_digest: str
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, **data: Any) -> "ProbeEvidence":
|
||||
outcome = data["outcome"]
|
||||
if not isinstance(outcome, ProbeOutcome):
|
||||
outcome = ProbeOutcome(str(outcome))
|
||||
probe = cls(
|
||||
schema_version=1,
|
||||
probe_id=data.get("probe_id") or f"probe-{uuid.uuid4()}",
|
||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||
claim_id=validate_identifier(data["claim_id"], "claim_id"),
|
||||
worker_id=validate_identifier(data["worker_id"], "worker_id"),
|
||||
worker_identity=str(data["worker_identity"]),
|
||||
operation_digest=str(data["operation_digest"]),
|
||||
outcome=outcome,
|
||||
observed_at=float(data["observed_at"]),
|
||||
expires_at=float(data["expires_at"]),
|
||||
probe_executor_identity=str(data["probe_executor_identity"]),
|
||||
evidence_digest="",
|
||||
signature=data.get("signature"),
|
||||
attestation_ref=data.get("attestation_ref"),
|
||||
)
|
||||
return dataclass_replace(
|
||||
probe, evidence_digest=compute_sha256_hex(probe.digest_payload())
|
||||
)
|
||||
|
||||
def digest_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"probe_id": self.probe_id,
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"worker_id": self.worker_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"operation_digest": self.operation_digest,
|
||||
"outcome": self.outcome.value,
|
||||
"observed_at": self.observed_at,
|
||||
"expires_at": self.expires_at,
|
||||
"probe_executor_identity": self.probe_executor_identity,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
}
|
||||
|
||||
def verify(self, *, now: float, attestation_verifier: Any) -> bool:
|
||||
if (
|
||||
self.schema_version != 1
|
||||
or self.expires_at <= now
|
||||
or self.observed_at > now
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
or not (self.signature or self.attestation_ref)
|
||||
):
|
||||
return False
|
||||
return bool(attestation_verifier and attestation_verifier(self))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
**self.digest_payload(),
|
||||
"evidence_digest": self.evidence_digest,
|
||||
"signature": self.signature,
|
||||
}
|
||||
|
||||
|
||||
def dataclass_replace(value: Any, **changes: Any) -> Any:
|
||||
from dataclasses import replace
|
||||
|
||||
return replace(value, **changes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerClaim:
|
||||
claim_id: str
|
||||
task_id: str
|
||||
worker_id: str
|
||||
worker_identity: str
|
||||
claimed_at: float
|
||||
lease_until: float
|
||||
attempt: int
|
||||
task_revision: int
|
||||
operation_digest: str
|
||||
status: str = "ACTIVE"
|
||||
|
||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
||||
payload = {
|
||||
"claim_id": self.claim_id,
|
||||
"task_id": self.task_id,
|
||||
"worker_id": self.worker_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"claimed_at": _fmt_ts(self.claimed_at),
|
||||
"lease_until": _fmt_ts(self.lease_until),
|
||||
"attempt": self.attempt,
|
||||
"task_revision": self.task_revision,
|
||||
"operation_digest": self.operation_digest,
|
||||
"status": self.status,
|
||||
}
|
||||
if redact_payload:
|
||||
payload = redact_value(payload)
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultEnvelope:
|
||||
task_id: str
|
||||
claim_id: str
|
||||
worker_identity: str
|
||||
status: str
|
||||
outputs: list[dict[str, Any]]
|
||||
evidence: list[dict[str, Any]]
|
||||
started_at: str
|
||||
completed_at: str
|
||||
result_digest: str | None = None
|
||||
signature: str | None = None
|
||||
attestation_ref: str | None = None
|
||||
operation_digest: str | None = None
|
||||
trust_status: str = "untrusted_shadow_result"
|
||||
|
||||
def digest_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"status": self.status,
|
||||
"outputs": self.outputs,
|
||||
"evidence": self.evidence,
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
"operation_digest": self.operation_digest,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
}
|
||||
|
||||
def computed_digest(self) -> str:
|
||||
return compute_sha256_hex(self.digest_payload())
|
||||
|
||||
def to_dict(self, redact_payload: bool = True) -> dict[str, Any]:
|
||||
payload = {
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"worker_identity": self.worker_identity,
|
||||
"status": self.status,
|
||||
"outputs": self.outputs,
|
||||
"evidence": self.evidence,
|
||||
"started_at": self.started_at,
|
||||
"completed_at": self.completed_at,
|
||||
"result_digest": self.result_digest,
|
||||
"signature": self.signature,
|
||||
"attestation_ref": self.attestation_ref,
|
||||
"operation_digest": self.operation_digest,
|
||||
"trust_status": self.trust_status,
|
||||
}
|
||||
if redact_payload:
|
||||
payload = redact_value(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _normalize_list(value: Any) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("expected list")
|
||||
if len(value) > 128 or any(
|
||||
not isinstance(v, str)
|
||||
or not v
|
||||
or len(v) > 256
|
||||
or any(ord(ch) < 32 for ch in v)
|
||||
for v in value
|
||||
):
|
||||
raise ValueError("invalid list item")
|
||||
return list(value)
|
||||
|
||||
|
||||
def _to_positive_int(value: Any, default: int | None = None) -> int | None:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if number >= 0 else default
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _first_non_none(primary: Any, fallback: Any) -> Any:
|
||||
if primary is not None:
|
||||
return primary
|
||||
return fallback
|
||||
|
||||
|
||||
def iso8601_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _fmt_ts(value: float | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Policy and verification logic (fail-closed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .errors import ForbiddenError, LockedError
|
||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
||||
|
||||
|
||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||
READONLY_WORKER_ACTIONS = frozenset(
|
||||
{"claim", "result", "fail", "progress", "heartbeat"}
|
||||
)
|
||||
ALL_ACTION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"task.creator",
|
||||
"worker.self_register",
|
||||
"worker.admin",
|
||||
"task.claim",
|
||||
"task.cancel",
|
||||
"task.delegate",
|
||||
"task.read",
|
||||
"task.audit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadonlyWorkerProfile:
|
||||
"""A narrowly-scoped identity allowed to process safe Shadow tasks only."""
|
||||
|
||||
worker_id: str
|
||||
identity: str
|
||||
capabilities: frozenset[str]
|
||||
task_types: frozenset[str] = frozenset({"code.review", "test.execute"})
|
||||
|
||||
def permits_task(self, task: TaskRecord) -> bool:
|
||||
plan = task.task_plan
|
||||
if plan.task_type not in self.task_types or plan.risk != "low":
|
||||
return False
|
||||
if plan.data_classification not in {"public", "internal"}:
|
||||
return False
|
||||
if plan.metadata.get("production_side_effects") is not False:
|
||||
return False
|
||||
return set(plan.required_capabilities).issubset(self.capabilities)
|
||||
|
||||
|
||||
class IdentityVerifier:
|
||||
def __init__(self, trusted_identities: set[str] | None = None):
|
||||
self.trusted_identities = trusted_identities
|
||||
|
||||
def verify(self, identity: str) -> bool:
|
||||
if self.trusted_identities is None:
|
||||
return True
|
||||
return identity in self.trusted_identities
|
||||
|
||||
|
||||
class EvidenceVerifier:
|
||||
def __init__(self, mandatory: list[str] | None = None):
|
||||
self.mandatory = set(mandatory or [])
|
||||
|
||||
def verify(self, task: TaskRecord, evidences: list[dict]) -> PolicyDecision:
|
||||
required = set(task.task_plan.evidence_requirements or [])
|
||||
if not required and not self.mandatory:
|
||||
return PolicyDecision(True)
|
||||
observed = {e.get("kind") for e in evidences}
|
||||
missing = required.union(self.mandatory) - observed
|
||||
if missing:
|
||||
return PolicyDecision(False, f"missing evidence: {', '.join(sorted(missing))}")
|
||||
return PolicyDecision(True)
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
"""
|
||||
Fail-closed policy engine with minimal but explicit decisions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
identity_verifier: IdentityVerifier | None = None,
|
||||
evidence_verifier: EvidenceVerifier | None = None,
|
||||
execution_enabled: bool = False,
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
||||
) -> None:
|
||||
if max_risk not in RISK_RANK:
|
||||
raise ValueError("invalid max_risk")
|
||||
self.identity_verifier = identity_verifier or IdentityVerifier()
|
||||
self.evidence_verifier = evidence_verifier or EvidenceVerifier()
|
||||
self.execution_enabled = execution_enabled
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
}
|
||||
self.admin_identities = set(admin_identities or ())
|
||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
||||
self.readonly_worker_profiles = {
|
||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
||||
}
|
||||
|
||||
def _readonly_worker_allows(
|
||||
self, action: str, actor: str, task: TaskRecord | None
|
||||
) -> bool:
|
||||
profile = self.readonly_worker_profiles.get(actor)
|
||||
return bool(
|
||||
self.readonly_worker_execution_enabled
|
||||
and action in READONLY_WORKER_ACTIONS
|
||||
and profile is not None
|
||||
and (task is None or profile.permits_task(task))
|
||||
)
|
||||
|
||||
def assert_readonly_worker_binding(self, actor: str, worker_id: str) -> None:
|
||||
profile = self.readonly_worker_profiles.get(actor)
|
||||
if profile is not None and profile.worker_id != worker_id:
|
||||
raise ForbiddenError("worker identity binding mismatch")
|
||||
|
||||
def assert_readonly_worker_registration(self, actor: str, payload: dict) -> None:
|
||||
profile = self.readonly_worker_profiles.get(actor)
|
||||
if profile is None:
|
||||
return
|
||||
if (
|
||||
str(payload.get("worker_id") or "") != profile.worker_id
|
||||
or str(payload.get("identity") or "") != profile.identity
|
||||
or set(payload.get("capabilities") or []) != set(profile.capabilities)
|
||||
or str(payload.get("trust_zone") or "") != "isolated-review"
|
||||
or int(payload.get("max_concurrency") or 0) != 1
|
||||
or int(payload.get("heartbeat_interval_seconds") or 0) != 15
|
||||
):
|
||||
raise ForbiddenError("readonly worker registration mismatch")
|
||||
|
||||
def verify_result(
|
||||
self, result: ResultEnvelope, task: TaskRecord | None = None
|
||||
) -> PolicyDecision:
|
||||
computed = result.computed_digest()
|
||||
if result.result_digest and result.result_digest != computed:
|
||||
return PolicyDecision(False, "result digest mismatch")
|
||||
sensitive = bool(
|
||||
task
|
||||
and (
|
||||
task.task_plan.data_classification
|
||||
in {"confidential", "credential-secret"}
|
||||
or any(
|
||||
capability.startswith(
|
||||
("code.write", "deploy", "secret", "production")
|
||||
)
|
||||
for capability in task.task_plan.required_capabilities
|
||||
)
|
||||
)
|
||||
)
|
||||
if sensitive and not (result.signature or result.attestation_ref):
|
||||
return PolicyDecision(False, "trusted result proof required")
|
||||
if result.signature is not None or result.attestation_ref is not None:
|
||||
if self.result_signature_verifier is None:
|
||||
return PolicyDecision(False, "result signature verifier unavailable")
|
||||
if not self.result_signature_verifier(result):
|
||||
return PolicyDecision(False, "result signature invalid")
|
||||
result.trust_status = "trusted_result"
|
||||
else:
|
||||
result.trust_status = "untrusted_shadow_result"
|
||||
return PolicyDecision(True)
|
||||
|
||||
def authorize(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None) -> PolicyDecision:
|
||||
context = context or {}
|
||||
if not self.identity_verifier.verify(actor):
|
||||
return PolicyDecision(False, "identity untrusted")
|
||||
|
||||
action_capabilities = {
|
||||
"create": "task.creator",
|
||||
"read": "task.read",
|
||||
"audit": "task.audit",
|
||||
"claim": "task.claim",
|
||||
"result": "task.claim",
|
||||
"fail": "task.claim",
|
||||
"progress": "task.claim",
|
||||
"heartbeat": "task.claim",
|
||||
"cancel": "task.cancel",
|
||||
"delegate": "task.delegate",
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
self.identity_capabilities.get(
|
||||
actor, self.identity_capabilities.get("*", set())
|
||||
)
|
||||
)
|
||||
if required_capability not in granted:
|
||||
return PolicyDecision(False, f"action {action} not permitted")
|
||||
|
||||
if action in {"claim", "result", "fail", "cancel", "progress", "delegate", "heartbeat"} and not self.execution_enabled:
|
||||
if not self._readonly_worker_allows(action, actor, task):
|
||||
return PolicyDecision(False, "execution disabled")
|
||||
|
||||
if action == "delegate" and task and task.status not in {"COMPLETED", "FAILED_FINAL", "CANCELLED"}:
|
||||
risk = str(context.get("risk", task.task_plan.risk))
|
||||
if RISK_RANK.get(risk, 2) > RISK_RANK.get(self.max_risk, 1):
|
||||
return PolicyDecision(False, f"risk {risk} exceeds policy")
|
||||
|
||||
return PolicyDecision(True)
|
||||
|
||||
def assert_allowed(self, action: str, actor: str, task: TaskRecord | None = None, context: dict | None = None):
|
||||
decision = self.authorize(action, actor, task, context=context)
|
||||
if not decision.allowed:
|
||||
if decision.reason == "identity untrusted":
|
||||
raise ForbiddenError(decision.reason)
|
||||
if decision.reason == "execution disabled":
|
||||
raise LockedError("execution disabled")
|
||||
raise ForbiddenError(decision.reason)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Task scheduler primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from .errors import NotFoundError
|
||||
from .capabilities import has_capabilities
|
||||
from .models import WorkerClaim
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def next_queued_task(self, worker_id: str):
|
||||
worker = self.store.get_worker(worker_id)
|
||||
tasks = sorted(
|
||||
self.store.list_tasks(),
|
||||
key=lambda task: (
|
||||
-int(task.task_plan.metadata.get("priority", 0)),
|
||||
task.created_at,
|
||||
task.task_id,
|
||||
),
|
||||
)
|
||||
for task in tasks:
|
||||
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
||||
continue
|
||||
if task.task_plan.task_type == "worker.registry.audit":
|
||||
continue
|
||||
if task.status == "FAILED_RECOVERABLE" and not self.store.task_has_retryable_probe(
|
||||
task.task_id
|
||||
):
|
||||
continue
|
||||
if not has_capabilities(
|
||||
worker.capabilities, task.task_plan.required_capabilities
|
||||
):
|
||||
continue
|
||||
if any(
|
||||
self.store.get_task(dependency).status != "COMPLETED"
|
||||
for dependency in task.task_plan.dependencies
|
||||
):
|
||||
continue
|
||||
if (
|
||||
task.task_plan.data_classification == "credential-secret"
|
||||
and worker.worker_type != "secure_file_transformer"
|
||||
and "secure_file_transformer" not in worker.capabilities
|
||||
):
|
||||
continue
|
||||
return task
|
||||
return None
|
||||
|
||||
def schedule_for_worker(self, worker_id: str, lease_seconds: int = 15) -> WorkerClaim:
|
||||
worker = self.store.get_worker(worker_id)
|
||||
if worker.disabled:
|
||||
from .errors import LockedError
|
||||
|
||||
raise LockedError("worker disabled")
|
||||
if time.time() > worker.last_heartbeat + (
|
||||
worker.heartbeat_interval_seconds * 3
|
||||
):
|
||||
from .errors import LockedError
|
||||
|
||||
raise LockedError("worker offline")
|
||||
task = self.next_queued_task(worker_id)
|
||||
if task is None:
|
||||
raise NotFoundError("no queued tasks available")
|
||||
claim = self.store.claim_task(
|
||||
task.task_id,
|
||||
worker_id,
|
||||
worker.identity,
|
||||
lease_seconds=lease_seconds,
|
||||
expected_revision=task.revision,
|
||||
operation_digest=task.task_plan.operation_digest(),
|
||||
)
|
||||
return claim
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
"""Worker registry abstraction and read helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .capabilities import has_capabilities
|
||||
from .errors import NotFoundError
|
||||
from .models import WorkerRegistration
|
||||
from .store import TaskPlaneStore
|
||||
|
||||
|
||||
class WorkerRegistry:
|
||||
def __init__(self, store: TaskPlaneStore):
|
||||
self.store = store
|
||||
|
||||
def register(
|
||||
self,
|
||||
payload: dict,
|
||||
*,
|
||||
actor_identity: str,
|
||||
is_admin: bool,
|
||||
expected_revision: int | None,
|
||||
) -> WorkerRegistration:
|
||||
reg = WorkerRegistration.from_dict(payload)
|
||||
return self.store.register_worker(
|
||||
reg,
|
||||
actor_identity=actor_identity,
|
||||
is_admin=is_admin,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
worker_id: str,
|
||||
heartbeat_interval_seconds: int | None = None,
|
||||
*,
|
||||
actor_identity: str,
|
||||
expected_revision: int | None,
|
||||
) -> WorkerRegistration:
|
||||
return self.store.heartbeat_worker(
|
||||
worker_id,
|
||||
heartbeat_interval_seconds=heartbeat_interval_seconds,
|
||||
actor_identity=actor_identity,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def list(self):
|
||||
return self.store.list_workers()
|
||||
|
||||
def get(self, worker_id: str) -> WorkerRegistration:
|
||||
try:
|
||||
return self.store.get_worker(worker_id)
|
||||
except NotFoundError:
|
||||
raise
|
||||
|
||||
def can_execute(self, worker_id: str, required_capabilities: list[str]) -> bool:
|
||||
worker = self.store.get_worker(worker_id)
|
||||
return has_capabilities(worker.capabilities, required_capabilities)
|
||||
|
||||
def is_online(self, worker_id: str, *, now: float | None = None) -> bool:
|
||||
worker = self.store.get_worker(worker_id)
|
||||
deadline = worker.last_heartbeat + (worker.heartbeat_interval_seconds * 3)
|
||||
return not worker.disabled and (time.time() if now is None else now) <= deadline
|
||||
|
||||
def stale_workers(self, *, now: float | None = None) -> list[WorkerRegistration]:
|
||||
current = time.time() if now is None else now
|
||||
return [worker for worker in self.list() if not self.is_online(worker.worker_id, now=current)]
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"schema_version": 4,
|
||||
"automatic": [
|
||||
"build",
|
||||
"test",
|
||||
"artifact",
|
||||
"canary",
|
||||
"evidence"
|
||||
],
|
||||
"owner": [
|
||||
"signed_release_plan"
|
||||
],
|
||||
"forbidden": [
|
||||
"force_push",
|
||||
"tag_rewrite",
|
||||
"direct_main_push",
|
||||
"baseline_mutation",
|
||||
"registry_mutation"
|
||||
],
|
||||
"runtime_deployment": {
|
||||
"approval_action": "signed_release_plan",
|
||||
"required_capability": "runtime_switch",
|
||||
"tag_operations_allowed": false,
|
||||
"baseline_mutation_allowed": false,
|
||||
"registry_mutation_allowed": false,
|
||||
"writer_default": false,
|
||||
"rollback_required": true
|
||||
},
|
||||
"tag_release": {
|
||||
"approval_action": "signed_release_plan",
|
||||
"required_capability": "tag_publish",
|
||||
"separate_controller": true,
|
||||
"duplicate_conflict": "fail_closed",
|
||||
"rewrite_allowed": false,
|
||||
"force_push_allowed": false
|
||||
},
|
||||
"baseline_registry_finalize": {
|
||||
"approval_action": "signed_release_plan",
|
||||
"required_capability": "baseline_registry_finalize",
|
||||
"separate_controller": true,
|
||||
"remote_tag_verification_required": true,
|
||||
"gitea_api_verification_required": true,
|
||||
"registry_baseline_consistency_required": true
|
||||
},
|
||||
"approval": {
|
||||
"algorithm": "Ed25519",
|
||||
"trust_domain": "aios.production",
|
||||
"maximum_lifetime_seconds": 3600,
|
||||
"release_plan_sha256_required": true,
|
||||
"not_before_required": true,
|
||||
"replay_protection": true,
|
||||
"revocation_fail_closed": true,
|
||||
"key_rotation_supported": true,
|
||||
"capabilities": [
|
||||
"runtime_switch",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize"
|
||||
]
|
||||
},
|
||||
"execution": {
|
||||
"environment_variable": "AIOS_SILENT_RELEASE_EXECUTION_ENABLED",
|
||||
"default_enabled": false,
|
||||
"shadow_side_effects_allowed": false,
|
||||
"durable_saga_required": true,
|
||||
"pre_switch_revalidation_required": true,
|
||||
"trusted_candidate_builder_required": true,
|
||||
"actual_plan_regeneration_required": true,
|
||||
"startup_fail_closed_on_missing_component": true,
|
||||
"recovery_worker_shadow_default": true,
|
||||
"production_approval_backend_required": true,
|
||||
"production_builder_signer": "kms-hsm-secret-manager-only",
|
||||
"production_execution_without_backend": "unavailable"
|
||||
},
|
||||
"durability": {
|
||||
"journal": "sha256-hash-chain",
|
||||
"probe_journal": "sha256-hash-chain",
|
||||
"exclusive_capability_claims": true,
|
||||
"side_effect_probe_before_lease_takeover": true,
|
||||
"side_effect_probe_before_failed_recoverable_retry": true,
|
||||
"probe_results": [
|
||||
"NOT_APPLIED",
|
||||
"APPLIED_VERIFIED",
|
||||
"INDETERMINATE",
|
||||
"CONFLICT"
|
||||
],
|
||||
"probe_operation_digest_required": true,
|
||||
"probe_freshness_required": true,
|
||||
"tamper_result": "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
},
|
||||
"tag_pending": {
|
||||
"required_observations": [
|
||||
"remote_git_ref",
|
||||
"annotated_tag_object",
|
||||
"peeled_target",
|
||||
"gitea_api",
|
||||
"gitea_listing",
|
||||
"repository_identity"
|
||||
],
|
||||
"indeterminate_result": "RECONCILIATION_REQUIRED",
|
||||
"conflict_result": "MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
"automatic_reconciliation": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"checks": {"72h_acceptance_gate": true, "approle_implemented": true, "approle_secret_not_persisted_in_hcl": true, "approle_secret_survives_isolated_restore": true, "audit_implemented": true, "autonomy_tls_readers_use_ca_only_bind": true, "backup_implemented": true, "backup_restore_share_without_openbao_private_key_group": true, "branch_protection_gate_not_bypassable": true, "charter_store_sign_verify": true, "continuous_optimization_implemented": true, "controller_uses_charter_only_bind": true, "declarative_self_initialization": true, "embedded_shell_syntax": true, "exact_checksum_duplicate_rejected": true, "exact_checksum_good_fixture": true, "exact_checksum_missing_rejected": true, "fault_injection_does_not_require_service_it_kills": true, "fault_injection_failure_phase_evidence": true, "fault_injection_implemented": true, "fault_injection_missing_seal_reaches_static_seal_validation": true, "fault_injection_systemd_sandbox": true, "gitea_1_22_6_rejected": true, "gitea_1_23_0_accepted": true, "gitea_1_27_nonempty_tag_allowlist": true, "gitea_probe_traversal_and_cleanup": true, "gitea_tag_api_version_gate": true, "isolated_restore_implemented": true, "isolated_restore_uses_private_ephemeral_audit_sink": true, "kv_v2_implemented": true, "machine_credential_persisted": true, "masked_systemd_units_do_not_change_dev_null": true, "no_git_force_push": true, "no_git_tag_mutation": true, "openbao_2_6_heredoc_compatible": true, "openbao_2_6_runtime_verifier": true, "openbao_config_memfd_injection": true, "openbao_ready_gate_not_bypassable": true, "outer_bash_syntax": true, "policy_implemented": true, "ready_not_predeclared": true, "restored_approle_login_has_bounded_readiness_retry": true, "root_token_not_persisted": true, "runtime_state_directory_remains_writable": true, "self_contained_no_sibling_preflight": true, "self_init_marker_bound_to_raft_lifecycle": true, "self_init_secret_removal_implemented": true, "systemd_credentials_mandatory": true, "systemd_runtime_directories": true, "tag_protection_gate_implemented": true, "transit_charter_key_implemented": true, "transit_runtime_key_implemented": true}, "passed": 52, "runtime_claims": false, "schema": "AIOS_OPENBAO_BOOTSTRAP_STATIC_SELF_TEST_V3", "scope": "static_and_fixture_only", "script_sha256": "f687e17b1806e4f4265013b85e2cc3b4472f508720856fc8374607e7d20dbcc6", "total": 52}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
f687e17b1806e4f4265013b85e2cc3b4472f508720856fc8374607e7d20dbcc6 admin-bootstrap-openbao-autonomy-v1-v3.sh
|
||||
@@ -0,0 +1,4 @@
|
||||
6bd0d51fbdd1c90df2f6ea8c667edecfaf892aaf76744c41757979407af0d424 admin-bootstrap-openbao-autonomy-v1-v3.self-test.json
|
||||
f687e17b1806e4f4265013b85e2cc3b4472f508720856fc8374607e7d20dbcc6 admin-bootstrap-openbao-autonomy-v1-v3.sh
|
||||
b7aa70280a417cebaa0363380024dc378714267de5d2eeb681b16a4f610a6ce5 admin-bootstrap-openbao-autonomy-v1-v3.sha256
|
||||
c355df01c83c2801ae3a0df43806c562ee70f97c9aa558e3f67dd097efa5ac99 test-admin-bootstrap-openbao-autonomy-v1-v3.sh
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
script="${1:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/admin-bootstrap-openbao-autonomy-v1-v3.sh}"
|
||||
report="${2:-$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/admin-bootstrap-openbao-autonomy-v1-v3.self-test.json}"
|
||||
|
||||
[[ -f "$script" ]] || {
|
||||
printf 'missing script: %s\n' "$script" >&2
|
||||
exit 66
|
||||
}
|
||||
|
||||
bash -n "$script"
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
bash "$script" --self-test >"$tmp"
|
||||
|
||||
python3 - "$script" "$tmp" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
script, report = map(pathlib.Path, sys.argv[1:])
|
||||
data = json.loads(report.read_text())
|
||||
actual_hash = hashlib.sha256(script.read_bytes()).hexdigest()
|
||||
|
||||
assert data["schema"] == "AIOS_OPENBAO_BOOTSTRAP_STATIC_SELF_TEST_V3"
|
||||
assert data["scope"] == "static_and_fixture_only"
|
||||
assert data["runtime_claims"] is False
|
||||
assert data["script_sha256"] == actual_hash
|
||||
assert data["passed"] == data["total"]
|
||||
assert all(data["checks"].values())
|
||||
PY
|
||||
|
||||
install -m 0600 "$tmp" "$report"
|
||||
printf 'PASS bash-n + embedded static/fixture tests\n'
|
||||
printf 'report=%s\n' "$report"
|
||||
sha256sum "$script"
|
||||
@@ -11,6 +11,8 @@ Environment=AIOS_GITOPS_WORKSPACE=/data/aios-gitops/workspace
|
||||
Environment=AIOS_GITOPS_DB=/data/aios-gitops/runtime/gitops_model.sqlite3
|
||||
Environment=AIOS_GITOPS_TOKEN_FILE=/data/aios-gitops/runtime/secrets/gitops.tokens
|
||||
Environment=AIOS_GITOPS_BARE_REPO=/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git
|
||||
Environment=AIOS_GITOPS_AUTHORITATIVE_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18890
|
||||
ExecStart=/usr/bin/python3 /data/aios-gitops/workspace/active-runtime/app/aios_chatgpt_actions_api.py
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[Unit]
|
||||
Description=AIOS Silent Release Shadow Controller
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
UMask=0077
|
||||
Environment=AIOS_GITOPS_WRITES_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 /usr/libexec/aios-silent-release-shadow/current/active-runtime/app/silent_release_execution.py --state-root /var/lib/aios-silent-release-shadow --repository /data/aios-gitops/repo.git --host 127.0.0.1 --port 18891 --poll-seconds 30
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/aios-silent-release-shadow
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git /usr/libexec/aios-silent-release-shadow
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,268 @@
|
||||
# AIOS Silent Release Controller Design Report
|
||||
|
||||
状态:隔离分支实现;生产 mutation 禁止
|
||||
目标:自动准备 → 自动验证 → 单次 Owner 签名授权 → 分控制器自动执行与验收
|
||||
|
||||
## 1. 当前痛点
|
||||
|
||||
现有流程需要多轮人工传递 commit、tree、artifact、manifest、测试、canary、drop-in
|
||||
和回滚证据。证据在不同消息里重复抄写,容易出现:
|
||||
|
||||
- 构建后 artifact、manifest 或 policy 已变化,但旧批准仍被使用;
|
||||
- Git、Gitea、systemd 与回滚结果由人工拼接,缺少同一一致性快照;
|
||||
- runtime switch、tag、Baseline、Registry 的授权边界不清;
|
||||
- 重启后只剩工作树内临时 JSON,无法可靠恢复;
|
||||
- approval replay、并发 writer、key revoke 和 secret inheritance 缺少统一防线。
|
||||
|
||||
## 2. 新架构
|
||||
|
||||
```text
|
||||
Build / Test / Canary
|
||||
|
|
||||
v
|
||||
ReleaseCandidate + DeploymentPlan
|
||||
|
|
||||
v
|
||||
ReleaseAcceptanceEngine
|
||||
|
|
||||
v
|
||||
READY_FOR_APPROVAL
|
||||
|
|
||||
v
|
||||
SignedReleasePlanApproval (Ed25519)
|
||||
|
|
||||
+--> runtime_switch capability
|
||||
| DeploymentController
|
||||
|
|
||||
+--> tag_publish capability
|
||||
| TagController
|
||||
|
|
||||
+--> baseline_registry_finalize capability
|
||||
BaselineRegistryFinalizeController
|
||||
|
||||
Durable ReleaseSagaStore + CapabilityLedger + Event Journal
|
||||
贯穿三个控制器;控制器互相不拥有对方的 mutation API。
|
||||
```
|
||||
|
||||
一次 Owner 批准是同一个不可变 release plan 的签名,不是一个万能执行入口。三个 capability
|
||||
分别消费、分别留痕、分别幂等恢复。`force_push`、`tag_rewrite`、`direct_main_push`
|
||||
永远不是可签发 capability。
|
||||
|
||||
## 3. 状态机
|
||||
|
||||
```text
|
||||
PREPARING
|
||||
-> VERIFYING
|
||||
-> READY_FOR_APPROVAL
|
||||
-> APPROVED
|
||||
-> PRE_SWITCH_REVALIDATING
|
||||
-> SWITCH_PENDING
|
||||
-> SWITCHED
|
||||
-> RUNTIME_VERIFYING
|
||||
-> TAG_PENDING
|
||||
-> TAG_VERIFIED
|
||||
-> BASELINE_REGISTRY_PENDING
|
||||
-> COMPLETED
|
||||
|
||||
任一可恢复故障 -> RECONCILIATION_REQUIRED
|
||||
对象冲突 -> MANUAL_OWNER_REVIEW_REQUIRED
|
||||
切换后失败 -> ROLLBACK_PENDING -> ROLLED_BACK
|
||||
```
|
||||
|
||||
状态快照使用 revision compare-and-set;事件同时写入 append-only journal。writer 崩溃后根据
|
||||
状态确定唯一恢复动作。并发控制器中只有一个 CAS winner。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### ReleaseCandidate
|
||||
|
||||
```text
|
||||
candidate_id
|
||||
source_commit
|
||||
tree_oid
|
||||
artifact_sha256
|
||||
manifest_sha256
|
||||
tests
|
||||
canary
|
||||
rollback_ready
|
||||
git_consistency
|
||||
gitea_consistency
|
||||
approval_state
|
||||
```
|
||||
|
||||
### DeploymentPlan
|
||||
|
||||
```text
|
||||
service
|
||||
release_path / release_root
|
||||
dropin_path / dropin_sha256
|
||||
rollback_path / rollback_sha256
|
||||
source_commit
|
||||
artifact_sha256
|
||||
manifest_sha256
|
||||
policy_sha256
|
||||
health_contract
|
||||
sha256
|
||||
```
|
||||
|
||||
### SignedReleasePlanApproval
|
||||
|
||||
```text
|
||||
release_plan_id
|
||||
candidate_id
|
||||
source_commit
|
||||
tree_oid
|
||||
artifact_sha256
|
||||
manifest_sha256
|
||||
deployment_plan_sha256
|
||||
policy_sha256
|
||||
approval_plan_id
|
||||
evidence_closure_oid
|
||||
owner_approval_record_oid
|
||||
capabilities
|
||||
expires_at
|
||||
nonce
|
||||
key_id
|
||||
signature
|
||||
fingerprint
|
||||
```
|
||||
|
||||
签名内容使用 canonical JSON,`signature` 不参与被签名字段。验证包括 Ed25519 签名、过期时间、
|
||||
公钥状态、approval revoke、candidate/plan/policy 逐字段绑定和 capability allowlist。
|
||||
|
||||
## 5. API
|
||||
|
||||
```text
|
||||
POST /createReleaseCandidate
|
||||
GET /getReleaseCandidate?candidate_id=...
|
||||
GET /getApprovalEnvelope?candidate_id=...
|
||||
POST /submitOwnerApproval
|
||||
POST /startDeployment
|
||||
GET /getReleaseSaga?candidate_id=...
|
||||
```
|
||||
|
||||
写接口要求 Bearer scope。`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` 是默认值;此时可以
|
||||
持久化 candidate、生成 approval envelope、保存经验证的 Owner approval 和读取 Saga,
|
||||
但 `/startDeployment` 只返回 `SHADOW_MODE` 与空 `side_effects`。
|
||||
|
||||
## 6. PreSwitchValidator
|
||||
|
||||
切换前重新收集并比较:
|
||||
|
||||
- source commit 与 tree;
|
||||
- artifact、manifest、policy 和 deployment plan SHA-256;
|
||||
- rollback readiness;
|
||||
- local、authoritative bare 与 Gitea refs;
|
||||
- repository identity;
|
||||
- task writer/production writer 边界;
|
||||
- health contract。
|
||||
|
||||
任一变化都抛出:
|
||||
|
||||
```text
|
||||
APPROVAL_STALE_REAPPROVAL_REQUIRED
|
||||
```
|
||||
|
||||
旧批准不会被“最佳努力”继续使用。
|
||||
|
||||
## 7. systemd 防线
|
||||
|
||||
- service 与 drop-in 均为显式 allowlist;
|
||||
- release 与 drop-in resolved path 必须位于 release root 内;
|
||||
- 拒绝普通文件被替换为 symlink,拒绝 symlink escape;
|
||||
- deployment lock 覆盖 switch、restart 和首次 health check;
|
||||
- restart 前后记录 MainPID 与 ActiveEnterTimestampMonotonic;
|
||||
- artifact、drop-in、rollback 和 manifest 均按 plan hash 复验;
|
||||
- 子进程只接收固定 allowlist 环境,不继承 token、password、credential、askpass、
|
||||
auth socket、`LD_PRELOAD`、`PYTHONPATH` 或数据库连接串。
|
||||
|
||||
## 8. TagController 分离
|
||||
|
||||
TagController 必须独立完成 approval、execute、verify,并留下:
|
||||
|
||||
```text
|
||||
evidence_closure_oid
|
||||
approval_plan_id
|
||||
repository_identity
|
||||
remote_tag_oid
|
||||
remote_peeled_target_oid
|
||||
gitea_api_tag_oid
|
||||
gitea_listing_visible
|
||||
```
|
||||
|
||||
同名 tag 的 tag OID 或 peeled target 不同,进入
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED`;自动流程不 push、不删除、不重写、不 force push。
|
||||
|
||||
## 9. Baseline / Registry 门禁
|
||||
|
||||
只有以下全部一致时,`baseline_registry_finalize` capability 才能完成:
|
||||
|
||||
```text
|
||||
Registry = Baseline = remote tag = Gitea API = expected target
|
||||
```
|
||||
|
||||
UNKNOWN、API 不可达、repository identity 不符或 listing 不可见均 fail closed。
|
||||
|
||||
## 10. Policy as Code
|
||||
|
||||
```text
|
||||
automatic:
|
||||
build, test, artifact, canary, evidence
|
||||
|
||||
owner:
|
||||
signed_release_plan
|
||||
|
||||
capabilities:
|
||||
runtime_switch
|
||||
tag_publish
|
||||
baseline_registry_finalize
|
||||
|
||||
forbidden:
|
||||
force_push
|
||||
tag_rewrite
|
||||
direct_main_push
|
||||
baseline_mutation
|
||||
registry_mutation
|
||||
```
|
||||
|
||||
最后两个 forbidden 项指“脱离受治理 finalize controller 的直接 mutation”。
|
||||
|
||||
## 11. Security model
|
||||
|
||||
- Ed25519 公钥 ring 支持 rotation 和 revoke,私钥不进入 runtime;
|
||||
- approval plan 可撤销,过期、未知 key、错误签名均 fail closed;
|
||||
- nonce 与 capability operation 持久化,重复相同请求幂等,不同 fingerprint 重放拒绝;
|
||||
- Saga snapshot 原子写、journal append-only、CAS 防并发 writer;
|
||||
- credential 只通过受控 reference 使用,不进入 Git、API 响应或异常;
|
||||
- 子进程环境从空集合构建,不复制 `os.environ`;
|
||||
- Shadow 模式默认开启,execution adapter 缺失时不能降级执行;
|
||||
- runtime、tag、Baseline/Registry 分属三个能力最小化控制器。
|
||||
|
||||
## 12. Migration plan
|
||||
|
||||
1. Shadow:生成 candidate、plan、acceptance、approval package,仅比较现有人工结果。
|
||||
2. Signed approval:接入 Owner 公钥 ring、撤销源和审计 record;仍不接生产 executor。
|
||||
3. Canary execution:使用非生产 service/release root 验证锁、PID、health、崩溃恢复和回滚。
|
||||
4. Controlled production switch:另行 Owner 授权启用 execution flag,只开放 runtime capability。
|
||||
5. Tag/finalize:分别完成 Gitea 与 Registry/Baseline 生产验收后再启用对应 capability。
|
||||
|
||||
每个阶段均可退回上一个阶段;不开启下一能力不会影响已经启用的控制器。
|
||||
|
||||
## 13. Rollback plan
|
||||
|
||||
- switch 前保存旧 symlink target、PID、启动时间、artifact 与 plan fingerprint;
|
||||
- restart 或 health 任一失败进入 `ROLLBACK_PENDING`;
|
||||
- 原子恢复旧 target,只 restart 指定 allowlist service;
|
||||
- 重新验证旧 runtime commit、writer=false 与 repository identity;
|
||||
- 成功进入 `ROLLED_BACK`,失败进入 `MANUAL_OWNER_REVIEW_REQUIRED`;
|
||||
- rollback 不修改 Git main、production tag、Baseline 或 Registry;
|
||||
- 保留脱敏 journal、approval fingerprint 和失败证据。
|
||||
|
||||
## 14. 测试与外部依赖边界
|
||||
|
||||
自动测试覆盖签名伪造、字段篡改、过期/revoke/key rotation、nonce replay、CAS race、
|
||||
崩溃恢复、artifact/policy/plan mutation、tag conflict、secret leakage、runtime without
|
||||
`.git`、rollback 和 shadow no-side-effects。
|
||||
|
||||
本隔离实现不创建生产 KMS/key、不安装 systemd unit、不部署 Action runtime、不创建 tag,
|
||||
也不修改 Baseline/Registry。上述动作必须在单独生产批准中执行。
|
||||
@@ -0,0 +1,107 @@
|
||||
# AIOS Silent Release Execution Readiness
|
||||
|
||||
## Scope and stop point
|
||||
|
||||
This change makes the controller execution-ready while deploying only a
|
||||
localhost-bound Shadow control plane. Both production gates remain false:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_WRITES_ENABLED=false
|
||||
AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
```
|
||||
|
||||
Shadow completion is evidence preparation, not a production release. It cannot
|
||||
write a production tag, switch a business runtime, or mutate Baseline/Registry.
|
||||
|
||||
## Recovery state machine
|
||||
|
||||
Every expired or `FAILED_RECOVERABLE` capability requires fresh, operation-bound
|
||||
probe evidence before another adapter may run:
|
||||
|
||||
```text
|
||||
NOT_APPLIED -> one new exclusive claim may retry
|
||||
APPLIED_VERIFIED -> mark COMPLETED without adapter replay
|
||||
INDETERMINATE -> RECONCILIATION_REQUIRED; no automatic writes
|
||||
CONFLICT -> MANUAL_OWNER_REVIEW_REQUIRED; no automatic writes
|
||||
```
|
||||
|
||||
Probe evidence binds the immutable operation digest, is freshness checked, and
|
||||
is appended to a separate SHA-256 hash-chained journal. Completion still
|
||||
requires the active claim ID. File locks and leases ensure that concurrent
|
||||
recovery workers cannot execute the same adapter.
|
||||
|
||||
`TAG_PENDING` uses independent remote ref, annotated object, peeled target,
|
||||
Gitea API, Gitea listing, and repository identity evidence. Exact absence may
|
||||
roll back a switched runtime. Exact publication continues to finalization.
|
||||
Unavailable/divergent evidence stops at reconciliation; a different OID or
|
||||
target stops for Owner review. No branch can force, delete, or rewrite a tag.
|
||||
|
||||
## Trusted build and approval
|
||||
|
||||
Callers submit only an event ID and exact source commit. Test, canary, artifact,
|
||||
manifest, rollback, secret scan, Git and Gitea evidence are generated by the
|
||||
trusted builder. The builder emits an Ed25519 attestation binding:
|
||||
|
||||
- builder attestation/key IDs;
|
||||
- source commit and tree OID;
|
||||
- artifact, manifest and package-closure SHA-256;
|
||||
- test and canary evidence SHA-256;
|
||||
- issuance time and signature.
|
||||
|
||||
The controller contains only trusted public keys. Production signing is exposed
|
||||
through a KMS/HSM/secret-manager adapter; a local private-key adapter is marked
|
||||
test-only and is used only for ephemeral Shadow acceptance.
|
||||
|
||||
Owner approvals are immutable, time-bounded, revocable, trust-domain bound, and
|
||||
single-use per capability ledger. If the production approval backend reference
|
||||
is absent, production execution is unavailable while Shadow remains available.
|
||||
Caller-supplied `owner_id` is never a fallback.
|
||||
|
||||
## Persistent controller and API
|
||||
|
||||
`aios-silent-release-controller.service` runs independently from the business
|
||||
GitOps API. It uses a single leader lock, durable intents/candidates/Sagas,
|
||||
startup journal verification, idempotent main events, one notification record,
|
||||
and recovery reporting. The final deployed service is report-only Shadow mode.
|
||||
|
||||
The Action contract exposes:
|
||||
|
||||
- `createReleaseIntent`
|
||||
- `getReleaseCandidate`
|
||||
- `getReleasePlan`
|
||||
- `getReleaseAcceptance`
|
||||
- `submitSignedApproval`
|
||||
- `getReleaseSaga`
|
||||
- `getCapabilityStatus`
|
||||
- `startDeployment`
|
||||
- `getRecoveryStatus`
|
||||
|
||||
Every Shadow response declares `execution_enabled=false`, `shadow_mode=true`
|
||||
and `side_effects_executed=false`. `startDeployment` returns a simulated
|
||||
`SHADOW_COMPLETED` result and never claims production capabilities.
|
||||
|
||||
## Migration and rollback
|
||||
|
||||
1. Merge through a governed pull request.
|
||||
2. Build from a clean detached worktree at the exact merge commit.
|
||||
3. Verify artifact, manifest, package closure, tests, live fixture, no-.git
|
||||
canary, rollback archive and secret scan.
|
||||
4. Install a versioned Shadow release without changing the business runtime.
|
||||
5. Start the separate localhost-only controller with both gates false.
|
||||
6. Run Shadow candidates and freeze production PID/tag/Baseline/Registry hashes.
|
||||
7. Remove ephemeral test keys and temporary repositories.
|
||||
|
||||
Rollback stops and disables only the Shadow controller and restores/removes its
|
||||
versioned `current` link. The existing production API, tag set, Baseline and
|
||||
Registry are not involved.
|
||||
|
||||
## Future activation envelope
|
||||
|
||||
Activation requires a new one-time Owner approval binding the exact controller
|
||||
commit, artifact/manifest/policy/unit hashes, trusted key IDs, KMS/secret
|
||||
references, release and rollback roots, authoritative Git/repository identity,
|
||||
Shadow candidate/evidence IDs, test results, activation plan and rollback plan.
|
||||
That approval may only flip
|
||||
`AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false` to `true`; each real release still
|
||||
requires its own signed ReleasePlan. This change does not request or consume
|
||||
that activation approval.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Silent Release Controller Production Contract
|
||||
|
||||
Status: review-only implementation. Production execution remains disabled.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
The caller submits only release intent. A configured trusted builder owns ref
|
||||
validation, build, tests, artifact and manifest hashing, package closure,
|
||||
canary, rollback readiness, Git/Gitea consistency, and secret scanning. Caller
|
||||
supplied success evidence is rejected.
|
||||
|
||||
The resulting immutable `ReleasePlan` contains deployment, tag, and
|
||||
finalization plans. One Ed25519 owner signature binds its SHA-256 digest,
|
||||
candidate evidence, policy digest, owner identity, trust domain, validity
|
||||
window, evidence lineage, and three narrow capabilities.
|
||||
|
||||
## Durable execution
|
||||
|
||||
The Saga journal is the source of truth. Every record is sequence-numbered and
|
||||
SHA-256 hash-chained from a fixed genesis hash. Snapshots are rebuildable
|
||||
caches. Journal corruption, truncation, reordering, missing records, or hash
|
||||
mismatch moves the release to `MANUAL_OWNER_REVIEW_REQUIRED`.
|
||||
|
||||
Capability execution uses an exclusive, leased claim. A second worker receives
|
||||
`BUSY`; an expired claim is not reassigned until a capability-specific
|
||||
side-effect probe reports that the operation was not applied. Runtime, tag,
|
||||
and finalization remain separately claimed and recoverable.
|
||||
|
||||
## Execution order
|
||||
|
||||
```text
|
||||
APPROVED
|
||||
-> PRE_SWITCH_REVALIDATING
|
||||
-> SWITCH_PENDING
|
||||
-> SWITCHED
|
||||
-> RUNTIME_VERIFYING
|
||||
-> RUNTIME_VERIFIED
|
||||
-> TAG_PENDING
|
||||
-> TAG_VERIFIED
|
||||
-> BASELINE_REGISTRY_PENDING
|
||||
-> COMPLETED
|
||||
```
|
||||
|
||||
The actual adapter configuration is regenerated before execution and must
|
||||
match the signed plan. Trusted allowlists come from controlled policy, not the
|
||||
request.
|
||||
|
||||
A failure before verified tag publication rolls the runtime back. A failure
|
||||
after the exact tag is verified enters `RECONCILIATION_REQUIRED`; the tag is
|
||||
never deleted or rewritten. A same-name/different-OID conflict enters
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED`. Registry/Baseline finalization requires exact
|
||||
remote and Gitea tag proof, and Baseline is recorded last.
|
||||
|
||||
## Action and recovery contract
|
||||
|
||||
`submitOwnerApproval` verifies and durably stores the signed plan, then moves
|
||||
the Saga to `APPROVED`. `startDeployment` loads that durable approval and
|
||||
ignores caller-supplied approval content. Enabling execution without the
|
||||
trusted builder, verifier, validator, policy, and orchestrator fails during
|
||||
service construction.
|
||||
|
||||
The recovery worker scans incomplete journals and resumes through the same
|
||||
claims and verification gates. It never automatically resumes a manual-review
|
||||
Saga. In shadow mode it only reports state and performs no adapter side
|
||||
effects.
|
||||
|
||||
The deployed Action factory does not enable the legacy caller-evidence path.
|
||||
If the trusted builder is absent, candidate creation is rejected even while
|
||||
execution remains in shadow mode. The compatibility path exists only through
|
||||
the explicit test-only `SilentReleaseActionService.shadow()` constructor.
|
||||
|
||||
Finalization is composed from separate `RegistryController` and
|
||||
`BaselineController` claims. Registry is verified first and Baseline is the
|
||||
last mutation; a retry reuses completed Registry evidence and never moves
|
||||
Baseline ahead of tag verification.
|
||||
|
||||
## Governance
|
||||
|
||||
- `force_push`: forbidden
|
||||
- `tag_rewrite`: forbidden
|
||||
- `direct_main_push`: forbidden
|
||||
- production execution default: disabled
|
||||
- production tag operations in this change: not executed
|
||||
- Baseline/Registry mutations in this change: not executed
|
||||
@@ -0,0 +1,68 @@
|
||||
# Gitea Tag Publication Atomicity
|
||||
|
||||
Release publication is a fail-closed Saga bound to one immutable governance
|
||||
fingerprint:
|
||||
|
||||
- `version`
|
||||
- `target_oid`
|
||||
- `evidence_closure_oid`
|
||||
- `approval_plan_id`
|
||||
|
||||
The normal progression is:
|
||||
|
||||
`PENDING` → `LOCAL_TAG_CREATED` → `REMOTE_PUSH_CONFIRMED` →
|
||||
`GITEA_API_CONFIRMED` → `BASELINE_UPDATE_PENDING` → `BASELINE_UPDATED` →
|
||||
`PRODUCTION_DEPLOYED`
|
||||
|
||||
An unavailable remote uses `REMOTE_PUSH_PENDING` or
|
||||
`RECONCILIATION_REQUIRED`. A same-name tag object mismatch, peeled-target
|
||||
mismatch, changed governance fingerprint, or immutable deployment-evidence
|
||||
conflict enters `MANUAL_OWNER_REVIEW_REQUIRED`. That state blocks automatic
|
||||
push and reconciliation; recovery never deletes, rewrites, or force-pushes a
|
||||
tag.
|
||||
|
||||
## Legacy state migration
|
||||
|
||||
Readers normalize the historical `TAG_CREATED_LOCAL` value to
|
||||
`LOCAL_TAG_CREATED` in memory. Read-only status responses therefore expose only
|
||||
the canonical value. The next governed Saga write persists the canonical name;
|
||||
reading legacy state alone does not mutate the tag, Baseline, Registry, or Saga
|
||||
file. Any unrecognized persisted state fails closed as
|
||||
`RECONCILIATION_REQUIRED`.
|
||||
|
||||
`BASELINE_UPDATE_PENDING` is persisted before either `baseline/current.json` or
|
||||
`versions/<version>.json` changes. Recovery revalidates the governance
|
||||
fingerprint, evidence lineage, remote annotated tag, peeled target, Gitea API,
|
||||
listing, and repository identity before idempotently completing matching
|
||||
partial records. A different tag or target enters
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED`.
|
||||
|
||||
## Durable state
|
||||
|
||||
Production writers set `AIOS_GITOPS_PUBLICATION_STATE_DIR` to storage outside
|
||||
their disposable worktree. Recreating a writer must reuse that directory.
|
||||
Worktree JSON is not the authoritative Saga store.
|
||||
|
||||
## Deployment gate
|
||||
|
||||
`mark_production_deployed` performs fresh Git remote, peeled-target, Gitea ref,
|
||||
Gitea tag-object, listing, repository-identity, evidence-lineage, Registry, and
|
||||
Baseline checks. Deployment is allowed only when:
|
||||
|
||||
`Registry = Baseline = remote tag = Gitea API = expected target`
|
||||
|
||||
Deployment evidence must be an immutable `sha256:<64 hex>` or `git:<40 hex>`
|
||||
identifier.
|
||||
|
||||
## Read-only acceptance
|
||||
|
||||
- `getTagPublicationStatus` reads durable Saga state.
|
||||
- `getReleaseConsistency` compares every release authority.
|
||||
- `runReleaseAcceptance` returns `accepted: true` only for `PASS`.
|
||||
|
||||
Unavailable evidence returns `UNKNOWN` with `fail_closed: true`; it is never
|
||||
treated as acceptance.
|
||||
|
||||
The CLI, HTTP Action, and release script all enter the same publisher core for
|
||||
evidence-lineage verification. The release script stages Registry and Baseline
|
||||
records only; durable Saga state remains outside Git.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Isolated Writer Exact-Path Policy V1
|
||||
|
||||
This policy is an additional, service-owned authorization layer for
|
||||
`POST /api/commit-file`. It never expands a Token Policy V2 grant.
|
||||
|
||||
An atomic write is permitted only when all of the following agree:
|
||||
|
||||
1. socket peer, audience, operation, branch, base and exact path in the
|
||||
Token Policy V2 grant;
|
||||
2. service audience, repository identity, branch, operation and exact path
|
||||
in `AIOS_GITOPS_PATH_POLICY_FILE`;
|
||||
3. lexical path, resolved containment and symlink checks;
|
||||
4. the configured content-size and content-profile checks;
|
||||
5. operation digest, expected branch HEAD and durable operation ledger.
|
||||
|
||||
Any failed layer returns a fail-closed policy error. Coarse `/api/file` and
|
||||
`/api/commit` routes remain forbidden.
|
||||
|
||||
## File contract
|
||||
|
||||
The configured path must be absolute. The parent directory must be
|
||||
`root:gitops 0750`; the regular, non-symlink file must be
|
||||
`root:gitops 0640`. JSON must use UTF-8 canonical encoding with sorted keys,
|
||||
compact separators and one trailing newline. Unknown fields, unsupported
|
||||
operations or profiles, duplicate paths, oversized policies and
|
||||
group/world-writable files are rejected during Writer startup.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{"allowed_branch":"patch/codex-review-test-worker-20260724","allowed_operations":{"commit_file.atomic":{"content_profile":"systemd-codex-review-worker-v1","exact_paths":["deploy/systemd/aios-codex-review-test-worker.service"],"max_content_bytes":32768}},"audience":"aios-codex-review-worker-writer.service","policy_id":"codex-review-worker-path-policy-v1","repository_identity":"aios/aios","schema_version":1}
|
||||
```
|
||||
|
||||
The SHA-256 of the canonical policy is exposed in the isolated runtime
|
||||
status and written to the operation audit record. The request body cannot
|
||||
select or override the policy file.
|
||||
|
||||
## Content profile
|
||||
|
||||
`systemd-codex-review-worker-v1` is an exact, fail-closed unit grammar. The
|
||||
only sections are `[Unit]`, `[Service]` and `[Install]`. Each section has a
|
||||
directive allowlist; every single-value directive and every repeated
|
||||
`Environment`, `ReadOnlyPaths` and `ReadWritePaths` entry must match the
|
||||
governed Shadow unit contract exactly.
|
||||
|
||||
The profile fixes the Python module
|
||||
`task_plane.workers.codex_review_test`, `User=aiman`, `Group=gitops`,
|
||||
the controlled `/usr/libexec/aios-codex-review-test-worker/current`
|
||||
working directory, all hardening switches and the three permitted
|
||||
environment variables. It grants read access only to the authoritative
|
||||
bare repository and the versioned worker artifact, and write access only
|
||||
to the worker runtime, state and cache directories.
|
||||
|
||||
Unknown sections or directives are rejected, including lifecycle hooks,
|
||||
credential injection, environment imports, root images, bind mounts,
|
||||
capability expansion, devices, sockets and top-level writable paths. The
|
||||
parser also rejects duplicate sections, duplicate single-value directives,
|
||||
empty resets, continuation lines, CRLF, NUL, non-UTF-8 surrogate content,
|
||||
overlong lines and Unicode lookalike directive names. It does not use
|
||||
`ConfigParser(strict=False)` or silently merge repeated values.
|
||||
|
||||
Production credentials, if later authorized, must be supplied by a
|
||||
separately governed deployment drop-in. They are not permitted in the
|
||||
source unit or this content profile.
|
||||
|
||||
Policy changes require a separate maintenance branch and Owner-reviewed
|
||||
ordinary merge commit. A task-scoped Writer must not edit its own policy
|
||||
file.
|
||||
@@ -13,6 +13,27 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
schemas:
|
||||
PublicationState:
|
||||
type: string
|
||||
description: Canonical durable publication state. Legacy TAG_CREATED_LOCAL is normalized on read and is never emitted by new writes.
|
||||
enum:
|
||||
- PENDING
|
||||
- LOCAL_TAG_CREATED
|
||||
- REMOTE_PUSH_PENDING
|
||||
- REMOTE_PUSH_CONFIRMED
|
||||
- GITEA_API_CONFIRMED
|
||||
- BASELINE_UPDATE_PENDING
|
||||
- BASELINE_UPDATED
|
||||
- PRODUCTION_DEPLOYED
|
||||
- RECONCILIATION_REQUIRED
|
||||
- MANUAL_OWNER_REVIEW_REQUIRED
|
||||
PublicationStatus:
|
||||
type: object
|
||||
required: [version, state]
|
||||
properties:
|
||||
version: {type: string}
|
||||
state: {$ref: '#/components/schemas/PublicationState'}
|
||||
fail_closed: {type: boolean}
|
||||
BranchRequest:
|
||||
type: object
|
||||
required: [branch]
|
||||
@@ -30,6 +51,31 @@ components:
|
||||
path: {type: string, example: docs/gitops/chatgpt-change.md}
|
||||
content: {type: string}
|
||||
message: {type: string}
|
||||
AtomicFileCommitRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [branch, path, content, message, expected_head_oid, idempotency_key]
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
pattern: '^patch/[A-Za-z0-9._/-]+$'
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 512
|
||||
content:
|
||||
type: string
|
||||
maxLength: 1048576
|
||||
message:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 200
|
||||
expected_head_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{40}$'
|
||||
idempotency_key:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$'
|
||||
CommitRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -50,7 +96,7 @@ components:
|
||||
message: {type: string}
|
||||
TagRequest:
|
||||
type: object
|
||||
required: [version, target_oid]
|
||||
required: [version, target_oid, evidence_closure_oid, approval_plan_id]
|
||||
properties:
|
||||
version: {type: string, example: V1R1C1}
|
||||
target_oid:
|
||||
@@ -60,7 +106,7 @@ components:
|
||||
evidence_closure_oid:
|
||||
type: string
|
||||
pattern: '^[0-9a-fA-F]{40}$'
|
||||
description: Optional evidence closure commit. When supplied, target_oid must be its ancestor.
|
||||
description: Required evidence closure commit. target_oid must be its ancestor.
|
||||
approval_plan_id:
|
||||
type: string
|
||||
description: Audited owner-approved release or tag plan identifier.
|
||||
@@ -114,7 +160,216 @@ components:
|
||||
properties:
|
||||
text: {type: string}
|
||||
branch: {type: string}
|
||||
DeploymentPlan:
|
||||
type: object
|
||||
required: [service, release_path, release_root, cutover_link, dropin_path, dropin_sha256, systemd_unit_sha256, rollback_path, rollback_sha256, source_commit, tree_oid, artifact_sha256, manifest_sha256, package_closure_sha256, authoritative_git_dir, repository_identity, writer_expected, baseline_before_sha256, registry_before_sha256, production_tag_refset_before_sha256, health_contract_version, timeout_policy, retry_policy, policy_sha256, health_contract]
|
||||
properties:
|
||||
service: {type: string}
|
||||
release_path: {type: string}
|
||||
release_root: {type: string}
|
||||
cutover_link: {type: string}
|
||||
dropin_path: {type: string}
|
||||
dropin_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
systemd_unit_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
rollback_path: {type: string}
|
||||
rollback_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
package_closure_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
authoritative_git_dir: {type: string}
|
||||
repository_identity: {type: string}
|
||||
writer_expected: {type: boolean, enum: [false]}
|
||||
baseline_before_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
registry_before_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
production_tag_refset_before_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
health_contract_version: {type: string}
|
||||
timeout_policy: {type: object}
|
||||
retry_policy: {type: object}
|
||||
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
health_contract: {type: object, additionalProperties: true}
|
||||
ReleaseCandidateInput:
|
||||
type: object
|
||||
required: [source_commit, version]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
candidate_id: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$'}
|
||||
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
version: {type: string, pattern: '^V[0-9]+R[0-9]+C[0-9]+(?:[-.][A-Za-z0-9][A-Za-z0-9.-]*)?$'}
|
||||
ReleaseIntentInput:
|
||||
type: object
|
||||
required: [event_id, source_commit]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
event_id: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,255}$'}
|
||||
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
SignedReleasePlanApproval:
|
||||
type: object
|
||||
required: [release_plan_id, candidate_id, source_commit, tree_oid, artifact_sha256, manifest_sha256, deployment_plan_sha256, release_plan_sha256, policy_sha256, approval_plan_id, evidence_closure_oid, owner_approval_record_oid, capabilities, owner_identity, trust_domain, issued_at, not_before, expires_at, nonce, key_id, signature]
|
||||
properties:
|
||||
release_plan_id: {type: string}
|
||||
candidate_id: {type: string}
|
||||
source_commit: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
tree_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
artifact_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
manifest_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
deployment_plan_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
release_plan_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
policy_sha256: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
approval_plan_id: {type: string}
|
||||
evidence_closure_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
owner_approval_record_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
owner_identity: {type: string}
|
||||
trust_domain: {type: string}
|
||||
issued_at: {type: string, format: date-time}
|
||||
not_before: {type: string, format: date-time}
|
||||
capabilities:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum: [runtime_switch, tag_publish, baseline_registry_finalize]
|
||||
expires_at: {type: string, format: date-time}
|
||||
nonce: {type: string, minLength: 16}
|
||||
key_id: {type: string}
|
||||
signature: {type: string, format: byte, writeOnly: true}
|
||||
StartDeploymentRequest:
|
||||
type: object
|
||||
required: [candidate_id]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
paths:
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
operationId: createReleaseIntent
|
||||
summary: Create an idempotent intent; all build evidence comes from the trusted builder
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ReleaseIntentInput'}
|
||||
responses:
|
||||
'200': {description: Intent created or idempotently returned}
|
||||
'400': {description: Caller evidence or invalid intent rejected}
|
||||
x-openai-isConsequential: true
|
||||
/createReleaseCandidate:
|
||||
post:
|
||||
operationId: createReleaseCandidate
|
||||
summary: Submit release intent to the trusted builder; caller evidence is rejected
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ReleaseCandidateInput'}
|
||||
responses:
|
||||
'200': {description: Candidate created or idempotently returned}
|
||||
'400': {description: Invalid or conflicting candidate evidence}
|
||||
'401': {description: Unauthorized}
|
||||
x-openai-isConsequential: true
|
||||
/getReleaseCandidate:
|
||||
get:
|
||||
operationId: getReleaseCandidate
|
||||
summary: Read a durable release candidate
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Candidate}
|
||||
'404': {description: Candidate not found}
|
||||
/getReleasePlan:
|
||||
get:
|
||||
operationId: getReleasePlan
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Immutable release plan}
|
||||
'404': {description: Candidate not found}
|
||||
/getReleaseAcceptance:
|
||||
get:
|
||||
operationId: getReleaseAcceptance
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Builder attestation and acceptance checks}
|
||||
'404': {description: Candidate not found}
|
||||
/getApprovalEnvelope:
|
||||
get:
|
||||
operationId: getApprovalEnvelope
|
||||
summary: Generate the immutable owner-signature payload
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Approval envelope fingerprint inputs}
|
||||
'404': {description: Candidate not found}
|
||||
/submitOwnerApproval:
|
||||
post:
|
||||
operationId: submitOwnerApproval
|
||||
summary: Verify and durably store a signed owner release plan
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
|
||||
responses:
|
||||
'200': {description: Signature verified and approval stored}
|
||||
'400': {description: Invalid approval}
|
||||
'403': {description: Signature, key, expiry, or revocation failure}
|
||||
'409': {description: Conflicting release plan}
|
||||
x-openai-isConsequential: true
|
||||
/submitSignedApproval:
|
||||
post:
|
||||
operationId: submitSignedApproval
|
||||
summary: Alias for immutable signed owner approval submission
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SignedReleasePlanApproval'}
|
||||
responses:
|
||||
'200': {description: Signature verified and approval stored}
|
||||
'403': {description: Production approval backend unavailable or signature invalid}
|
||||
x-openai-isConsequential: true
|
||||
/startDeployment:
|
||||
post:
|
||||
operationId: startDeployment
|
||||
summary: Resume the durable approved Saga; caller cannot replace approval
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/StartDeploymentRequest'}
|
||||
responses:
|
||||
'200': {description: Shadow result or deployment Saga result}
|
||||
'403': {description: Execution disabled or approval invalid}
|
||||
'409': {description: Saga conflict}
|
||||
x-openai-isConsequential: true
|
||||
/getReleaseSaga:
|
||||
get:
|
||||
operationId: getReleaseSaga
|
||||
summary: Read durable Saga state and event journal
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Saga snapshot}
|
||||
'404': {description: Saga not found}
|
||||
/getCapabilityStatus:
|
||||
get:
|
||||
operationId: getCapabilityStatus
|
||||
parameters:
|
||||
- {in: query, name: candidate_id, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: Durable single-use capability records}
|
||||
/getRecoveryStatus:
|
||||
get:
|
||||
operationId: getRecoveryStatus
|
||||
responses:
|
||||
'200': {description: Fail-closed recovery classification}
|
||||
/api/base:
|
||||
get:
|
||||
operationId: getBaseline
|
||||
@@ -198,6 +453,25 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/FileCommitRequest'}
|
||||
responses: {'200': {description: File committed}, '400': {description: Secret rejected or invalid path}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit-file:
|
||||
post:
|
||||
operationId: commitFileAtomic
|
||||
summary: Atomically write one allowlisted file, commit, push, and verify using Token Policy V2
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AtomicFileCommitRequest'}
|
||||
responses:
|
||||
'200': {description: Completed or idempotently replayed}
|
||||
'400': {description: Invalid canonical request}
|
||||
'401': {description: Missing, invalid, expired, or revoked Token Policy V2 grant}
|
||||
'403': {description: Audience, source, branch, operation, path, or policy denied}
|
||||
'409': {description: Idempotency conflict or manual review required}
|
||||
'413': {description: Request too large}
|
||||
'503': {description: Push or remote verification unavailable; recoverable}
|
||||
x-openai-isConsequential: true
|
||||
/api/commit:
|
||||
post:
|
||||
operationId: commitChanges
|
||||
@@ -210,6 +484,39 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/CommitRequest'}
|
||||
responses: {'200': {description: Commit result}, '401': {description: Unauthorized}}
|
||||
x-openai-isConsequential: true
|
||||
/api/tag-publication-status:
|
||||
get:
|
||||
operationId: getTagPublicationStatus
|
||||
summary: Read durable publication Saga status without mutation
|
||||
parameters:
|
||||
- {in: query, name: version, required: true, schema: {type: string, pattern: '^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$'}}
|
||||
responses:
|
||||
'200':
|
||||
description: Canonical publication state or fail-closed UNKNOWN
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PublicationStatus'}
|
||||
'400': {description: Version required}
|
||||
/api/release-consistency:
|
||||
get:
|
||||
operationId: getReleaseConsistency
|
||||
summary: Compare Registry, Baseline, remote tag, Gitea API, listing, repository identity, and expected target
|
||||
parameters:
|
||||
- {in: query, name: version, required: true, schema: {type: string}}
|
||||
- {in: query, name: target_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
|
||||
- {in: query, name: evidence_closure_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
|
||||
- {in: query, name: approval_plan_id, required: true, schema: {type: string}}
|
||||
responses: {'200': {description: PASS, FAIL, or fail-closed UNKNOWN}}
|
||||
/api/release-acceptance:
|
||||
get:
|
||||
operationId: runReleaseAcceptance
|
||||
summary: Run read-only release acceptance; UNKNOWN is not accepted
|
||||
parameters:
|
||||
- {in: query, name: version, required: true, schema: {type: string}}
|
||||
- {in: query, name: target_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
|
||||
- {in: query, name: evidence_closure_oid, required: true, schema: {type: string, pattern: '^[0-9a-fA-F]{40}$'}}
|
||||
- {in: query, name: approval_plan_id, required: true, schema: {type: string}}
|
||||
responses: {'200': {description: Acceptance result with accepted boolean}}
|
||||
/api/tag:
|
||||
post:
|
||||
operationId: requestOrCreateTag
|
||||
@@ -220,7 +527,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TagRequest'}
|
||||
responses: {'200': {description: Tag request committed or tag created}, '400': {description: Invalid or unresolved target OID}, '403': {description: Forbidden}, '409': {description: Existing tag target conflict or evidence lineage mismatch}}
|
||||
responses: {'200': {description: Gitea-confirmed tag and fail-closed baseline result}, '400': {description: Missing governance evidence or invalid OID}, '403': {description: Forbidden}, '409': {description: Existing tag target conflict or evidence lineage mismatch}, '502': {description: Gitea reconciliation required}, '503': {description: Remote push pending}}
|
||||
x-openai-isConsequential: true
|
||||
/api/rollback:
|
||||
post:
|
||||
@@ -256,4 +563,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/NLRequest'}
|
||||
responses: {'
|
||||
responses:
|
||||
'200': {description: Safe GitOps plan}
|
||||
'401': {description: Unauthorized}
|
||||
x-openai-isConsequential: true
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# AIOS Task Plane
|
||||
|
||||
该模块是与任务发起方、Worker 类型和通知后端解耦的持久化任务控制平面。当前交付仅用于隔离开发与 Shadow 验收;默认 `execution_enabled=false`,不会修改生产、tag、Baseline 或 Registry。
|
||||
|
||||
## 架构
|
||||
|
||||
- `TaskPlaneService`:统一服务边界和 HTTP 路由。
|
||||
- `TaskPlaneStore`:SQLite WAL、revision/CAS、串行事务、租约、hash-chained journal、evidence 和 durable outbox。
|
||||
- `WorkerRegistry` / `LeaseManager`:身份绑定、capability、并发额度、心跳、过期探测后恢复。
|
||||
- `DelegationEngine`:父子 DAG、全局深度、capability/risk/approval/budget 单调约束和幂等重放。
|
||||
- `PolicyEngine`:默认关闭执行;身份、证据、结果摘要和可插拔签名验签均 fail-closed。
|
||||
- `WorkerAdapter`:OpenClaw、Codex、Hermes、A2A 和 systemd Worker 的统一契约。默认实现只返回 `SHADOW_VERIFIED`,不执行外部命令。
|
||||
- `DurableOutboxDispatcher`:Webhook、NATS JetStream、GPT callback、Dashboard 和 WeChat 的回执式通知接口。没有真实回执时始终保留 `NOT_DELIVERED`。
|
||||
|
||||
## 状态与恢复
|
||||
|
||||
正常路径为 `CREATED → QUEUED/CLAIMED → RUNNING → VERIFYING/COMPLETED`;失败路径使用 `FAILED_RECOVERABLE`、`FAILED_FINAL`、`CANCELLED` 或 `MANUAL_REVIEW_REQUIRED`。每次状态事件都进入同一事务中的 append-only hash chain。
|
||||
|
||||
租约过期不会自动重放副作用。控制平面必须先调用 Worker probe,只有明确确认原执行已停止,才可将 claim 标记为 `EXPIRED` 并把任务转为 `FAILED_RECOVERABLE`。结果必须匹配 task、claim、Worker identity、未过期 lease 和 operation digest;重复结果返回冲突。
|
||||
|
||||
## 安全模型
|
||||
|
||||
- task/worker/claim/message ID 使用 allowlist pattern,拒绝 URL 编码差异、路径分隔符和控制字符。
|
||||
- HTTP body 最大 1 MB,非对象 JSON、异常 Content-Length 和非法参数返回 400/413。
|
||||
- 内部异常响应不回显路径、payload 或凭据;默认 HTTP access log 不记录原始 request target。
|
||||
- secret-like 字段在 durable task、event、evidence 和 outbox 写入前脱敏。
|
||||
- 结果签名存在但没有配置验签器时拒绝;摘要、evidence 或 event 被篡改时验收失败。
|
||||
- SQLite 文件创建为 0600;新建状态目录使用 0700。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
PYTHONPATH=active-runtime \
|
||||
bash scripts/task-plane/run_task_plane_acceptance.sh
|
||||
|
||||
PYTHONPATH=active-runtime \
|
||||
python3 scripts/task-plane/create_shadow_tasks.py \
|
||||
--database reports/task-plane-shadow.sqlite3 \
|
||||
--report reports/task-plane-shadow.json
|
||||
```
|
||||
|
||||
上述脚本只使用本地 SQLite 和 Shadow adapter,不触发真实 OpenClaw、Codex、Hermes、微信或生产执行。
|
||||
@@ -0,0 +1,314 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: AIOS Task Plane API
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Durable control-plane APIs for task orchestration with fail-closed policy.
|
||||
Every POST operation requires bearerAuth; read-only GET operations may be
|
||||
exposed according to the surrounding runtime policy.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/tasks:
|
||||
get:
|
||||
summary: List tasks
|
||||
responses:
|
||||
"200":
|
||||
description: Task list
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Create task
|
||||
parameters:
|
||||
- in: header
|
||||
name: Idempotency-Key
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TaskPlan"
|
||||
responses:
|
||||
"201": { description: Created }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"409": { description: Conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Execution or resource locked }
|
||||
"503": { description: Authentication or service unavailable }
|
||||
/tasks/{task_id}:
|
||||
get:
|
||||
summary: Get task
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
responses:
|
||||
"200": { description: OK }
|
||||
"404": { description: Not found }
|
||||
/tasks/{task_id}/claim:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Claim a task
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TaskClaimRequest"
|
||||
responses:
|
||||
"200": { description: Claimed }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Not found }
|
||||
"409": { description: Conflict }
|
||||
"423": { description: Execution disabled }
|
||||
/tasks/{task_id}/heartbeat:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Renew task claim lease
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Leased }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Task or claim not found }
|
||||
"409": { description: Claim conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Lease or execution locked }
|
||||
"503": { description: Service unavailable }
|
||||
/tasks/{task_id}/progress:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Report task progress
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Progress saved }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Task or claim not found }
|
||||
"409": { description: Claim conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Lease or execution locked }
|
||||
"503": { description: Service unavailable }
|
||||
/tasks/{task_id}/result:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Submit task result
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Result accepted }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Result policy rejected }
|
||||
"404": { description: Task or claim not found }
|
||||
"409": { description: Duplicate or mismatched result }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Lease or execution locked }
|
||||
"503": { description: Verification service unavailable }
|
||||
/tasks/{task_id}/fail:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Mark task failed
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Failure recorded }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Task or claim not found }
|
||||
"409": { description: Claim conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Lease or execution locked }
|
||||
"503": { description: Service unavailable }
|
||||
/tasks/{task_id}/cancel:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Cancel task
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Cancelled }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Task not found }
|
||||
"409": { description: Revision or state conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Resource locked }
|
||||
"503": { description: Service unavailable }
|
||||
/tasks/{task_id}/delegate:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Delegate task
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Delegated }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Parent task not found }
|
||||
"409": { description: Conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Execution locked }
|
||||
"503": { description: Service unavailable }
|
||||
/workers/register:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Register worker
|
||||
responses:
|
||||
"201": { description: Worker registered }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Worker identity mismatch }
|
||||
"409": { description: Registration conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Resource locked }
|
||||
"503": { description: Service unavailable }
|
||||
/workers/{worker_id}/heartbeat:
|
||||
post:
|
||||
security: [ { bearerAuth: [] } ]
|
||||
summary: Worker heartbeat
|
||||
parameters: [ $ref: "#/components/parameters/WorkerId" ]
|
||||
responses:
|
||||
"200": { description: Heartbeat accepted }
|
||||
"400": { description: Bad request }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Worker not found }
|
||||
"409": { description: Worker state conflict }
|
||||
"413": { description: Payload too large }
|
||||
"423": { description: Worker locked }
|
||||
"503": { description: Service unavailable }
|
||||
/workers:
|
||||
get:
|
||||
summary: List workers
|
||||
responses:
|
||||
"200": { description: Worker list }
|
||||
/workers/{worker_id}:
|
||||
get:
|
||||
summary: Get worker
|
||||
parameters: [ $ref: "#/components/parameters/WorkerId" ]
|
||||
responses:
|
||||
"200": { description: Worker details }
|
||||
/tasks/{task_id}/events:
|
||||
get:
|
||||
summary: Task events
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Event list }
|
||||
/tasks/{task_id}/evidence:
|
||||
get:
|
||||
summary: Task evidence
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Evidence list }
|
||||
/tasks/{task_id}/children:
|
||||
get:
|
||||
summary: Task child tasks
|
||||
parameters: [ $ref: "#/components/parameters/TaskId" ]
|
||||
responses:
|
||||
"200": { description: Child list }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque
|
||||
parameters:
|
||||
TaskId:
|
||||
name: task_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
WorkerId:
|
||||
name: worker_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
schemas:
|
||||
TaskPlan:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
schema_version: { type: integer, enum: [1] }
|
||||
task_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
task_type:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$'
|
||||
issuer: { type: object }
|
||||
intent: { type: string, maxLength: 16384 }
|
||||
required_capabilities:
|
||||
type: array
|
||||
maxItems: 128
|
||||
items: { type: string }
|
||||
preferred_workers:
|
||||
type: array
|
||||
maxItems: 64
|
||||
items: { type: string }
|
||||
risk: { type: string, enum: [low, medium, high] }
|
||||
approval_policy:
|
||||
type: string
|
||||
maxLength: 128
|
||||
dependencies:
|
||||
type: array
|
||||
maxItems: 128
|
||||
items: { type: string }
|
||||
max_delegation_depth: { type: integer, minimum: 0, maximum: 32 }
|
||||
budgets:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
time_seconds: { type: integer, minimum: 0 }
|
||||
attempts: { type: integer, minimum: 0 }
|
||||
tokens: { type: integer, minimum: 0 }
|
||||
cost: { type: number, minimum: 0 }
|
||||
evidence_requirements:
|
||||
type: array
|
||||
maxItems: 128
|
||||
items: { type: string }
|
||||
parent_task_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
correlation_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
metadata:
|
||||
type: object
|
||||
TaskClaimRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [worker_id, actor_identity]
|
||||
properties:
|
||||
worker_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3600
|
||||
actor_identity:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 512
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"patch_name": "gitea-tag-publication-atomicity",
|
||||
"branch": "patch/gitea-tag-publication-atomicity-20260723",
|
||||
"base": "main",
|
||||
"risk": "high",
|
||||
"description": "Fail-closed, resumable Gitea release-tag publication with exact remote ref, peeled target, API and listing verification before baseline update.",
|
||||
"owner_approval": "received",
|
||||
"constraints": {
|
||||
"force_push": false,
|
||||
"tag_rewrite": false,
|
||||
"direct_main_push": false,
|
||||
"secret_commit": false,
|
||||
"production_runtime_anchor_change": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/aios-agent-task-control-plane-20260724",
|
||||
"base": "main",
|
||||
"created_by": "agent-task-plane-writer",
|
||||
"created_at": "2026-07-24T02:20:56Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "branch",
|
||||
"branch": "patch/gitea-tag-publication-atomicity-20260723",
|
||||
"base": "main",
|
||||
"created_by": "owner-gitea-tag-atomicity-20260723",
|
||||
"created_at": "2026-07-23T04:59:13Z",
|
||||
"direct_main_push": false,
|
||||
"owner_approval_required": true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"base": "main",
|
||||
"base_oid": "b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870",
|
||||
"commit_count": 10,
|
||||
"commits": [
|
||||
"fc0b9f29bf6dadfbcc8599da6f0ab11a6e19b529",
|
||||
"3812e1ab0123c7da4726e51179a10746b5bbde83",
|
||||
"3196d91b12d0ac5d7ea550b0474703a08736471b",
|
||||
"54093d15437ce0af31acf6feeeffacf6626a59cd",
|
||||
"27a0ae126b96d63c40544174e7a3859e30eba056",
|
||||
"83ab67f7e11eae9da4546cab53169f5a6a47e0ae",
|
||||
"f4b6ac8609faf947d2659b2c32564a26fcb3eb10",
|
||||
"a3edc628a91a4af3d702aacefe0389f844615bc1",
|
||||
"e40c05779c435c3726618de81e02e90953d727f3",
|
||||
"edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
|
||||
],
|
||||
"file_count": 15,
|
||||
"files": [
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"active-runtime/app/aios_gitops_write_guard.py",
|
||||
"active-runtime/app/gitea_tag_publication.py",
|
||||
"docs/gitops/gitea-tag-publication-atomicity.md",
|
||||
"docs/gitops/openapi-chatgpt-actions.yaml",
|
||||
"patches/gitea-tag-publication-atomicity-20260723.json",
|
||||
"patches/patch_gitea-tag-publication-atomicity-20260723.branch.json",
|
||||
"scripts/generate_delivery_manifest.py",
|
||||
"scripts/release_version.sh",
|
||||
"scripts/verify_gitea_credential_rotation.py",
|
||||
"security/incidents/GITEA-TOKEN-33-EXPOSURE-20260723.redacted.json",
|
||||
"tests/run_live_gitea_fixture.sh",
|
||||
"tests/test_chatgpt_actions_tag.py",
|
||||
"tests/test_gitea_tag_publication.py",
|
||||
"tests/test_gitea_tag_publication_live.py"
|
||||
],
|
||||
"generated_from": {
|
||||
"commit_range": "b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f",
|
||||
"commits_command": "git log --reverse --format=%H b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f",
|
||||
"files_command": "git diff --name-only b739fbb5ec8f7096d2f5ade87f65a7c74b0ee870..edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
|
||||
},
|
||||
"manifest_path": "reports/gitea-tag-publication-atomicity-delivery.json",
|
||||
"source_branch": "patch/gitea-tag-publication-atomicity-20260723",
|
||||
"source_commit": "edfad05d57fb3d23d8ab19d27bb6a43a7f2a298f"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"execution_status": "PARTIALLY_COMPLETED",
|
||||
"tests_run": 19,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"multi_agent_shadow_verified": true,
|
||||
"production_side_effects": false,
|
||||
"notification_delivery_status": "NOT_DELIVERED"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"root_task_id": "AIOS-AGENT-TASK-CONTROL-PLANE-20260724-SHADOW",
|
||||
"execution_mode": "shadow_adapter",
|
||||
"production_side_effects": false,
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "AIOS-TASK-PLANE-OPENCLAW-ACCEPTANCE",
|
||||
"parent_task_id": "AIOS-AGENT-TASK-CONTROL-PLANE-20260724-SHADOW",
|
||||
"task_status": "COMPLETED",
|
||||
"worker_result": {
|
||||
"worker": "openclaw",
|
||||
"task_id": "AIOS-TASK-PLANE-OPENCLAW-ACCEPTANCE",
|
||||
"status": "SHADOW_VERIFIED",
|
||||
"claim_id": "claim-093cbf4c-11d5-47a3-97dc-a373a8a53940",
|
||||
"execution_mode": "shadow",
|
||||
"production_side_effects": false
|
||||
},
|
||||
"result_digest": "b3a8ed4e0a60a0a9364d1eea1a6d30a403da1fb5ff79ff38a489a52227f4116e"
|
||||
},
|
||||
{
|
||||
"task_id": "AIOS-TASK-PLANE-CODEX-REVIEW",
|
||||
"parent_task_id": "AIOS-AGENT-TASK-CONTROL-PLANE-20260724-SHADOW",
|
||||
"task_status": "COMPLETED",
|
||||
"worker_result": {
|
||||
"worker": "codex",
|
||||
"task_id": "AIOS-TASK-PLANE-CODEX-REVIEW",
|
||||
"status": "SHADOW_VERIFIED",
|
||||
"claim_id": "claim-3cfe4031-27c7-409c-9841-cd5c0d26218d",
|
||||
"execution_mode": "shadow",
|
||||
"production_side_effects": false
|
||||
},
|
||||
"result_digest": "65fb825869b9b32d923b387796839e57b9ca7053716720861988ceb8d7e9c93a"
|
||||
},
|
||||
{
|
||||
"task_id": "AIOS-TASK-PLANE-HERMES-ARCHITECTURE",
|
||||
"parent_task_id": "AIOS-AGENT-TASK-CONTROL-PLANE-20260724-SHADOW",
|
||||
"task_status": "COMPLETED",
|
||||
"worker_result": {
|
||||
"worker": "hermes",
|
||||
"task_id": "AIOS-TASK-PLANE-HERMES-ARCHITECTURE",
|
||||
"status": "SHADOW_VERIFIED",
|
||||
"claim_id": "claim-fd7ba481-ab71-47dc-8e91-3655cbef1c40",
|
||||
"execution_mode": "shadow",
|
||||
"production_side_effects": false
|
||||
},
|
||||
"result_digest": "09c9ce563424b6490b7be1e854d06de786bd243a48b099137a7a2e9cb7035265"
|
||||
}
|
||||
],
|
||||
"event_chains_valid": true,
|
||||
"notification_delivery_status": [
|
||||
"NOT_DELIVERED"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema_version":1,"task_id":"AIOS-AGENT-TASK-CONTROL-PLANE-20260724","writer_status":"READY","production_side_effects":false}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic, secret-scanned Silent Release evidence package."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
AUTHORIZATION_PATTERN = re.compile(
|
||||
rb"Authorization:\s*(?:Bearer|token)\s+([A-Za-z0-9._~+/=-]{16,})",
|
||||
re.I,
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"(?:ghp|glpat)_[A-Za-z0-9_-]{20,}", re.I),
|
||||
re.compile(
|
||||
rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----\r?\n"
|
||||
rb"[A-Za-z0-9+/=\r\n]{64,}\r?\n"
|
||||
rb"-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"
|
||||
),
|
||||
)
|
||||
ALLOWED_AUTHORIZATION_PLACEHOLDER_SHA256 = frozenset(
|
||||
{
|
||||
"009f7191815ec615d09d0698457d048432e9a73f62ab4f26af1875009d0afbbc",
|
||||
"599a7f359d1e11124054f8afeae201f2d265b8988d91cb5b7d4dc4c9e2225c30",
|
||||
"f39dac6cbaba535e2c207cd0cd8f154974223c848f727f98b3564cea569b41cf",
|
||||
}
|
||||
)
|
||||
TARGET_TESTS = (
|
||||
"tests/test_silent_release_execution_readiness.py",
|
||||
"tests/test_silent_release_controller.py",
|
||||
"tests/test_silent_release_hardening.py",
|
||||
"tests/test_silent_release_production_contract.py",
|
||||
"tests/test_chatgpt_actions_tag.py",
|
||||
"tests/test_gitea_tag_publication.py",
|
||||
"tests/test_authoritative_git_context.py",
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
with temporary.open("w", encoding="utf-8", newline="\n") as output:
|
||||
json.dump(payload, output, sort_keys=True, separators=(",", ":"))
|
||||
output.write("\n")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def redact_output(value: str) -> str:
|
||||
value = re.sub(
|
||||
r"(?i)(Authorization:\s*(?:Bearer|token)\s+)"
|
||||
r"[A-Za-z0-9._~+/=-]{8,}",
|
||||
r"\1<redacted>",
|
||||
str(value),
|
||||
)
|
||||
value = re.sub(
|
||||
r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----.*?"
|
||||
r"-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
|
||||
"<redacted-private-key>",
|
||||
value,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def git(git_dir: Path, *arguments: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *arguments],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def run_evidence(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
output: Path,
|
||||
timeout: int,
|
||||
) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env={
|
||||
"HOME": str(cwd / ".builder-home"),
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
},
|
||||
)
|
||||
record = {
|
||||
"command": command,
|
||||
"exit_code": completed.returncode,
|
||||
"stderr": redact_output(completed.stderr[-20000:]),
|
||||
"stdout": redact_output(completed.stdout[-20000:]),
|
||||
}
|
||||
atomic_json(output, record)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(f"trusted builder command failed: {command[0]}")
|
||||
return record
|
||||
|
||||
|
||||
def secret_scan(root: Path) -> dict[str, Any]:
|
||||
findings: list[str] = []
|
||||
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||
if path.stat().st_size > 5 * 1024 * 1024:
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
authorization_tokens = AUTHORIZATION_PATTERN.findall(data)
|
||||
unsafe_authorization = any(
|
||||
hashlib.sha256(token.lower()).hexdigest()
|
||||
not in ALLOWED_AUTHORIZATION_PLACEHOLDER_SHA256
|
||||
for token in authorization_tokens
|
||||
)
|
||||
if unsafe_authorization or any(
|
||||
pattern.search(data) for pattern in SECRET_PATTERNS
|
||||
):
|
||||
findings.append(path.relative_to(root).as_posix())
|
||||
return {"findings": findings, "passed": not findings}
|
||||
|
||||
|
||||
def build(git_dir: Path, commit: str, output: Path) -> dict[str, Any]:
|
||||
commit = git(git_dir, "rev-parse", f"{commit}^{{commit}}")
|
||||
if not OID_RE.fullmatch(commit):
|
||||
raise ValueError("commit must resolve to a full OID")
|
||||
tree_oid = git(git_dir, "rev-parse", f"{commit}^{{tree}}")
|
||||
if output.exists():
|
||||
raise FileExistsError("artifact output already exists")
|
||||
output.mkdir(parents=True, mode=0o700)
|
||||
source_tar = output / "source.tar"
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={git_dir}",
|
||||
"archive",
|
||||
"--format=tar",
|
||||
f"--output={source_tar}",
|
||||
commit,
|
||||
],
|
||||
check=True,
|
||||
timeout=120,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
canary = output / "canary-src"
|
||||
canary.mkdir(mode=0o700)
|
||||
with tarfile.open(source_tar, "r") as archive:
|
||||
archive.extractall(canary, filter="data")
|
||||
if any(path.name == ".git" for path in canary.rglob(".git")):
|
||||
raise RuntimeError("no-.git canary contains Git metadata")
|
||||
|
||||
test_record = run_evidence(
|
||||
["python3", "-m", "pytest", "-q", *TARGET_TESTS],
|
||||
cwd=canary,
|
||||
output=output / "test-evidence.json",
|
||||
timeout=600,
|
||||
)
|
||||
fixture_record = run_evidence(
|
||||
["bash", "tests/run_live_gitea_fixture.sh"],
|
||||
cwd=canary,
|
||||
output=output / "live-fixture-evidence.json",
|
||||
timeout=600,
|
||||
)
|
||||
canary_record = {
|
||||
"git_directory_present": False,
|
||||
"live_fixture_exit_code": fixture_record["exit_code"],
|
||||
"passed": True,
|
||||
"target_test_exit_code": test_record["exit_code"],
|
||||
}
|
||||
atomic_json(output / "canary-evidence.json", canary_record)
|
||||
|
||||
scan = secret_scan(canary)
|
||||
atomic_json(output / "secret-scan.json", scan)
|
||||
if not scan["passed"]:
|
||||
raise RuntimeError("secret scan failed")
|
||||
|
||||
manifest_files: list[dict[str, Any]] = []
|
||||
tracked = git(git_dir, "ls-tree", "-r", "--name-only", commit).splitlines()
|
||||
for relative in tracked:
|
||||
path = canary / relative
|
||||
if path.is_file():
|
||||
manifest_files.append(
|
||||
{
|
||||
"path": relative,
|
||||
"sha256": sha256(path),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"commit": commit,
|
||||
"file_count": len(manifest_files),
|
||||
"files": manifest_files,
|
||||
"tree_oid": tree_oid,
|
||||
}
|
||||
atomic_json(output / "manifest.json", manifest)
|
||||
shutil.copyfile(source_tar, output / "rollback-source.tar")
|
||||
|
||||
closure = {
|
||||
"artifact_sha256": sha256(source_tar),
|
||||
"canary_evidence_sha256": sha256(output / "canary-evidence.json"),
|
||||
"commit": commit,
|
||||
"live_fixture_evidence_sha256": sha256(
|
||||
output / "live-fixture-evidence.json"
|
||||
),
|
||||
"manifest_sha256": sha256(output / "manifest.json"),
|
||||
"no_git_canary": True,
|
||||
"rollback_artifact_sha256": sha256(
|
||||
output / "rollback-source.tar"
|
||||
),
|
||||
"secret_scan_sha256": sha256(output / "secret-scan.json"),
|
||||
"test_evidence_sha256": sha256(output / "test-evidence.json"),
|
||||
"tree_oid": tree_oid,
|
||||
}
|
||||
atomic_json(output / "package-closure.json", closure)
|
||||
closure["package_closure_sha256"] = sha256(
|
||||
output / "package-closure.json"
|
||||
)
|
||||
checksummed = sorted(
|
||||
path
|
||||
for path in output.iterdir()
|
||||
if path.is_file() and path.name != "checksums.sha256"
|
||||
)
|
||||
with (output / "checksums.sha256").open(
|
||||
"w", encoding="utf-8", newline="\n"
|
||||
) as checksums:
|
||||
for path in checksummed:
|
||||
checksums.write(f"{sha256(path)} {path.name}\n")
|
||||
return closure
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--git-dir", required=True, type=Path)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
result = build(
|
||||
arguments.git_dir.resolve(),
|
||||
arguments.commit,
|
||||
arguments.output.resolve(),
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic, local-only fault injection for Task Plane recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from app.task_plane.api import TaskPlaneService
|
||||
from app.task_plane.policy import PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database", required=True)
|
||||
args = parser.parse_args()
|
||||
store = TaskPlaneStore(args.database)
|
||||
service = TaskPlaneService(
|
||||
store, policy=PolicyEngine(execution_enabled=True)
|
||||
)
|
||||
identity = "spiffe://aios/agent/codex/fault-injection"
|
||||
service.register_worker(
|
||||
{
|
||||
"worker_id": "codex-fault-injection",
|
||||
"worker_type": "codex",
|
||||
"identity": identity,
|
||||
"capabilities": ["test.execute"],
|
||||
"trust_zone": "isolated-shadow",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 1,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
task = service.create_task(
|
||||
{"task_id": "task-fault-expired-lease", "required_capabilities": ["test.execute"]}
|
||||
)
|
||||
claim = service.claim(
|
||||
task["task_id"],
|
||||
{
|
||||
"worker_id": "codex-fault-injection",
|
||||
"actor_identity": identity,
|
||||
"lease_seconds": 1,
|
||||
},
|
||||
)
|
||||
with mock.patch("app.task_plane.store._now", return_value=time.time() + 5):
|
||||
recovered = store.recover_expired_claim(
|
||||
task["task_id"], claim["claim_id"], probe_confirms_stopped=True
|
||||
)
|
||||
output = {
|
||||
"fault": "expired_lease",
|
||||
"recovered_status": recovered.status,
|
||||
"event_chain_valid": store.verify_event_chain(task["task_id"]),
|
||||
"production_side_effects": False,
|
||||
}
|
||||
print(json.dumps(output, sort_keys=True))
|
||||
store.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the delivery file list from Git instead of hand-maintained counts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def git(root: Path, *args: str) -> list[str]:
|
||||
output = subprocess.check_output(
|
||||
["git", *args], cwd=root, text=True, encoding="utf-8"
|
||||
)
|
||||
return [line for line in output.splitlines() if line]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", default="main")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="reports/gitea-tag-publication-atomicity-delivery.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
root = Path(
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"], text=True, encoding="utf-8"
|
||||
).strip()
|
||||
)
|
||||
output = Path(args.output)
|
||||
if output.is_absolute() or ".." in output.parts:
|
||||
raise SystemExit("output must remain inside the repository")
|
||||
output_path = root / output
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
base_oid = git(root, "rev-parse", "--verify", f"{args.base}^{{commit}}")[0]
|
||||
source_commit = git(root, "rev-parse", "--verify", "HEAD^{commit}")[0]
|
||||
source_branch = git(root, "symbolic-ref", "--short", "HEAD")[0]
|
||||
commit_range = f"{base_oid}..{source_commit}"
|
||||
commits = git(root, "log", "--reverse", "--format=%H", commit_range)
|
||||
files = [
|
||||
path
|
||||
for path in git(root, "diff", "--name-only", commit_range)
|
||||
if path != output.as_posix()
|
||||
]
|
||||
payload = {
|
||||
"base": args.base,
|
||||
"base_oid": base_oid,
|
||||
"source_branch": source_branch,
|
||||
"source_commit": source_commit,
|
||||
"commit_count": len(commits),
|
||||
"commits": commits,
|
||||
"file_count": len(files),
|
||||
"files": files,
|
||||
"manifest_path": output.as_posix(),
|
||||
"generated_from": {
|
||||
"commit_range": commit_range,
|
||||
"files_command": f"git diff --name-only {commit_range}",
|
||||
"commits_command": f"git log --reverse --format=%H {commit_range}",
|
||||
},
|
||||
}
|
||||
output_path.write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+52
-22
@@ -1,23 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION="${1:?version required, e.g. V1R1C0}"
|
||||
MESSAGE="${2:-release $VERSION}"
|
||||
if ! echo "$VERSION" | grep -Eq '^V[0-9]+R[0-9]+C[0-9]+$'; then echo "ERROR: version must match VxRxCx"; exit 1; fi
|
||||
cd "${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
||||
git checkout main
|
||||
git pull origin main || true
|
||||
git tag -a "$VERSION" -m "$MESSAGE" || true
|
||||
git push origin "$VERSION" || true
|
||||
python3 - <<PY
|
||||
import json, datetime
|
||||
from pathlib import Path
|
||||
p=Path('baseline/current.json')
|
||||
if p.exists(): d=json.loads(p.read_text())
|
||||
else: p.parent.mkdir(exist_ok=True); d={}
|
||||
d['active_version']='$VERSION'; d['active_tag']='$VERSION'; d['updated_at']=datetime.datetime.now(datetime.UTC).isoformat(); d['update_mode']='git_patch_only'
|
||||
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
git add baseline/current.json || true
|
||||
git commit -m "release($VERSION): update active baseline" || true
|
||||
git push origin main || true
|
||||
echo "RELEASE_DONE $VERSION"
|
||||
set -Eeuo pipefail
|
||||
|
||||
VERSION="${1:?version required, e.g. V2R1C4}"
|
||||
TARGET_OID="${2:?target_oid required}"
|
||||
EVIDENCE_CLOSURE_OID="${3:?evidence_closure_oid required}"
|
||||
APPROVAL_PLAN_ID="${4:?approval_plan_id required}"
|
||||
WORKSPACE="${AIOS_GITOPS_WORKSPACE:-/data/aios-gitops/workspace}"
|
||||
TAG_REMOTE="${AIOS_GITOPS_GITEA_REMOTE:-gitea}"
|
||||
BRANCH_REMOTE="${AIOS_GITOPS_BRANCH_REMOTE:-gitea}"
|
||||
|
||||
case "$VERSION" in
|
||||
V[0-9]*R[0-9]*C[0-9]*) ;;
|
||||
*) echo "ERROR: version must match VxRxCx" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
for value in "$TARGET_OID" "$EVIDENCE_CLOSURE_OID"; do
|
||||
[[ "$value" =~ ^[0-9a-f]{40}$ ]] || {
|
||||
echo "ERROR: target and evidence OIDs must be full lowercase 40-character OIDs" >&2
|
||||
exit 64
|
||||
}
|
||||
done
|
||||
|
||||
[[ -n "$APPROVAL_PLAN_ID" ]] || {
|
||||
echo "ERROR: approval_plan_id is required" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
cd "$WORKSPACE"
|
||||
BRANCH="$(git symbolic-ref --quiet --short HEAD)" || {
|
||||
echo "ERROR: release must run on an isolated branch, not detached HEAD" >&2
|
||||
exit 65
|
||||
}
|
||||
case "$BRANCH" in
|
||||
main|master) echo "ERROR: direct main/master release writes are forbidden" >&2; exit 65 ;;
|
||||
esac
|
||||
|
||||
python3 "$WORKSPACE/active-runtime/app/gitea_tag_publication.py" publish \
|
||||
--workspace "$WORKSPACE" \
|
||||
--remote "$TAG_REMOTE" \
|
||||
--version "$VERSION" \
|
||||
--target-oid "$TARGET_OID" \
|
||||
--evidence-closure-oid "$EVIDENCE_CLOSURE_OID" \
|
||||
--approval-plan-id "$APPROVAL_PLAN_ID"
|
||||
|
||||
git add -- "baseline/current.json" "versions/$VERSION.json"
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "release($VERSION): record Gitea-confirmed baseline"
|
||||
fi
|
||||
|
||||
# No force, no main push, and no ignored failure.
|
||||
git push "$BRANCH_REMOTE" "HEAD:refs/heads/$BRANCH"
|
||||
echo "RELEASE_BASELINE_UPDATED $VERSION branch=$BRANCH"
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run two zero-side-effect Silent Release candidates in Shadow mode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
||||
from silent_release_execution import ( # noqa: E402
|
||||
LocalTestApprovalSigner,
|
||||
LocalTestBuilderSigner,
|
||||
PersistentSilentReleaseController,
|
||||
SignedTrustedBuilderAdapter,
|
||||
)
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BuilderAttestationVerifier,
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TagPlan,
|
||||
TagPublicationProbeEvidence,
|
||||
TrustedCandidateBundle,
|
||||
TrustedReleasePolicy,
|
||||
classify_tag_publication,
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def oid(seed: str) -> str:
|
||||
return hashlib.sha1(seed.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def candidate_id(event_id: str, source_commit: str) -> str:
|
||||
return "rc-shadow-" + hashlib.sha256(
|
||||
f"{event_id}\0{source_commit}".encode("utf-8")
|
||||
).hexdigest()[:20]
|
||||
|
||||
|
||||
def make_plan(arguments: argparse.Namespace, candidate: str) -> ReleasePlan:
|
||||
version = "shadow-" + candidate.removeprefix("rc-shadow-")
|
||||
tag_oid = oid("tag:" + candidate)
|
||||
approval_plan_id = "shadow-plan-" + candidate.removeprefix("rc-shadow-")
|
||||
release_path = str(Path(arguments.release_path))
|
||||
previous_baseline = arguments.baseline_before_sha256
|
||||
return ReleasePlan(
|
||||
deployment=DeploymentPlan(
|
||||
service="aios-silent-release-controller",
|
||||
release_path=release_path,
|
||||
release_root=str(Path(release_path).parent.parent),
|
||||
cutover_link=str(Path(release_path).parent.parent / "current"),
|
||||
dropin_path=str(
|
||||
Path(release_path)
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-silent-release-controller.service"
|
||||
),
|
||||
dropin_sha256=arguments.systemd_unit_sha256,
|
||||
systemd_unit_sha256=arguments.systemd_unit_sha256,
|
||||
rollback_path=str(Path(arguments.rollback_artifact)),
|
||||
rollback_sha256=arguments.artifact_sha256,
|
||||
source_commit=arguments.source_commit,
|
||||
tree_oid=arguments.tree_oid,
|
||||
artifact_sha256=arguments.artifact_sha256,
|
||||
manifest_sha256=arguments.manifest_sha256,
|
||||
package_closure_sha256=arguments.package_closure_sha256,
|
||||
authoritative_git_dir=str(Path(arguments.repository)),
|
||||
repository_identity=arguments.repository_identity,
|
||||
writer_expected=False,
|
||||
baseline_before_sha256=previous_baseline,
|
||||
registry_before_sha256=arguments.registry_before_sha256,
|
||||
production_tag_refset_before_sha256=(
|
||||
arguments.tag_refset_before_sha256
|
||||
),
|
||||
health_contract_version="shadow-health/v1",
|
||||
timeout_policy={"health_seconds": 10},
|
||||
retry_policy={"health_attempts": 1},
|
||||
policy_sha256=arguments.policy_sha256,
|
||||
health_contract={
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
},
|
||||
),
|
||||
tag=TagPlan(
|
||||
version=version,
|
||||
tag_type="annotated",
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=arguments.source_commit,
|
||||
evidence_closure_oid=arguments.source_commit,
|
||||
approval_plan_id=approval_plan_id,
|
||||
repository_identity=arguments.repository_identity,
|
||||
gitea_owner="aios",
|
||||
gitea_repository="aios",
|
||||
current_remote_tag_oid=None,
|
||||
remote_absent_evidence_sha256=hashlib.sha256(
|
||||
f"shadow-only:{candidate}".encode("utf-8")
|
||||
).hexdigest(),
|
||||
verification_contract="shadow-no-tag-write/v1",
|
||||
),
|
||||
finalization=FinalizationPlan(
|
||||
version=version,
|
||||
expected_tag_oid=tag_oid,
|
||||
expected_target_oid=arguments.source_commit,
|
||||
expected_previous_baseline_sha256=previous_baseline,
|
||||
registry_expected_record={
|
||||
"shadow_only": True,
|
||||
"target_oid": arguments.source_commit,
|
||||
},
|
||||
baseline_expected_record={
|
||||
"shadow_only": True,
|
||||
"target_oid": arguments.source_commit,
|
||||
},
|
||||
final_consistency_contract="shadow-no-finalization/v1",
|
||||
),
|
||||
policy_sha256=arguments.policy_sha256,
|
||||
)
|
||||
|
||||
|
||||
def make_candidate(
|
||||
arguments: argparse.Namespace, event_id: str
|
||||
) -> ReleaseCandidate:
|
||||
return ReleaseCandidate(
|
||||
candidate_id=candidate_id(event_id, arguments.source_commit),
|
||||
source_commit=arguments.source_commit,
|
||||
tree_oid=arguments.tree_oid,
|
||||
artifact_sha256=arguments.artifact_sha256,
|
||||
manifest_sha256=arguments.manifest_sha256,
|
||||
tests={"passed": True, "source": "trusted-artifact-evidence"},
|
||||
canary={"passed": True, "without_git": True},
|
||||
rollback_ready=True,
|
||||
git_consistency={"consistent": True},
|
||||
gitea_consistency={"consistent": True},
|
||||
approval_state="PENDING",
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
)
|
||||
|
||||
|
||||
def sign_approval(
|
||||
service: SilentReleaseActionService,
|
||||
signer: LocalTestApprovalSigner,
|
||||
candidate: str,
|
||||
nonce_suffix: str,
|
||||
) -> SignedReleasePlanApproval:
|
||||
release_candidate = service.candidates.load(candidate)
|
||||
plan = service.release_plans.load(candidate)
|
||||
now = datetime.now(timezone.utc)
|
||||
unsigned = SignedReleasePlanApproval(
|
||||
release_plan_id="shadow-release-" + candidate.removeprefix("rc-"),
|
||||
candidate_id=candidate,
|
||||
source_commit=release_candidate.source_commit,
|
||||
tree_oid=release_candidate.tree_oid,
|
||||
artifact_sha256=release_candidate.artifact_sha256,
|
||||
manifest_sha256=release_candidate.manifest_sha256,
|
||||
deployment_plan_sha256=plan.deployment.sha256,
|
||||
release_plan_sha256=plan.sha256,
|
||||
policy_sha256=plan.policy_sha256,
|
||||
approval_plan_id=plan.tag.approval_plan_id,
|
||||
evidence_closure_oid=plan.tag.evidence_closure_oid,
|
||||
owner_approval_record_oid=oid("shadow-owner:" + candidate),
|
||||
capabilities=(
|
||||
"runtime_switch",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize",
|
||||
),
|
||||
owner_identity="owner:shadow-acceptance-test",
|
||||
trust_domain="aios.shadow",
|
||||
issued_at=now.isoformat().replace("+00:00", "Z"),
|
||||
not_before=now.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=(now + timedelta(minutes=10))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
nonce="shadow-nonce-" + nonce_suffix,
|
||||
key_id=signer.key_id,
|
||||
signature="",
|
||||
)
|
||||
return signer.sign(unsigned)
|
||||
|
||||
|
||||
def tag_probe_results(plan: TagPlan) -> dict[str, str]:
|
||||
exact = TagPublicationProbeEvidence(
|
||||
repository_identity=plan.repository_identity,
|
||||
remote_reachable=True,
|
||||
gitea_api_reachable=True,
|
||||
gitea_listing_reachable=True,
|
||||
remote_tag_oid=plan.expected_tag_oid,
|
||||
annotated_tag_object_oid=plan.expected_tag_oid,
|
||||
remote_peeled_target_oid=plan.expected_target_oid,
|
||||
gitea_api_tag_oid=plan.expected_tag_oid,
|
||||
gitea_api_peeled_target_oid=plan.expected_target_oid,
|
||||
gitea_listing_visible=True,
|
||||
gitea_listing_tag_oid=plan.expected_tag_oid,
|
||||
gitea_listing_peeled_target_oid=plan.expected_target_oid,
|
||||
observed_at=1,
|
||||
)
|
||||
absent = dataclasses.replace(
|
||||
exact,
|
||||
remote_tag_oid=None,
|
||||
annotated_tag_object_oid=None,
|
||||
remote_peeled_target_oid=None,
|
||||
gitea_api_tag_oid=None,
|
||||
gitea_api_peeled_target_oid=None,
|
||||
gitea_listing_visible=False,
|
||||
gitea_listing_tag_oid=None,
|
||||
gitea_listing_peeled_target_oid=None,
|
||||
)
|
||||
indeterminate = dataclasses.replace(exact, gitea_api_reachable=False)
|
||||
conflict = dataclasses.replace(exact, remote_tag_oid=oid("conflict"))
|
||||
return {
|
||||
"APPLIED_VERIFIED": classify_tag_publication(exact, plan).value,
|
||||
"CONFLICT": classify_tag_publication(conflict, plan).value,
|
||||
"INDETERMINATE": classify_tag_publication(
|
||||
indeterminate, plan
|
||||
).value,
|
||||
"NOT_APPLIED": classify_tag_publication(absent, plan).value,
|
||||
}
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser()
|
||||
value.add_argument("--state-root", required=True, type=Path)
|
||||
value.add_argument("--repository", required=True, type=Path)
|
||||
value.add_argument("--release-path", required=True, type=Path)
|
||||
value.add_argument("--rollback-artifact", required=True, type=Path)
|
||||
value.add_argument("--source-commit", required=True)
|
||||
value.add_argument("--tree-oid", required=True)
|
||||
value.add_argument("--artifact-sha256", required=True)
|
||||
value.add_argument("--manifest-sha256", required=True)
|
||||
value.add_argument("--package-closure-sha256", required=True)
|
||||
value.add_argument("--policy-sha256", required=True)
|
||||
value.add_argument("--systemd-unit-sha256", required=True)
|
||||
value.add_argument("--baseline-before-sha256", required=True)
|
||||
value.add_argument("--registry-before-sha256", required=True)
|
||||
value.add_argument("--tag-refset-before-sha256", required=True)
|
||||
value.add_argument("--repository-identity", default="aios/aios")
|
||||
value.add_argument("--output", required=True, type=Path)
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = parser().parse_args()
|
||||
if sha256_file(arguments.rollback_artifact) != arguments.artifact_sha256:
|
||||
raise SystemExit("rollback artifact hash mismatch")
|
||||
unit_path = (
|
||||
arguments.release_path
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-silent-release-controller.service"
|
||||
)
|
||||
if sha256_file(unit_path) != arguments.systemd_unit_sha256:
|
||||
raise SystemExit("systemd unit hash mismatch")
|
||||
|
||||
builder_signer = LocalTestBuilderSigner.generate()
|
||||
builder_public = builder_signer.public_key.public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
owner_private = Ed25519PrivateKey.generate()
|
||||
owner_public = owner_private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
owner_signer = LocalTestApprovalSigner(
|
||||
"shadow-owner-ephemeral", owner_private
|
||||
)
|
||||
|
||||
def collector(intent):
|
||||
event_id = str(intent["event_id"])
|
||||
candidate = make_candidate(arguments, event_id)
|
||||
return TrustedCandidateBundle(
|
||||
candidate=candidate,
|
||||
release_plan=make_plan(arguments, candidate.candidate_id),
|
||||
checks={
|
||||
"authoritative_git": True,
|
||||
"artifact": True,
|
||||
"canary": True,
|
||||
"gitea": True,
|
||||
"manifest": True,
|
||||
"package_closure": True,
|
||||
"rollback": True,
|
||||
"secret_scan": True,
|
||||
"tests": True,
|
||||
},
|
||||
)
|
||||
|
||||
release_root = str(arguments.release_path.parent.parent)
|
||||
dropin = str(
|
||||
arguments.release_path
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-silent-release-controller.service"
|
||||
)
|
||||
policy = TrustedReleasePolicy(
|
||||
services=frozenset({"aios-silent-release-controller"}),
|
||||
release_roots=frozenset({release_root}),
|
||||
cutover_links=frozenset({str(Path(release_root) / "current")}),
|
||||
dropin_paths=frozenset({dropin}),
|
||||
authoritative_git_dirs=frozenset({str(arguments.repository)}),
|
||||
repository_identities=frozenset(
|
||||
{arguments.repository_identity}
|
||||
),
|
||||
)
|
||||
verifier = SignedApprovalVerifier(
|
||||
ApprovalKeyRing({"shadow-owner-ephemeral": owner_public}),
|
||||
ApprovalRevocationStore(arguments.state_root / "revocations"),
|
||||
allowed_trust_domains={"aios.shadow"},
|
||||
)
|
||||
service = SilentReleaseActionService(
|
||||
arguments.state_root,
|
||||
execution_enabled=False,
|
||||
approval_verifier=verifier,
|
||||
candidate_builder=SignedTrustedBuilderAdapter(
|
||||
collector, builder_signer
|
||||
),
|
||||
builder_attestation_verifier=BuilderAttestationVerifier(
|
||||
{builder_signer.key_id: builder_public}
|
||||
),
|
||||
trusted_policy=policy,
|
||||
expected_repository=arguments.repository_identity,
|
||||
policy_sha256=arguments.policy_sha256,
|
||||
)
|
||||
|
||||
first_event = "main-" + arguments.source_commit
|
||||
first = service.create_release_intent(
|
||||
{
|
||||
"event_id": first_event,
|
||||
"source_commit": arguments.source_commit,
|
||||
}
|
||||
)
|
||||
first_duplicate = service.create_release_intent(
|
||||
{
|
||||
"event_id": first_event,
|
||||
"source_commit": arguments.source_commit,
|
||||
}
|
||||
)
|
||||
first_notification = service.ensure_owner_notification(
|
||||
first["candidate_id"]
|
||||
)
|
||||
repeated_notification = service.ensure_owner_notification(
|
||||
first["candidate_id"]
|
||||
)
|
||||
first_approval = sign_approval(
|
||||
service, owner_signer, first["candidate_id"], "candidate-one"
|
||||
)
|
||||
first_submit = service.submit_owner_approval(
|
||||
first_approval.to_dict()
|
||||
)
|
||||
first_submit_repeated = service.submit_owner_approval(
|
||||
first_approval.to_dict()
|
||||
)
|
||||
first_start = service.start_deployment(first["candidate_id"])
|
||||
|
||||
second = service.create_release_intent(
|
||||
{
|
||||
"event_id": "shadow-indeterminate-" + arguments.source_commit,
|
||||
"source_commit": arguments.source_commit,
|
||||
}
|
||||
)
|
||||
second_plan = service.release_plans.load(second["candidate_id"])
|
||||
probe_results = tag_probe_results(second_plan.tag)
|
||||
|
||||
first_controller = PersistentSilentReleaseController(
|
||||
arguments.state_root,
|
||||
service,
|
||||
main_probe=lambda: arguments.source_commit,
|
||||
)
|
||||
controller_first = first_controller.run_once()
|
||||
controller_restarted = PersistentSilentReleaseController(
|
||||
arguments.state_root,
|
||||
service,
|
||||
main_probe=lambda: arguments.source_commit,
|
||||
).run_once()
|
||||
|
||||
report = {
|
||||
"approval_backend": "ephemeral-shadow-test-only",
|
||||
"artifact_sha256": arguments.artifact_sha256,
|
||||
"builder_attestation": "VERIFIED",
|
||||
"candidate_count": 2,
|
||||
"candidates": [
|
||||
{
|
||||
"candidate_id": first["candidate_id"],
|
||||
"duplicate_intent_idempotent": first_duplicate["idempotent"],
|
||||
"notification_idempotent": (
|
||||
repeated_notification["idempotent"]
|
||||
),
|
||||
"notification_initial_idempotent": (
|
||||
first_notification["idempotent"]
|
||||
),
|
||||
"replayed_approval_status": (
|
||||
first_submit_repeated["status"]
|
||||
),
|
||||
"shadow_start": first_start,
|
||||
"signed_approval_status": first_submit["status"],
|
||||
},
|
||||
{
|
||||
"candidate_id": second["candidate_id"],
|
||||
"tag_publication_probe": "INDETERMINATE",
|
||||
},
|
||||
],
|
||||
"controller_restart_idempotent": (
|
||||
controller_restarted["intent"]["idempotent"]
|
||||
),
|
||||
"controller_restart_notification_idempotent": (
|
||||
controller_restarted["notification"]["idempotent"]
|
||||
),
|
||||
"controller_status": controller_first["startup"]["status"],
|
||||
"execution_enabled": False,
|
||||
"manifest_sha256": arguments.manifest_sha256,
|
||||
"package_closure_sha256": arguments.package_closure_sha256,
|
||||
"private_key_files_written": False,
|
||||
"production_tag": "NOT_EXECUTED",
|
||||
"repository_identity": arguments.repository_identity,
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"source_commit": arguments.source_commit,
|
||||
"tag_probe_outcomes": probe_results,
|
||||
"tree_oid": arguments.tree_oid,
|
||||
}
|
||||
write_json(arguments.output, report)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate_count": 2,
|
||||
"execution_enabled": False,
|
||||
"output": str(arguments.output),
|
||||
"shadow_mode": True,
|
||||
"side_effects_executed": False,
|
||||
"status": "SHADOW_ACCEPTANCE_PASSED",
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create one authenticated, no-side-effect Task Plane Shadow task.
|
||||
|
||||
The client deliberately has no worker, Git, deployment, or credential-writing
|
||||
capabilities. Its token is accepted only from a protected file reference or
|
||||
stdin so that it cannot enter the process command line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
MINIMUM_CLIENT_CAPABILITIES = (
|
||||
"task.creator",
|
||||
"task.read",
|
||||
"task.events.read",
|
||||
"task.evidence.read",
|
||||
)
|
||||
|
||||
|
||||
class ShadowClientError(RuntimeError):
|
||||
"""HTTP failure that intentionally carries no response body."""
|
||||
|
||||
def __init__(self, status: int):
|
||||
super().__init__(f"shadow task request failed with HTTP {status}")
|
||||
self.status = status
|
||||
|
||||
|
||||
def _request_json(
|
||||
base_url: str,
|
||||
path: str,
|
||||
token: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
payload: dict[str, Any] | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
data = None
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
data = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
|
||||
request = urllib.request.Request(
|
||||
base_url.rstrip("/") + path,
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
body = response.read()
|
||||
parsed = json.loads(body.decode("utf-8")) if body else {}
|
||||
return response.status, parsed
|
||||
except urllib.error.HTTPError as error:
|
||||
# Never read or print an error body: it could contain sensitive data.
|
||||
raise ShadowClientError(error.code) from None
|
||||
except urllib.error.URLError as error:
|
||||
raise ShadowClientError(503) from error
|
||||
|
||||
|
||||
def run_shadow_client(
|
||||
*,
|
||||
base_url: str,
|
||||
token: str,
|
||||
task_id: str,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Create and read one deterministic Shadow task without executing it."""
|
||||
create_status, _ = _request_json(
|
||||
base_url,
|
||||
"/tasks",
|
||||
token,
|
||||
method="POST",
|
||||
idempotency_key=idempotency_key,
|
||||
payload={
|
||||
"task_id": task_id,
|
||||
"task_type": "platform.shadow",
|
||||
"risk": "low",
|
||||
"approval_policy": "patch-only",
|
||||
"metadata": {"production_side_effects": False},
|
||||
},
|
||||
)
|
||||
task_status, task = _request_json(base_url, f"/tasks/{task_id}", token)
|
||||
events_status, events = _request_json(base_url, f"/tasks/{task_id}/events", token)
|
||||
evidence_status, evidence = _request_json(base_url, f"/tasks/{task_id}/evidence", token)
|
||||
return {
|
||||
"task_id": str(task.get("task_id", task_id)),
|
||||
"status": str(task.get("status", "UNKNOWN")),
|
||||
"event_count": len(events if isinstance(events, list) else events.get("events", [])),
|
||||
"evidence_count": len(evidence if isinstance(evidence, list) else evidence.get("evidence", [])),
|
||||
"http_statuses": {
|
||||
"create": create_status,
|
||||
"task": task_status,
|
||||
"events": events_status,
|
||||
"evidence": evidence_status,
|
||||
},
|
||||
"production_side_effects": False,
|
||||
}
|
||||
|
||||
|
||||
def _reject_plaintext_token_argument(argv: list[str]) -> None:
|
||||
if any(argument == "--token" or argument.startswith("--token=") for argument in argv):
|
||||
raise SystemExit("plaintext token arguments are forbidden")
|
||||
|
||||
|
||||
def _read_token(args: argparse.Namespace) -> str:
|
||||
if args.token_stdin:
|
||||
token = sys.stdin.buffer.read().decode("utf-8").strip()
|
||||
else:
|
||||
token_file = args.token_file or os.environ.get("AIOS_TASK_PLANE_TOKEN_FILE")
|
||||
if not token_file:
|
||||
raise SystemExit("runtime credential reference is required")
|
||||
try:
|
||||
token = Path(token_file).read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
raise SystemExit("runtime credential reference is unavailable") from None
|
||||
if not token:
|
||||
raise SystemExit("runtime credential reference is unavailable")
|
||||
return token
|
||||
|
||||
|
||||
def _write_report(path: Path, summary: dict[str, Any]) -> str:
|
||||
encoded = (json.dumps(summary, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(encoded)
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
_reject_plaintext_token_argument(argv)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:18922")
|
||||
token_group = parser.add_mutually_exclusive_group()
|
||||
token_group.add_argument("--token-file", help="protected runtime credential reference")
|
||||
token_group.add_argument("--token-stdin", action="store_true")
|
||||
parser.add_argument("--task-id", default="AIOS-TASK-PLANE-SHADOW-CLIENT")
|
||||
parser.add_argument("--idempotency-key", default="task-plane-shadow-client-v1")
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
token = _read_token(args)
|
||||
try:
|
||||
summary = run_shadow_client(
|
||||
base_url=args.base_url,
|
||||
token=token,
|
||||
task_id=args.task_id,
|
||||
idempotency_key=args.idempotency_key,
|
||||
)
|
||||
except ShadowClientError as error:
|
||||
print(f"http_status={error.status}", file=sys.stderr)
|
||||
return error.status
|
||||
|
||||
report_sha256 = _write_report(args.report, summary)
|
||||
print(f"task_id={summary['task_id']}")
|
||||
print(f"status={summary['status']}")
|
||||
print(f"event_count={summary['event_count']}")
|
||||
print(f"evidence_count={summary['evidence_count']}")
|
||||
print("http_statuses=" + json.dumps(summary["http_statuses"], sort_keys=True))
|
||||
print(f"report_sha256={report_sha256}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPORT_PATH="${1:-reports/task-plane-acceptance.json}"
|
||||
mkdir -p "$(dirname "${REPORT_PATH}")"
|
||||
export TASKPLANE_REPORT="${REPORT_PATH}"
|
||||
export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/active-runtime"
|
||||
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from pathlib import Path
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
report_path = Path(__import__('os').environ.get("TASKPLANE_REPORT", "reports/task-plane-acceptance.json"))
|
||||
stream = io.StringIO()
|
||||
|
||||
loader = unittest.TestLoader()
|
||||
suite = loader.discover("tests/task_plane/public_interface", pattern="test_*.py")
|
||||
runner = unittest.TextTestRunner(stream=sys.stdout, verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
payload = {
|
||||
"execution_status": "PARTIALLY_COMPLETED" if result.wasSuccessful() else "FAILED",
|
||||
"tests_run": result.testsRun,
|
||||
"failures": len(result.failures),
|
||||
"errors": len(result.errors),
|
||||
"skipped": len(result.skipped),
|
||||
"multi_agent_shadow_verified": result.wasSuccessful(),
|
||||
"production_side_effects": False,
|
||||
"notification_delivery_status": "NOT_DELIVERED",
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"acceptance_report={report_path}")
|
||||
sys.exit(0 if result.wasSuccessful() else 1)
|
||||
PY
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a Gitea credential rotation without printing either credential."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def token_at(path: Path, line: int) -> str:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
if line < 1 or line > len(lines) or not lines[line - 1].strip():
|
||||
raise RuntimeError("credential reference has no configured token line")
|
||||
return lines[line - 1].strip()
|
||||
|
||||
|
||||
def get(api: str, path: str, token: str) -> tuple[int, object | None]:
|
||||
request = urllib.request.Request(
|
||||
api.rstrip("/") + path,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"token {token}",
|
||||
"User-Agent": "aios-gitops-credential-rotation-verifier/1",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--old-token-file", type=Path, required=True)
|
||||
parser.add_argument("--new-credential-file", type=Path, required=True)
|
||||
parser.add_argument("--new-token-line", type=int, default=2)
|
||||
parser.add_argument("--api", default="http://127.0.0.1:3000/api/v1")
|
||||
parser.add_argument("--repository", default="aios/aios")
|
||||
parser.add_argument("--version", default="V2R1C2")
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--remote", default="http://127.0.0.1:3000/aios/aios.git")
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
default="patch/gitea-tag-publication-atomicity-20260723",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
old_token = token_at(args.old_token_file, 1)
|
||||
new_token = token_at(args.new_credential_file, args.new_token_line)
|
||||
repo_path = f"/repos/{args.repository}"
|
||||
old_status, _ = get(args.api, repo_path, old_token)
|
||||
new_status, _ = get(args.api, repo_path, new_token)
|
||||
encoded = urllib.parse.quote(args.version, safe="")
|
||||
ref_status, ref = get(
|
||||
args.api,
|
||||
f"{repo_path}/git/refs/tags/{encoded}",
|
||||
new_token,
|
||||
)
|
||||
listing_status, listing = get(
|
||||
args.api,
|
||||
f"{repo_path}/tags?limit=50&page=1",
|
||||
new_token,
|
||||
)
|
||||
refs = ref if isinstance(ref, list) else [ref]
|
||||
exact = [
|
||||
item
|
||||
for item in refs
|
||||
if isinstance(item, dict)
|
||||
and item.get("ref") in {
|
||||
f"refs/tags/{args.version}",
|
||||
f"tags/{args.version}",
|
||||
}
|
||||
]
|
||||
tag_oid = (
|
||||
str(((exact[0].get("object") or {}).get("sha")) or "").lower()
|
||||
if len(exact) == 1
|
||||
else None
|
||||
)
|
||||
tag_status, tag = (
|
||||
get(args.api, f"{repo_path}/git/tags/{tag_oid}", new_token)
|
||||
if tag_oid
|
||||
else (0, None)
|
||||
)
|
||||
peeled_target = (
|
||||
str(((tag.get("object") or {}).get("sha")) or "").lower()
|
||||
if isinstance(tag, dict)
|
||||
else None
|
||||
)
|
||||
listing_target = None
|
||||
if isinstance(listing, list):
|
||||
for item in listing:
|
||||
if item.get("name") == args.version:
|
||||
listing_target = str(
|
||||
((item.get("commit") or {}).get("sha")) or ""
|
||||
).lower()
|
||||
break
|
||||
remote = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"safe.directory={args.workspace}",
|
||||
"-c",
|
||||
"credential.helper=!/usr/local/libexec/aios-gitea-credential",
|
||||
"-c",
|
||||
"credential.useHttpPath=true",
|
||||
"-C",
|
||||
str(args.workspace),
|
||||
"ls-remote",
|
||||
args.remote,
|
||||
f"refs/heads/{args.branch}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
remote_oid = (
|
||||
remote.stdout.split()[0].lower()
|
||||
if remote.returncode == 0 and remote.stdout.split()
|
||||
else None
|
||||
)
|
||||
result = {
|
||||
"old_token_repository_http": old_status,
|
||||
"new_token_repository_http": new_status,
|
||||
"git_ls_remote_oid": remote_oid,
|
||||
"gitea_ref_http": ref_status,
|
||||
"gitea_tag_oid": tag_oid,
|
||||
"gitea_tag_object_http": tag_status,
|
||||
"gitea_peeled_target_oid": peeled_target,
|
||||
"gitea_listing_http": listing_status,
|
||||
"gitea_listing_target_oid": listing_target,
|
||||
"credentials_printed": False,
|
||||
}
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
passed = (
|
||||
old_status in {401, 403}
|
||||
and new_status == 200
|
||||
and remote_oid is not None
|
||||
and ref_status == 200
|
||||
and tag_oid is not None
|
||||
and tag_status == 200
|
||||
and peeled_target is not None
|
||||
and listing_status == 200
|
||||
and listing_target is not None
|
||||
)
|
||||
return 0 if passed else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"exposure_window": {
|
||||
"detected_at": "2026-07-23T13:40:23+08:00",
|
||||
"revoked_at": "2026-07-23T13:40:56+08:00",
|
||||
"start": "2026-07-23T12:00:00+08:00",
|
||||
"start_accuracy": "bounded approximation; precise platform tool-log timestamp unavailable"
|
||||
},
|
||||
"gitea_tag_read_validation": {
|
||||
"listing_target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
||||
"peeled_target_oid": "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24",
|
||||
"tag_mutation_performed": false,
|
||||
"tag_oid": "a3bd84ebec9c705f7c9c5c03f7f4ec55f8854e36",
|
||||
"version": "V2R1C2"
|
||||
},
|
||||
"impact_scope": "Gitea repository write access for gitops-bot; no evidence of unauthorized use observed during rotation checks",
|
||||
"incident_id": "GITEA-TOKEN-33-EXPOSURE-20260723",
|
||||
"log_handling": {
|
||||
"credential_values_in_record": false,
|
||||
"reason": "formal audit/tool logs must not be deleted",
|
||||
"server_redacted_record_mode": "0640 root:gitops",
|
||||
"source_tool_log_deleted": false
|
||||
},
|
||||
"new_credential_reference": "/etc/aios-gitops/credentials/gitea-git-http",
|
||||
"new_token_validation": {
|
||||
"after_revoke_repository_http": 200,
|
||||
"before_revoke_repository_http": 200,
|
||||
"git_ls_remote_oid": "54093d15437ce0af31acf6feeeffacf6626a59cd",
|
||||
"idempotent_branch_push": true
|
||||
},
|
||||
"old_token_validation": {
|
||||
"after_revoke_repository_http": 401,
|
||||
"before_revoke_repository_http": 200
|
||||
},
|
||||
"production_tag_operation": "NOT_EXECUTED",
|
||||
"replacement_token_record_id": 50,
|
||||
"scope": "write:repository",
|
||||
"token_id": 33
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
umask 077
|
||||
GITEA="${AIOS_GITEA_BINARY:-/data/aios-gitops/runtime/bin/gitea}"
|
||||
CONFIG="${AIOS_GITEA_CONFIG:-/data/aios-gitops/runtime/gitea-bin/custom/conf/app.ini}"
|
||||
WORKSPACE="${AIOS_GITOPS_WORKSPACE:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
USER_NAME="tagatomicitytest$(date +%s)"
|
||||
TMP_DIR="$(mktemp -d /tmp/aios-gitea-live-test.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
"$GITEA" --config "$CONFIG" admin user delete \
|
||||
--username "$USER_NAME" --purge >/dev/null 2>&1 || true
|
||||
rm -f "$TMP_DIR/token" "$TMP_DIR/create-output"
|
||||
rmdir "$TMP_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
"$GITEA" --config "$CONFIG" admin user create \
|
||||
--username "$USER_NAME" \
|
||||
--email "$USER_NAME@example.invalid" \
|
||||
--random-password \
|
||||
--must-change-password=false >"$TMP_DIR/create-output" 2>&1
|
||||
"$GITEA" --config "$CONFIG" admin user generate-access-token \
|
||||
--username "$USER_NAME" \
|
||||
--token-name live-temporary-repository-test \
|
||||
--raw \
|
||||
--scopes write:repository,write:user >"$TMP_DIR/token"
|
||||
chmod 600 "$TMP_DIR/token"
|
||||
|
||||
cd "$WORKSPACE"
|
||||
AIOS_GITOPS_LIVE_GITEA_TEST=1 \
|
||||
AIOS_GITOPS_LIVE_GITEA_OWNER="$USER_NAME" \
|
||||
AIOS_GITOPS_LIVE_GITEA_TOKEN_FILE="$TMP_DIR/token" \
|
||||
AIOS_GITOPS_LIVE_GITEA_TOKEN_LINE=1 \
|
||||
python3 -m pytest -q tests/test_gitea_tag_publication_live.py
|
||||
@@ -0,0 +1,172 @@
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[0]
|
||||
|
||||
|
||||
def _load_client(root: Path):
|
||||
path = root / "scripts" / "task-plane" / "create_shadow_tasks.py"
|
||||
spec = importlib.util.spec_from_file_location("shadow_client", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class ShadowClientRBACPublicInterfaceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.repo_root = Path.cwd()
|
||||
sys.path.insert(0, str(cls.repo_root / "active-runtime"))
|
||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||
from app.task_plane.policy import IdentityVerifier, PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
cls.RequestHandler = TaskPlaneRequestHandler
|
||||
cls.TaskPlaneService = TaskPlaneService
|
||||
cls.IdentityVerifier = IdentityVerifier
|
||||
cls.PolicyEngine = PolicyEngine
|
||||
cls.TaskPlaneStore = TaskPlaneStore
|
||||
cls.client = _load_client(cls.repo_root)
|
||||
|
||||
def _server(self, capabilities):
|
||||
store = self.TaskPlaneStore(":memory:")
|
||||
policy = self.PolicyEngine(
|
||||
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
||||
execution_enabled=False,
|
||||
identity_capabilities={"shadow-client": set(capabilities)},
|
||||
)
|
||||
service = self.TaskPlaneService(store, policy=policy)
|
||||
|
||||
class Handler(self.RequestHandler):
|
||||
pass
|
||||
|
||||
Handler.service = service
|
||||
Handler.authenticator = lambda value: "shadow-client" if value == "test-token" else None
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
self.addCleanup(thread.join, 2)
|
||||
return f"http://127.0.0.1:{server.server_port}", store
|
||||
|
||||
@staticmethod
|
||||
def _request(base, path, token=None, body=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token is not None:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(
|
||||
base + path,
|
||||
data=None if body is None else json.dumps(body).encode(),
|
||||
headers=headers,
|
||||
method="POST" if body is not None else "GET",
|
||||
)
|
||||
return urllib.request.urlopen(req, timeout=3)
|
||||
|
||||
def test_missing_credential_returns_401(self):
|
||||
base, _ = self._server({"task.creator", "task.read", "task.audit"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||
self._request(base, "/tasks")
|
||||
self.assertEqual(raised.exception.code, 401)
|
||||
|
||||
def test_missing_task_creator_returns_403(self):
|
||||
base, _ = self._server({"task.read", "task.audit"})
|
||||
with self.assertRaises(self.client.ShadowClientError) as raised:
|
||||
self.client.run_shadow_client(
|
||||
base_url=base,
|
||||
token="test-token",
|
||||
task_id="SHADOW-RBAC-NO-CREATE",
|
||||
idempotency_key="shadow-rbac-no-create-v1",
|
||||
)
|
||||
self.assertEqual(raised.exception.status, 403)
|
||||
|
||||
def test_minimum_client_is_idempotent_and_cannot_claim(self):
|
||||
base, store = self._server({"task.creator", "task.read", "task.audit"})
|
||||
first = self.client.run_shadow_client(
|
||||
base_url=base,
|
||||
token="test-token",
|
||||
task_id="SHADOW-RBAC-IDEMPOTENT",
|
||||
idempotency_key="shadow-rbac-idempotent-v1",
|
||||
)
|
||||
second = self.client.run_shadow_client(
|
||||
base_url=base,
|
||||
token="test-token",
|
||||
task_id="SHADOW-RBAC-IDEMPOTENT",
|
||||
idempotency_key="shadow-rbac-idempotent-v1",
|
||||
)
|
||||
self.assertEqual(first["task_id"], second["task_id"])
|
||||
self.assertEqual(first["status"], "CREATED")
|
||||
self.assertEqual(first["event_count"], 1)
|
||||
self.assertEqual(first["evidence_count"], 0)
|
||||
self.assertEqual(store.list_workers(), [])
|
||||
claim_base, _ = self._server({"task.claim"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||
self._request(
|
||||
claim_base,
|
||||
"/tasks/SHADOW-RBAC-IDEMPOTENT/claim",
|
||||
"test-token",
|
||||
{"worker_id": "shadow-worker", "lease_seconds": 30},
|
||||
)
|
||||
self.assertEqual(raised.exception.code, 423)
|
||||
|
||||
def test_forged_request_body_identity_is_rejected(self):
|
||||
base, _ = self._server({"task.creator", "task.read", "task.audit"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||
self._request(
|
||||
base,
|
||||
"/tasks",
|
||||
"test-token",
|
||||
{
|
||||
"task_id": "SHADOW-RBAC-FORGED",
|
||||
"actor_identity": "administrator",
|
||||
},
|
||||
)
|
||||
self.assertEqual(raised.exception.code, 400)
|
||||
|
||||
def test_default_deny_get_rbac_and_redacted_report(self):
|
||||
base, _ = self._server({"task.creator", "task.read", "task.audit"})
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
token_file = root / "token"
|
||||
report = root / "report.json"
|
||||
token_file.write_text("test-token", encoding="utf-8")
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
self.client.main(
|
||||
[
|
||||
"--base-url", base,
|
||||
"--token-file", str(token_file),
|
||||
"--task-id", "SHADOW-RBAC-REPORT",
|
||||
"--idempotency-key", "shadow-rbac-report-v1",
|
||||
"--report", str(report),
|
||||
]
|
||||
)
|
||||
output = stdout.getvalue()
|
||||
self.assertNotIn("test-token", output)
|
||||
self.assertNotIn("Authorization", output)
|
||||
self.assertNotIn("test-token", report.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("Authorization", report.read_text(encoding="utf-8"))
|
||||
self.assertIn("report_sha256=", output)
|
||||
|
||||
denied_base, _ = self._server({"task.creator"})
|
||||
with self.assertRaises(urllib.error.HTTPError) as raised:
|
||||
self._request(denied_base, "/tasks", "test-token")
|
||||
self.assertEqual(raised.exception.code, 403)
|
||||
|
||||
def test_plaintext_token_argument_is_rejected_without_echo(self):
|
||||
stderr = io.StringIO()
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
with self.assertRaises(SystemExit):
|
||||
self.client.main(["--token", "sentinel-token"])
|
||||
self.assertNotIn("sentinel-token", stderr.getvalue())
|
||||
@@ -0,0 +1,215 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.getcwd()
|
||||
if os.path.join(ROOT, "active-runtime") not in sys.path:
|
||||
sys.path.append(os.path.join(ROOT, "active-runtime"))
|
||||
|
||||
from app.task_plane.api import TaskPlaneService
|
||||
from app.task_plane.policy import (
|
||||
ALL_ACTION_CAPABILITIES,
|
||||
IdentityVerifier,
|
||||
PolicyEngine,
|
||||
ReadonlyWorkerProfile,
|
||||
)
|
||||
|
||||
|
||||
def _policy(**kwargs):
|
||||
return PolicyEngine(
|
||||
identity_capabilities={"*": set(ALL_ACTION_CAPABILITIES)}, **kwargs
|
||||
)
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
class TaskPlaneAPIBehaviorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = TaskPlaneStore(":memory:")
|
||||
self.service = TaskPlaneService(self.store, policy=_policy(execution_enabled=False))
|
||||
|
||||
def test_task_crud_and_events_chain(self):
|
||||
task = self.service.create_task(
|
||||
{
|
||||
"task_type": "code.change",
|
||||
"intent": "verify core",
|
||||
"required_capabilities": ["code.read", "test.python"],
|
||||
}
|
||||
)
|
||||
self.assertIn("task_id", task)
|
||||
read = self.service.get_task(task["task_id"])
|
||||
self.assertEqual("CREATED", read["status"])
|
||||
events = self.service.get_events(task["task_id"])
|
||||
self.assertEqual(len(events), 1)
|
||||
|
||||
def test_execution_disabled_blocks_claim(self):
|
||||
task = self.service.create_task({"task_type": "code.change"})
|
||||
self.store.register_worker(
|
||||
{
|
||||
"worker_id": "codex-dev-01",
|
||||
"worker_type": "codex",
|
||||
"identity": "spiffe://aios/agent/codex/dev-01",
|
||||
"capabilities": ["code.read", "test.python"],
|
||||
"trust_zone": "isolated-development",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
self.service.claim(task["task_id"], {"worker_id": "codex-dev-01", "lease_seconds": 20})
|
||||
|
||||
def test_workers_and_events_outbox(self):
|
||||
policy = _policy(execution_enabled=True)
|
||||
service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
||||
service.register_worker(
|
||||
{
|
||||
"worker_id": "codex-dev-01",
|
||||
"worker_type": "codex",
|
||||
"identity": "spiffe://aios/agent/codex/dev-01",
|
||||
"capabilities": ["code.read", "test.python", "code.write.patch"],
|
||||
"trust_zone": "isolated-development",
|
||||
"max_concurrency": 2,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
task = service.create_task({"required_capabilities": ["code.read"], "task_type": "code.change"})
|
||||
task_record = service.store.get_task(task["task_id"])
|
||||
identity = "spiffe://aios/agent/codex/dev-01"
|
||||
claim = service.claim(task["task_id"], {"worker_id": "codex-dev-01", "lease_seconds": 15, "actor_identity": identity})
|
||||
service.heartbeat(task["task_id"], {"claim_id": claim["claim_id"], "lease_seconds": 20, "actor_identity": identity})
|
||||
service.progress(task["task_id"], {"claim_id": claim["claim_id"], "progress": 40, "stage": "tests", "message": "running", "actor_identity": identity})
|
||||
service.complete(task["task_id"], {"claim_id": claim["claim_id"], "status": "COMPLETED", "outputs": [], "evidence": [], "actor_identity": identity})
|
||||
events = service.get_events(task["task_id"])
|
||||
self.assertGreater(len(events), 1)
|
||||
self.assertTrue(service.store.verify_event_chain(task["task_id"]))
|
||||
self.assertEqual(service.get_children(task["task_id"]), [])
|
||||
|
||||
|
||||
class ReadonlyCodexWorkerPolicyTests(unittest.TestCase):
|
||||
identity = "spiffe://aios/agent/codex/review-test-01"
|
||||
worker_id = "codex-review-test-01"
|
||||
capabilities = frozenset(
|
||||
{
|
||||
"code.read",
|
||||
"git.diff.read",
|
||||
"code.review",
|
||||
"review.security",
|
||||
"review.api-contract",
|
||||
"test.python",
|
||||
"test.shell.allowlisted",
|
||||
"task.progress",
|
||||
"task.result",
|
||||
"task.evidence.submit",
|
||||
}
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
profile = ReadonlyWorkerProfile(self.worker_id, self.identity, self.capabilities)
|
||||
policy = PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({"anon", self.identity}),
|
||||
execution_enabled=False,
|
||||
readonly_worker_execution_enabled=True,
|
||||
readonly_worker_profiles=[profile],
|
||||
identity_capabilities={
|
||||
"anon": {"task.creator"},
|
||||
self.identity: {"worker.self_register", "task.claim"},
|
||||
},
|
||||
)
|
||||
self.service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
||||
self.registration = {
|
||||
"worker_id": self.worker_id,
|
||||
"worker_type": "codex",
|
||||
"identity": self.identity,
|
||||
"capabilities": sorted(self.capabilities),
|
||||
"trust_zone": "isolated-review",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1", "a2a"],
|
||||
"actor_identity": self.identity,
|
||||
}
|
||||
self.service.register_worker(self.registration)
|
||||
|
||||
def _task(self, *, task_type="code.review", capabilities=None, risk="low"):
|
||||
return self.service.create_task(
|
||||
{
|
||||
"task_type": task_type,
|
||||
"required_capabilities": capabilities or ["code.read", "code.review"],
|
||||
"risk": risk,
|
||||
"metadata": {"production_side_effects": False},
|
||||
}
|
||||
)
|
||||
|
||||
def test_readonly_profile_can_claim_only_safe_task(self):
|
||||
task = self._task()
|
||||
claim = self.service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": self.worker_id, "lease_seconds": 120, "actor_identity": self.identity},
|
||||
)
|
||||
self.assertTrue(claim["claim_id"])
|
||||
|
||||
def test_readonly_profile_rejects_wrong_worker_or_write_task(self):
|
||||
task = self._task()
|
||||
with self.assertRaises(Exception):
|
||||
self.service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": "other-worker", "lease_seconds": 120, "actor_identity": self.identity},
|
||||
)
|
||||
write_task = self._task(task_type="code.change", capabilities=["code.write"])
|
||||
with self.assertRaises(Exception):
|
||||
self.service.claim(
|
||||
write_task["task_id"],
|
||||
{"worker_id": self.worker_id, "lease_seconds": 120, "actor_identity": self.identity},
|
||||
)
|
||||
|
||||
def test_readonly_registration_cannot_escalate_capabilities(self):
|
||||
payload = dict(self.registration)
|
||||
payload["capabilities"] = [*payload["capabilities"], "git.commit"]
|
||||
with self.assertRaises(Exception):
|
||||
self.service.register_worker(payload)
|
||||
|
||||
|
||||
class DelegationGuardTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = TaskPlaneStore(":memory:")
|
||||
self.policy = _policy(execution_enabled=True)
|
||||
self.service = TaskPlaneService(self.store, policy=self.policy)
|
||||
self.parent = self.service.create_task(
|
||||
{
|
||||
"task_type": "code.change",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 3, "tokens": 100, "time_seconds": 1000},
|
||||
"max_delegation_depth": 2,
|
||||
}
|
||||
)
|
||||
|
||||
def test_delegation_budget_and_depth(self):
|
||||
child = self.service.delegate(
|
||||
self.parent["task_id"],
|
||||
{
|
||||
"task_type": "code.change",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 1},
|
||||
"actor_identity": "owner",
|
||||
},
|
||||
)
|
||||
self.assertEqual(child["plan"]["parent_task_id"], self.parent["task_id"])
|
||||
grandchild = self.service.delegate(
|
||||
child["task_id"],
|
||||
{
|
||||
"task_type": "code.change",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 1},
|
||||
"actor_identity": "owner",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
self.service.delegate(
|
||||
grandchild["task_id"],
|
||||
{
|
||||
"task_type": "code.change",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 1},
|
||||
"actor_identity": "owner",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,381 @@
|
||||
import json
|
||||
import http.client
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
from unittest import mock
|
||||
|
||||
from app.task_plane.adapters.codex import CodexAdapter
|
||||
from app.task_plane.adapters.hermes import HermesAdapter
|
||||
from app.task_plane.adapters.openclaw import OpenClawAdapter
|
||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||
from app.task_plane.errors import ConflictError, LockedError, ValidationError
|
||||
from app.task_plane.events import DurableOutboxDispatcher, WeChatGatewayAdapter
|
||||
from app.task_plane.models import ProbeEvidence, ProbeOutcome, TaskPlan
|
||||
from app.task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
IDENTITY = "spiffe://aios/agent/codex/dev-01"
|
||||
|
||||
|
||||
def _policy(**kwargs):
|
||||
return PolicyEngine(
|
||||
identity_capabilities={"*": set(ALL_ACTION_CAPABILITIES)}, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def register_worker(service):
|
||||
return service.register_worker(
|
||||
{
|
||||
"worker_id": "codex-dev-01",
|
||||
"worker_type": "codex",
|
||||
"identity": IDENTITY,
|
||||
"capabilities": ["code.read", "code.write.patch", "test.python"],
|
||||
"trust_zone": "isolated-development",
|
||||
"max_concurrency": 2,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DurableStoreTests(unittest.TestCase):
|
||||
def test_idempotency_replay_and_payload_conflict(self):
|
||||
service = TaskPlaneService(TaskPlaneStore(":memory:"))
|
||||
first = service.create_task(
|
||||
{"task_id": "task-idempotent", "intent": "same"}, "idem-1"
|
||||
)
|
||||
replay = service.create_task(
|
||||
{"task_id": "task-idempotent", "intent": "same"}, "idem-1"
|
||||
)
|
||||
self.assertEqual(first["task_id"], replay["task_id"])
|
||||
with self.assertRaises(ConflictError):
|
||||
service.create_task(
|
||||
{"task_id": "task-other", "intent": "different"}, "idem-1"
|
||||
)
|
||||
|
||||
def test_cas_rejects_stale_revision(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
task = store.create_task(TaskPlan.from_dict({"task_id": "task-cas"}))
|
||||
store.update_task_status(task.task_id, task.revision, "QUEUED")
|
||||
with self.assertRaises(ConflictError):
|
||||
store.update_task_status(task.task_id, task.revision, "CANCELLED")
|
||||
|
||||
def test_crash_recovery_preserves_task_events_and_outbox(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "task-plane.sqlite3")
|
||||
first = TaskPlaneService(TaskPlaneStore(path))
|
||||
task = first.create_task({"task_id": "task-restart"})
|
||||
first.store.close()
|
||||
second = TaskPlaneService(TaskPlaneStore(path))
|
||||
self.assertEqual("CREATED", second.get_task(task["task_id"])["status"])
|
||||
self.assertTrue(second.store.verify_event_chain(task["task_id"]))
|
||||
self.assertEqual(1, len(second.store.list_outbox()))
|
||||
second.store.close()
|
||||
|
||||
def test_event_and_evidence_tamper_are_detected(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(store)
|
||||
task = service.create_task({"task_id": "task-tamper"})
|
||||
evidence_id = store.add_evidence(
|
||||
task["task_id"], "test-report", {"passed": True}, IDENTITY
|
||||
)
|
||||
store.conn.execute(
|
||||
"UPDATE events SET event_data=? WHERE task_id=?",
|
||||
(json.dumps({"changed": True}), task["task_id"]),
|
||||
)
|
||||
store.conn.execute(
|
||||
"UPDATE evidence SET payload=? WHERE evidence_id=?",
|
||||
(json.dumps({"passed": False}), evidence_id),
|
||||
)
|
||||
self.assertFalse(store.verify_event_chain(task["task_id"]))
|
||||
self.assertFalse(store.verify_evidence_integrity(task["task_id"]))
|
||||
|
||||
def test_secrets_are_redacted_at_rest_and_in_outbox(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(store)
|
||||
secret = "never-store-this-value"
|
||||
task = service.create_task(
|
||||
{
|
||||
"task_id": "task-redaction",
|
||||
"metadata": {"access_token": secret, "token_count": 12},
|
||||
}
|
||||
)
|
||||
raw_task = store.conn.execute(
|
||||
"SELECT task_json FROM tasks WHERE task_id=?", (task["task_id"],)
|
||||
).fetchone()[0]
|
||||
raw_outbox = store.conn.execute(
|
||||
"SELECT payload FROM outbox WHERE task_id=?", (task["task_id"],)
|
||||
).fetchone()[0]
|
||||
self.assertNotIn(secret, raw_task + raw_outbox)
|
||||
self.assertIn("[REDACTED]", raw_task)
|
||||
self.assertIn("token_count", raw_task)
|
||||
|
||||
|
||||
class ClaimLeaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.store = TaskPlaneStore(":memory:")
|
||||
self.service = TaskPlaneService(
|
||||
self.store, policy=_policy(execution_enabled=True)
|
||||
)
|
||||
register_worker(self.service)
|
||||
|
||||
def test_only_one_worker_can_claim_concurrently(self):
|
||||
task = self.service.create_task({"task_id": "task-race"})
|
||||
revision = self.store.get_task(task["task_id"]).revision
|
||||
outcomes = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def attempt():
|
||||
barrier.wait()
|
||||
try:
|
||||
self.store.claim_task(
|
||||
task["task_id"],
|
||||
"codex-dev-01",
|
||||
IDENTITY,
|
||||
30,
|
||||
revision,
|
||||
self.store.get_task(task["task_id"]).task_plan.operation_digest(),
|
||||
)
|
||||
outcomes.append("claimed")
|
||||
except ConflictError:
|
||||
outcomes.append("conflict")
|
||||
|
||||
threads = [threading.Thread(target=attempt) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertCountEqual(["claimed", "conflict"], outcomes)
|
||||
|
||||
def test_expired_lease_requires_probe_before_reassignment(self):
|
||||
task = self.service.create_task({"task_id": "task-expiry"})
|
||||
claim = self.service.claim(
|
||||
task["task_id"],
|
||||
{
|
||||
"worker_id": "codex-dev-01",
|
||||
"actor_identity": IDENTITY,
|
||||
"lease_seconds": 1,
|
||||
},
|
||||
)
|
||||
future = time.time() + 5
|
||||
with mock.patch("app.task_plane.store._now", return_value=future):
|
||||
self.store.probe_attestation_verifier = lambda probe: (
|
||||
probe.attestation_ref == "trusted://probe/recovery"
|
||||
)
|
||||
probe = ProbeEvidence.create(
|
||||
task_id=task["task_id"],
|
||||
claim_id=claim["claim_id"],
|
||||
worker_id="codex-dev-01",
|
||||
worker_identity=IDENTITY,
|
||||
operation_digest=claim["operation_digest"],
|
||||
outcome=ProbeOutcome.NOT_APPLIED,
|
||||
observed_at=future,
|
||||
expires_at=future + 60,
|
||||
probe_executor_identity="spiffe://aios/admin/recovery",
|
||||
attestation_ref="trusted://probe/recovery",
|
||||
)
|
||||
recovered = self.store.recover_claim_with_probe_and_event(
|
||||
task["task_id"],
|
||||
claim["claim_id"],
|
||||
probe,
|
||||
)
|
||||
self.assertEqual("FAILED_RECOVERABLE", recovered.status)
|
||||
retry = self.service.claim(
|
||||
task["task_id"],
|
||||
{
|
||||
"worker_id": "codex-dev-01",
|
||||
"actor_identity": IDENTITY,
|
||||
"lease_seconds": 30,
|
||||
},
|
||||
)
|
||||
self.assertEqual(2, retry["attempt"])
|
||||
|
||||
def test_wrong_claim_identity_and_duplicate_result_are_rejected(self):
|
||||
task = self.service.create_task({"task_id": "task-result"})
|
||||
claim = self.service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": "codex-dev-01", "actor_identity": IDENTITY},
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
self.service.complete(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": "spiffe://aios/agent/codex/other",
|
||||
},
|
||||
)
|
||||
self.service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": IDENTITY,
|
||||
"progress": 10,
|
||||
"stage": "running",
|
||||
},
|
||||
)
|
||||
self.service.complete(
|
||||
task["task_id"],
|
||||
{"claim_id": claim["claim_id"], "actor_identity": IDENTITY},
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
self.service.complete(
|
||||
task["task_id"],
|
||||
{"claim_id": claim["claim_id"], "actor_identity": IDENTITY},
|
||||
)
|
||||
|
||||
def test_tampered_result_digest_and_unverifiable_signature_fail_closed(self):
|
||||
for suffix, extra in (
|
||||
("digest", {"result_digest": "0" * 64}),
|
||||
("signature", {"signature": "tampered"}),
|
||||
):
|
||||
task = self.service.create_task({"task_id": f"task-{suffix}"})
|
||||
claim = self.service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": "codex-dev-01", "actor_identity": IDENTITY},
|
||||
)
|
||||
self.service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": IDENTITY,
|
||||
"progress": 10,
|
||||
"stage": "running",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
self.service.complete(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": IDENTITY,
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
"ACTIVE", self.store.get_claim(claim["claim_id"]).status
|
||||
)
|
||||
|
||||
|
||||
class DelegationAndNotificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.service = TaskPlaneService(
|
||||
TaskPlaneStore(":memory:"),
|
||||
policy=_policy(execution_enabled=True, max_risk="high"),
|
||||
)
|
||||
self.parent = self.service.create_task(
|
||||
{
|
||||
"task_id": "task-parent",
|
||||
"task_type": "code.review",
|
||||
"required_capabilities": ["code.read"],
|
||||
"risk": "medium",
|
||||
"approval_policy": "patch-only",
|
||||
"max_delegation_depth": 1,
|
||||
"budgets": {"attempts": 1, "tokens": 10},
|
||||
}
|
||||
)
|
||||
|
||||
def test_capability_risk_budget_and_cycle_escalation_are_rejected(self):
|
||||
base = {
|
||||
"actor_identity": "owner",
|
||||
"task_type": "code.review",
|
||||
"budgets": {"attempts": 1},
|
||||
}
|
||||
for payload in (
|
||||
{**base, "required_capabilities": ["git.force_push"]},
|
||||
{**base, "required_capabilities": ["code.read"], "risk": "high"},
|
||||
{**base, "required_capabilities": ["code.read"], "budgets": {"tokens": 11}},
|
||||
{
|
||||
**base,
|
||||
"task_id": "task-parent",
|
||||
"required_capabilities": ["code.read"],
|
||||
},
|
||||
):
|
||||
with self.assertRaises(Exception):
|
||||
self.service.delegate(self.parent["task_id"], payload)
|
||||
|
||||
def test_missing_gateway_receipt_remains_not_delivered(self):
|
||||
message = self.service.store.list_outbox()[0]
|
||||
dispatcher = DurableOutboxDispatcher(self.service.store)
|
||||
updated = dispatcher.dispatch(
|
||||
message["message_id"], WeChatGatewayAdapter(lambda envelope: None)
|
||||
)
|
||||
self.assertEqual("NOT_DELIVERED", updated["delivery_status"])
|
||||
delivered = self.service.store.record_outbox_delivery(
|
||||
message["message_id"], receipt="gateway-receipt-1"
|
||||
)
|
||||
replay = self.service.store.record_outbox_delivery(
|
||||
message["message_id"], receipt="gateway-receipt-1"
|
||||
)
|
||||
self.assertEqual("DELIVERED", delivered["delivery_status"])
|
||||
self.assertEqual(delivered["receipt"], replay["receipt"])
|
||||
|
||||
def test_worker_offline_detection_is_fail_closed(self):
|
||||
register_worker(self.service)
|
||||
worker = self.service.store.get_worker("codex-dev-01")
|
||||
after_deadline = (
|
||||
worker.last_heartbeat + worker.heartbeat_interval_seconds * 3 + 1
|
||||
)
|
||||
self.assertFalse(
|
||||
self.service.workers.is_online("codex-dev-01", now=after_deadline)
|
||||
)
|
||||
|
||||
def test_path_and_parameter_injection_are_rejected(self):
|
||||
for value in ("../task", "task/child", "%2e%2e", "task\ninjected", ""):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.service.store.get_task(value)
|
||||
|
||||
def test_shadow_adapters_only_return_descriptions(self):
|
||||
task = TaskPlan.from_dict({"task_id": "task-shadow"})
|
||||
claim = type("Claim", (), {"claim_id": "claim-shadow", "status": "ACTIVE"})()
|
||||
for adapter in (OpenClawAdapter(), CodexAdapter(), HermesAdapter()):
|
||||
result = adapter.execute(task, claim)
|
||||
self.assertEqual("SHADOW_VERIFIED", result["status"])
|
||||
self.assertFalse(result["production_side_effects"])
|
||||
self.assertNotIn("command", result)
|
||||
|
||||
def test_http_rejects_encoded_path_and_non_object_json(self):
|
||||
TaskPlaneRequestHandler.service = self.service
|
||||
TaskPlaneRequestHandler.authenticator = None
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), TaskPlaneRequestHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", server.server_address[1], timeout=3
|
||||
)
|
||||
connection.request("GET", "/tasks/%2e%2e")
|
||||
response = connection.getresponse()
|
||||
payload = response.read().decode()
|
||||
self.assertEqual(400, response.status)
|
||||
self.assertNotIn("Traceback", payload)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/tasks",
|
||||
body="[]",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
self.assertEqual(400, response.status)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/tasks",
|
||||
body=json.dumps({"task_id": "task-http-unauthorized"}),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
self.assertEqual(401, response.status)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,892 @@
|
||||
import dataclasses
|
||||
import http.client
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||
from app.task_plane.errors import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
LockedError,
|
||||
ValidationError,
|
||||
)
|
||||
from app.task_plane.models import (
|
||||
EvidenceEnvelope,
|
||||
ProbeEvidence,
|
||||
ProbeOutcome,
|
||||
TaskPlan,
|
||||
)
|
||||
from app.task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from app.task_plane.scheduler import TaskScheduler
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
WORKER_ID = "codex-secure-01"
|
||||
WORKER_IDENTITY = "spiffe://aios/agent/codex/secure-01"
|
||||
ADMIN_IDENTITY = "spiffe://aios/admin/task-plane"
|
||||
|
||||
|
||||
def _worker_payload(**overrides):
|
||||
payload = {
|
||||
"worker_id": WORKER_ID,
|
||||
"worker_type": "codex",
|
||||
"identity": WORKER_IDENTITY,
|
||||
"owner_identity": WORKER_IDENTITY,
|
||||
"capabilities": ["code.read", "test.python"],
|
||||
"trust_zone": "isolated-development",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
"registration_policy": "self",
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _service(store=None, **policy_kwargs):
|
||||
grants = {
|
||||
ADMIN_IDENTITY: {
|
||||
"task.creator",
|
||||
"task.read",
|
||||
"task.audit",
|
||||
"task.cancel",
|
||||
"task.delegate",
|
||||
"task.claim",
|
||||
"worker.admin",
|
||||
"worker.self_register",
|
||||
},
|
||||
WORKER_IDENTITY: {
|
||||
"task.read",
|
||||
"task.claim",
|
||||
"worker.self_register",
|
||||
},
|
||||
}
|
||||
return TaskPlaneService(
|
||||
store or TaskPlaneStore(":memory:"),
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
identity_capabilities=grants,
|
||||
admin_identities={ADMIN_IDENTITY},
|
||||
**policy_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _claim_for_service(service, *, task_id="task-secure", plan=None):
|
||||
plan = plan or {
|
||||
"task_id": task_id,
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
"risk": "low",
|
||||
}
|
||||
service.register_worker(
|
||||
_worker_payload(
|
||||
capabilities=sorted(
|
||||
{"code.read", "test.python", *plan.get("required_capabilities", [])}
|
||||
)
|
||||
)
|
||||
)
|
||||
task = service.create_task(plan)
|
||||
claim = service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": WORKER_ID, "actor_identity": WORKER_IDENTITY},
|
||||
)
|
||||
return task, claim
|
||||
|
||||
|
||||
def _process_claim(db_path, task_id, expected_revision, digest, queue):
|
||||
store = TaskPlaneStore(db_path)
|
||||
try:
|
||||
store.claim_task(
|
||||
task_id,
|
||||
WORKER_ID,
|
||||
WORKER_IDENTITY,
|
||||
30,
|
||||
expected_revision,
|
||||
digest,
|
||||
)
|
||||
queue.put("claimed")
|
||||
except Exception as exc:
|
||||
queue.put(type(exc).__name__)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class EvidenceEnvelopeContractTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.base = EvidenceEnvelope.create(
|
||||
task_id="task-evidence",
|
||||
claim_id="claim-evidence",
|
||||
kind="test-report",
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
attestation_ref="trusted://shadow/test-report",
|
||||
)
|
||||
|
||||
def test_digest_binds_complete_envelope_and_order(self):
|
||||
self.assertTrue(
|
||||
self.base.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
mutations = {
|
||||
"kind": "secret-scan",
|
||||
"worker_identity": "spiffe://attacker",
|
||||
"task_id": "task-other",
|
||||
"claim_id": "claim-other",
|
||||
"created_at": self.base.created_at + 1,
|
||||
"payload": {"passed": False},
|
||||
"previous_evidence_hash": "f" * 64,
|
||||
}
|
||||
for field, value in mutations.items():
|
||||
with self.subTest(field=field):
|
||||
self.assertFalse(
|
||||
dataclasses.replace(self.base, **{field: value}).verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
|
||||
def test_store_rejects_cross_task_required_evidence_and_chain_tamper(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
store.create_task_with_event(
|
||||
TaskPlan.from_dict({"task_id": "task-evidence"})
|
||||
)
|
||||
store.add_evidence_envelope(self.base)
|
||||
self.assertTrue(store.verify_evidence_integrity("task-evidence"))
|
||||
store.conn.execute(
|
||||
"UPDATE evidence SET envelope_json=json_set(envelope_json,'$.task_id','task-other')"
|
||||
)
|
||||
self.assertFalse(store.verify_evidence_integrity("task-evidence"))
|
||||
|
||||
|
||||
class AtomicMutationContractTests(unittest.TestCase):
|
||||
def _fault(self, point):
|
||||
def inject(observed):
|
||||
if observed == point:
|
||||
raise RuntimeError("injected fault")
|
||||
|
||||
return inject
|
||||
|
||||
def test_create_cancel_and_delegate_roll_back_at_event_boundaries(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
store.fault_injector = self._fault("create.after_task_before_event")
|
||||
with self.assertRaises(RuntimeError):
|
||||
store.create_task_with_event(TaskPlan.from_dict({"task_id": "task-create"}))
|
||||
self.assertEqual([], store.list_tasks())
|
||||
|
||||
store.fault_injector = None
|
||||
parent = store.create_task_with_event(
|
||||
TaskPlan.from_dict(
|
||||
{
|
||||
"task_id": "task-parent-atomic",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 2},
|
||||
}
|
||||
)
|
||||
)
|
||||
store.fault_injector = self._fault("cancel.after_state_before_event")
|
||||
with self.assertRaises(RuntimeError):
|
||||
store.cancel_task_with_event(parent.task_id, parent.revision, "stop")
|
||||
self.assertEqual("CREATED", store.get_task(parent.task_id).status)
|
||||
|
||||
store.fault_injector = self._fault("delegate.after_budget_before_child")
|
||||
with self.assertRaises(RuntimeError):
|
||||
store.delegate_task_with_budget_and_events(
|
||||
parent.task_id,
|
||||
TaskPlan.from_dict(
|
||||
{
|
||||
"task_id": "task-child-atomic",
|
||||
"required_capabilities": ["code.read"],
|
||||
"budgets": {"attempts": 1},
|
||||
}
|
||||
),
|
||||
caller=ADMIN_IDENTITY,
|
||||
)
|
||||
self.assertEqual(2, store.get_task_budget(parent.task_id).attempts)
|
||||
self.assertEqual([], store.list_children(parent.task_id))
|
||||
|
||||
def test_complete_fail_and_recover_roll_back_wholly(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-complete-atomic")
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"progress": 10,
|
||||
"stage": "running",
|
||||
},
|
||||
)
|
||||
service.store.fault_injector = self._fault(
|
||||
"complete.after_evidence_before_state"
|
||||
)
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task["task_id"],
|
||||
claim_id=claim["claim_id"],
|
||||
kind="test-report",
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
attestation_ref="trusted://shadow/test",
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.store.complete_claim_with_evidence_and_event(
|
||||
task["task_id"],
|
||||
claim["claim_id"],
|
||||
"COMPLETED",
|
||||
{"status": "COMPLETED"},
|
||||
[envelope],
|
||||
)
|
||||
self.assertEqual([], service.store.list_evidence(task["task_id"]))
|
||||
self.assertEqual("RUNNING", service.store.get_task(task["task_id"]).status)
|
||||
|
||||
def test_fail_rolls_back_claim_state_and_event(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-fail-atomic")
|
||||
service.store.fault_injector = self._fault("fail.after_state_before_commit")
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.store.fail_claim_with_event(
|
||||
task["task_id"], claim["claim_id"], "FAILED_FINAL", "injected"
|
||||
)
|
||||
self.assertEqual("ACTIVE", service.store.get_claim(claim["claim_id"]).status)
|
||||
self.assertEqual("CLAIMED", service.store.get_task(task["task_id"]).status)
|
||||
self.assertNotIn(
|
||||
"TASK_FAILED",
|
||||
{
|
||||
event["type"]
|
||||
for event in service.store.list_events(task["task_id"])
|
||||
},
|
||||
)
|
||||
|
||||
def test_recovery_rolls_back_probe_state_and_event(self):
|
||||
service = _service(
|
||||
probe_attestation_verifier=lambda probe: probe.attestation_ref
|
||||
== "trusted://probe/atomic"
|
||||
)
|
||||
task, claim = _claim_for_service(service, task_id="task-recover-atomic")
|
||||
service.store.conn.execute(
|
||||
"UPDATE claims SET lease_until=? WHERE claim_id=?",
|
||||
(time.time() - 10, claim["claim_id"]),
|
||||
)
|
||||
probe = ProbeEvidence.create(
|
||||
task_id=task["task_id"],
|
||||
claim_id=claim["claim_id"],
|
||||
worker_id=WORKER_ID,
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
operation_digest=claim["operation_digest"],
|
||||
outcome=ProbeOutcome.NOT_APPLIED,
|
||||
observed_at=time.time(),
|
||||
expires_at=time.time() + 60,
|
||||
probe_executor_identity=ADMIN_IDENTITY,
|
||||
attestation_ref="trusted://probe/atomic",
|
||||
)
|
||||
service.store.fault_injector = self._fault(
|
||||
"recover.after_probe_before_state"
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.store.recover_claim_with_probe_and_event(
|
||||
task["task_id"], claim["claim_id"], probe
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
service.store.conn.execute(
|
||||
"SELECT COUNT(*) FROM probe_evidence WHERE probe_id=?",
|
||||
(probe.probe_id,),
|
||||
).fetchone()[0],
|
||||
)
|
||||
self.assertEqual("ACTIVE", service.store.get_claim(claim["claim_id"]).status)
|
||||
self.assertEqual("CLAIMED", service.store.get_task(task["task_id"]).status)
|
||||
|
||||
|
||||
class WorkerOwnershipContractTests(unittest.TestCase):
|
||||
def test_identity_takeover_escalation_and_disabled_reregistration_rejected(self):
|
||||
service = _service()
|
||||
registered = service.register_worker(_worker_payload())
|
||||
revision = registered["registration_revision"]
|
||||
with self.assertRaises(ConflictError):
|
||||
service.register_worker(
|
||||
_worker_payload(
|
||||
identity="spiffe://aios/attacker",
|
||||
owner_identity="spiffe://aios/attacker",
|
||||
actor_identity="spiffe://aios/attacker",
|
||||
expected_revision=revision,
|
||||
)
|
||||
)
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.register_worker(
|
||||
_worker_payload(
|
||||
capabilities=["code.read", "deploy.production"],
|
||||
expected_revision=revision,
|
||||
)
|
||||
)
|
||||
disabled = service.disable_worker(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": ADMIN_IDENTITY,
|
||||
"expected_revision": revision,
|
||||
"reason": "security review",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(LockedError):
|
||||
service.register_worker(
|
||||
_worker_payload(expected_revision=disabled["registration_revision"])
|
||||
)
|
||||
|
||||
def test_worker_cannot_heartbeat_other_identity_and_all_updates_use_cas(self):
|
||||
service = _service()
|
||||
worker = service.register_worker(_worker_payload())
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.worker_heartbeat(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": "spiffe://aios/attacker",
|
||||
"expected_revision": worker["registration_revision"],
|
||||
},
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
service.worker_heartbeat(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"expected_revision": worker["registration_revision"] + 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class RecoveryProbeContractTests(unittest.TestCase):
|
||||
def _expired_claim(self, suffix):
|
||||
service = _service(
|
||||
probe_attestation_verifier=lambda probe: probe.attestation_ref
|
||||
== "trusted://probe/test"
|
||||
)
|
||||
task, claim = _claim_for_service(service, task_id=f"task-probe-{suffix}")
|
||||
service.store.conn.execute(
|
||||
"UPDATE claims SET lease_until=? WHERE claim_id=?",
|
||||
(time.time() - 10, claim["claim_id"]),
|
||||
)
|
||||
return service, task, claim
|
||||
|
||||
def _probe(self, task, claim, outcome, **overrides):
|
||||
data = {
|
||||
"task_id": task["task_id"],
|
||||
"claim_id": claim["claim_id"],
|
||||
"worker_id": WORKER_ID,
|
||||
"worker_identity": WORKER_IDENTITY,
|
||||
"operation_digest": claim["operation_digest"],
|
||||
"outcome": outcome,
|
||||
"observed_at": time.time(),
|
||||
"expires_at": time.time() + 60,
|
||||
"probe_executor_identity": ADMIN_IDENTITY,
|
||||
"attestation_ref": "trusted://probe/test",
|
||||
}
|
||||
data.update(overrides)
|
||||
return ProbeEvidence.create(**data)
|
||||
|
||||
def test_four_probe_outcomes_have_distinct_fail_closed_transitions(self):
|
||||
expected = {
|
||||
ProbeOutcome.NOT_APPLIED: "FAILED_RECOVERABLE",
|
||||
ProbeOutcome.APPLIED_VERIFIED: "COMPLETED",
|
||||
ProbeOutcome.INDETERMINATE: "FAILED_RECOVERABLE",
|
||||
ProbeOutcome.CONFLICT: "MANUAL_REVIEW_REQUIRED",
|
||||
}
|
||||
for outcome, status in expected.items():
|
||||
with self.subTest(outcome=outcome):
|
||||
service, task, claim = self._expired_claim(outcome.value.lower())
|
||||
recovered = service.store.recover_claim_with_probe_and_event(
|
||||
task["task_id"],
|
||||
claim["claim_id"],
|
||||
self._probe(task, claim, outcome),
|
||||
)
|
||||
self.assertEqual(status, recovered.status)
|
||||
if outcome == ProbeOutcome.INDETERMINATE:
|
||||
self.assertFalse(
|
||||
service.store.task_has_retryable_probe(task["task_id"])
|
||||
)
|
||||
|
||||
def test_forged_expired_or_mismatched_probe_is_rejected(self):
|
||||
service, task, claim = self._expired_claim("invalid")
|
||||
valid = self._probe(task, claim, ProbeOutcome.NOT_APPLIED)
|
||||
for probe in (
|
||||
dataclasses.replace(valid, evidence_digest="0" * 64),
|
||||
self._probe(
|
||||
task,
|
||||
claim,
|
||||
ProbeOutcome.NOT_APPLIED,
|
||||
expires_at=time.time() - 1,
|
||||
),
|
||||
self._probe(
|
||||
task,
|
||||
claim,
|
||||
ProbeOutcome.NOT_APPLIED,
|
||||
operation_digest="f" * 64,
|
||||
),
|
||||
):
|
||||
with self.assertRaises((ForbiddenError, ConflictError, LockedError)):
|
||||
service.store.recover_claim_with_probe_and_event(
|
||||
task["task_id"], claim["claim_id"], probe
|
||||
)
|
||||
|
||||
|
||||
class SchedulerAndClassificationTests(unittest.TestCase):
|
||||
def test_scheduler_skips_incompatible_and_dependency_blocked_tasks(self):
|
||||
service = _service()
|
||||
service.register_worker(_worker_payload())
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-incompatible",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["gpu.train"],
|
||||
}
|
||||
)
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-dependency",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
}
|
||||
)
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-blocked",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
"dependencies": ["task-dependency"],
|
||||
}
|
||||
)
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-eligible",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
"metadata": {"priority": 100},
|
||||
}
|
||||
)
|
||||
claim = TaskScheduler(service.store).schedule_for_worker(WORKER_ID)
|
||||
self.assertEqual("task-eligible", claim.task_id)
|
||||
self.assertEqual(WORKER_IDENTITY, claim.worker_identity)
|
||||
|
||||
def test_credential_secret_rejects_generic_worker_and_redacts_free_text(self):
|
||||
service = _service()
|
||||
service.register_worker(_worker_payload())
|
||||
task = service.create_task(
|
||||
{
|
||||
"task_id": "task-credential-secret",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["secret.transform"],
|
||||
"data_classification": "credential-secret",
|
||||
"execution_zone": "secure-file-transformer",
|
||||
"network_policy": "denied",
|
||||
"logging_policy": "metadata-only",
|
||||
"output_policy": "hash-count-status-only",
|
||||
}
|
||||
)
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": WORKER_ID, "actor_identity": WORKER_IDENTITY},
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
TaskPlan.from_dict(
|
||||
{
|
||||
"task_id": "task-secret-leak",
|
||||
"intent": "Authorization: Bearer super-secret-value",
|
||||
"data_classification": "credential-secret",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ReadRBACAndResultProofTests(unittest.TestCase):
|
||||
def test_get_evidence_requires_authentication(self):
|
||||
service = _service()
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-http-read",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
}
|
||||
)
|
||||
TaskPlaneRequestHandler.service = service
|
||||
TaskPlaneRequestHandler.authenticator = lambda token: (
|
||||
ADMIN_IDENTITY if token == "valid" else None
|
||||
)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), TaskPlaneRequestHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
connection = http.client.HTTPConnection(
|
||||
"127.0.0.1", server.server_address[1], timeout=3
|
||||
)
|
||||
connection.request("GET", "/tasks/task-http-read/evidence")
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
self.assertEqual(401, response.status)
|
||||
connection.request(
|
||||
"GET",
|
||||
"/tasks/task-http-read/evidence",
|
||||
headers={"Authorization": "Bearer valid"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
self.assertEqual(200, response.status)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
def test_sensitive_result_requires_worker_proof_but_low_shadow_is_marked(self):
|
||||
service = _service(result_signature_verifier=lambda result: False)
|
||||
task, claim = _claim_for_service(
|
||||
service,
|
||||
task_id="task-write-proof",
|
||||
plan={
|
||||
"task_id": "task-write-proof",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read", "code.write.patch"],
|
||||
"risk": "medium",
|
||||
},
|
||||
)
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"progress": 100,
|
||||
"stage": "done",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.complete(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"signature": "invalid",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SQLiteAndEventIntegrityTests(unittest.TestCase):
|
||||
def test_event_hash_binds_metadata(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
task = store.create_task_with_event(
|
||||
TaskPlan.from_dict({"task_id": "task-event-metadata"})
|
||||
)
|
||||
self.assertTrue(store.verify_event_chain(task.task_id))
|
||||
for column, value in (
|
||||
("event_id", "evt-forged"),
|
||||
("subject", "task/other"),
|
||||
("created_at", 1.0),
|
||||
):
|
||||
with self.subTest(column=column):
|
||||
original = dict(
|
||||
store.conn.execute(
|
||||
"SELECT * FROM events WHERE task_id=?", (task.task_id,)
|
||||
).fetchone()
|
||||
)
|
||||
store.conn.execute(
|
||||
f"UPDATE events SET {column}=? WHERE task_id=?",
|
||||
(value, task.task_id),
|
||||
)
|
||||
self.assertFalse(store.verify_event_chain(task.task_id))
|
||||
store.conn.execute(
|
||||
f"UPDATE events SET {column}=? WHERE task_id=?",
|
||||
(original[column], task.task_id),
|
||||
)
|
||||
|
||||
def test_sqlite_high_assurance_schema_integrity_checkpoint_and_backup(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "task-plane.sqlite3")
|
||||
store = TaskPlaneStore(path, high_assurance=True)
|
||||
self.assertGreaterEqual(store.schema_version(), 2)
|
||||
self.assertEqual(5000, store.conn.execute("PRAGMA busy_timeout").fetchone()[0])
|
||||
self.assertEqual(2, store.conn.execute("PRAGMA synchronous").fetchone()[0])
|
||||
self.assertEqual("ok", store.integrity_check())
|
||||
store.create_task_with_event(
|
||||
TaskPlan.from_dict({"task_id": "task-backup"})
|
||||
)
|
||||
backup = os.path.join(directory, "backup.sqlite3")
|
||||
store.checkpoint_and_backup(backup)
|
||||
self.assertTrue(os.path.exists(backup))
|
||||
store.close()
|
||||
|
||||
def test_multi_process_claim_race_has_single_winner(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "task-plane.sqlite3")
|
||||
store = TaskPlaneStore(path)
|
||||
service = _service(store)
|
||||
service.register_worker(_worker_payload(max_concurrency=2))
|
||||
task = service.create_task(
|
||||
{
|
||||
"task_id": "task-multiprocess",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
}
|
||||
)
|
||||
record = store.get_task(task["task_id"])
|
||||
queue = multiprocessing.Queue()
|
||||
processes = [
|
||||
multiprocessing.Process(
|
||||
target=_process_claim,
|
||||
args=(
|
||||
path,
|
||||
task["task_id"],
|
||||
record.revision,
|
||||
record.task_plan.operation_digest(),
|
||||
queue,
|
||||
),
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
for process in processes:
|
||||
process.start()
|
||||
for process in processes:
|
||||
process.join(10)
|
||||
outcomes = [queue.get(timeout=3) for _ in processes]
|
||||
self.assertEqual(1, outcomes.count("claimed"))
|
||||
store.close()
|
||||
|
||||
|
||||
class ExplicitSecurityMatrixTests(unittest.TestCase):
|
||||
def test_evidence_kind_tamper_is_rejected(self):
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id="task-kind",
|
||||
claim_id="claim-kind",
|
||||
kind="test-report",
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
)
|
||||
self.assertFalse(
|
||||
dataclasses.replace(envelope, kind="forged").verify(
|
||||
expected_task_id="task-kind",
|
||||
expected_claim_id="claim-kind",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
|
||||
def test_worker_admin_restore_is_cas_and_journaled(self):
|
||||
service = _service()
|
||||
worker = service.register_worker(_worker_payload())
|
||||
disabled = service.disable_worker(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": ADMIN_IDENTITY,
|
||||
"expected_revision": worker["registration_revision"],
|
||||
"reason": "test",
|
||||
},
|
||||
)
|
||||
restored = service.restore_worker(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": ADMIN_IDENTITY,
|
||||
"expected_revision": disabled["registration_revision"],
|
||||
},
|
||||
)
|
||||
self.assertFalse(restored["disabled"])
|
||||
events = service.store.list_events("task-worker-registry")
|
||||
self.assertIn("WORKER_RESTORED", {event["type"] for event in events})
|
||||
|
||||
def test_disabled_worker_heartbeat_is_rejected(self):
|
||||
service = _service()
|
||||
worker = service.register_worker(_worker_payload())
|
||||
disabled = service.disable_worker(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": ADMIN_IDENTITY,
|
||||
"expected_revision": worker["registration_revision"],
|
||||
"reason": "test",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(LockedError):
|
||||
service.worker_heartbeat(
|
||||
WORKER_ID,
|
||||
{
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"expected_revision": disabled["registration_revision"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_low_risk_unsigned_result_is_explicitly_untrusted(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-low-shadow")
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"progress": 100,
|
||||
"stage": "done",
|
||||
},
|
||||
)
|
||||
result = service.complete(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
},
|
||||
)
|
||||
self.assertEqual("untrusted_shadow_result", result["trust_status"])
|
||||
|
||||
def test_boolean_recovery_contract_is_rejected(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-no-bool-probe")
|
||||
service.store.conn.execute(
|
||||
"UPDATE claims SET lease_until=? WHERE claim_id=?",
|
||||
(time.time() - 10, claim["claim_id"]),
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.store.recover_expired_claim(
|
||||
task["task_id"], claim["claim_id"], True
|
||||
)
|
||||
|
||||
def test_scheduler_rejects_offline_worker(self):
|
||||
service = _service()
|
||||
service.register_worker(_worker_payload())
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-offline",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
}
|
||||
)
|
||||
service.store.conn.execute(
|
||||
"UPDATE workers SET last_heartbeat=? WHERE worker_id=?",
|
||||
(time.time() - 1000, WORKER_ID),
|
||||
)
|
||||
with self.assertRaises(LockedError):
|
||||
TaskScheduler(service.store).schedule_for_worker(WORKER_ID)
|
||||
|
||||
def test_schedule_decision_event_is_single_for_claim(self):
|
||||
service = _service()
|
||||
service.register_worker(_worker_payload())
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-schedule-event",
|
||||
"issuer": {"type": "agent", "id": ADMIN_IDENTITY},
|
||||
"required_capabilities": ["code.read"],
|
||||
}
|
||||
)
|
||||
TaskScheduler(service.store).schedule_for_worker(WORKER_ID)
|
||||
events = service.store.list_events("task-schedule-event")
|
||||
self.assertEqual(
|
||||
1, sum(event["type"] == "SCHEDULE_DECISION" for event in events)
|
||||
)
|
||||
|
||||
def _credential_service_and_claim(self):
|
||||
identity = "spiffe://aios/agent/secure-file-transformer/01"
|
||||
service = TaskPlaneService(
|
||||
TaskPlaneStore(":memory:"),
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
identity_capabilities={"*": set(ALL_ACTION_CAPABILITIES)},
|
||||
),
|
||||
)
|
||||
service.register_worker(
|
||||
{
|
||||
"worker_id": "secure-transformer-01",
|
||||
"worker_type": "secure_file_transformer",
|
||||
"identity": identity,
|
||||
"capabilities": ["secret.transform", "secure_file_transformer"],
|
||||
"trust_zone": "secure",
|
||||
"max_concurrency": 1,
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"supported_protocols": ["aios-task-v1"],
|
||||
}
|
||||
)
|
||||
task = service.create_task(
|
||||
{
|
||||
"task_id": "task-secure-output",
|
||||
"required_capabilities": ["secret.transform"],
|
||||
"data_classification": "credential-secret",
|
||||
"execution_zone": "secure-file-transformer",
|
||||
"network_policy": "denied",
|
||||
"logging_policy": "metadata-only",
|
||||
"output_policy": "hash-count-status-only",
|
||||
}
|
||||
)
|
||||
claim = service.claim(
|
||||
task["task_id"],
|
||||
{"worker_id": "secure-transformer-01", "actor_identity": identity},
|
||||
)
|
||||
return service, task, claim, identity
|
||||
|
||||
def test_credential_progress_free_text_is_rejected(self):
|
||||
service, task, claim, identity = self._credential_service_and_claim()
|
||||
with self.assertRaises(ValidationError):
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": identity,
|
||||
"progress": 10,
|
||||
"stage": "running",
|
||||
"message": "Authorization: Bearer forbidden",
|
||||
},
|
||||
)
|
||||
|
||||
def test_credential_result_content_is_rejected(self):
|
||||
service, task, claim, identity = self._credential_service_and_claim()
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": identity,
|
||||
"progress": 100,
|
||||
"stage": "done",
|
||||
},
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.complete(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": identity,
|
||||
"outputs": [{"content": "forbidden"}],
|
||||
},
|
||||
)
|
||||
|
||||
def test_action_rbac_denies_read_without_task_read(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(
|
||||
store,
|
||||
policy=PolicyEngine(
|
||||
identity_capabilities={"spiffe://reader/none": set()}
|
||||
),
|
||||
)
|
||||
store.create_task_with_event(TaskPlan.from_dict({"task_id": "task-rbac"}))
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.get_task("task-rbac", "spiffe://reader/none")
|
||||
|
||||
def test_default_service_policy_denies_authenticated_read(self):
|
||||
service = TaskPlaneService(TaskPlaneStore(":memory:"))
|
||||
service.create_task({"task_id": "task-default-deny"})
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.get_task("task-default-deny", "spiffe://reader/unconfigured")
|
||||
|
||||
def test_fail_path_emits_one_task_failed_event(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-single-fail")
|
||||
service.fail(
|
||||
task["task_id"],
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"reason": "expected test failure",
|
||||
},
|
||||
)
|
||||
events = service.store.list_events(task["task_id"])
|
||||
self.assertEqual(1, sum(event["type"] == "TASK_FAILED" for event in events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
import sys
|
||||
sys.path.insert(0, str(ROOT / "runtime" / "agent-os"))
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
from gitea_tag_publication import build_publisher
|
||||
import authoritative_git as authoritative_git_module
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
AuthoritativeGitNotFound,
|
||||
AuthoritativeGitOutputTooLarge,
|
||||
authoritative_branches,
|
||||
authoritative_diff,
|
||||
authoritative_commit,
|
||||
authoritative_head_oid,
|
||||
authoritative_tags,
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
validate_git_revision,
|
||||
verify_repository,
|
||||
)
|
||||
|
||||
API_SPEC = importlib.util.spec_from_file_location(
|
||||
"authoritative_test_api",
|
||||
ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py",
|
||||
)
|
||||
assert API_SPEC and API_SPEC.loader
|
||||
api = importlib.util.module_from_spec(API_SPEC)
|
||||
API_SPEC.loader.exec_module(api)
|
||||
|
||||
|
||||
class AuthoritativeGitContextTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.bare = self.root / "repo.git"
|
||||
self.seed = self.root / "seed"
|
||||
self.runtime = self.root / "runtime"
|
||||
subprocess.run(["git", "init", "--bare", str(self.bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", "-b", "main", str(self.seed)], check=True, capture_output=True)
|
||||
for key, value in (("user.name", "test"), ("user.email", "test@example.invalid")):
|
||||
subprocess.run(["git", "-C", str(self.seed), "config", key, value], check=True)
|
||||
(self.seed / "README").write_text("context\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(self.seed), "add", "README"], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "commit", "-m", "seed"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "remote", "add", "origin", str(self.bare)], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "push", "origin", "main"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "--git-dir", str(self.bare), "config", "remote.gitea.url", "https://g.cnnetmkt.top/git/aios/aios.git"], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "tag", "-a", "V1R1C0", "-m", "annotated"], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "tag", "V1R1C1"], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "checkout", "-b", "patch/context"], check=True, capture_output=True)
|
||||
(self.seed / "CHANGE").write_text("authoritative diff\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(self.seed), "add", "CHANGE"], check=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "commit", "-m", "change"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(self.seed), "push", "origin", "patch/context", "--tags"], check=True, capture_output=True)
|
||||
(self.runtime / "versions").mkdir(parents=True)
|
||||
(self.runtime / "patches").mkdir()
|
||||
(self.runtime / "versions" / "V1R1C0.json").write_text(
|
||||
json.dumps({"version": "V1R1C0"}), encoding="utf-8"
|
||||
)
|
||||
(self.runtime / "patches" / "context.json").write_text(
|
||||
json.dumps({"branch": "patch/context"}), encoding="utf-8"
|
||||
)
|
||||
self.env = {
|
||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.bare),
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_runtime_cwd_without_git_uses_authoritative_head(self):
|
||||
with patch.dict(os.environ, self.env, clear=False), patch("os.getcwd", return_value=str(self.runtime)):
|
||||
self.assertFalse((self.runtime / ".git").exists())
|
||||
self.assertEqual(len(authoritative_head_oid()), 40)
|
||||
|
||||
def test_annotated_and_lightweight_tags_include_type_and_peeled_target(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
tags = {item["name"]: item for item in authoritative_tags()}
|
||||
self.assertEqual(tags["V1R1C0"]["object_type"], "tag")
|
||||
self.assertEqual(tags["V1R1C0"]["peeled_target_oid"], tags["V1R1C1"]["peeled_target_oid"])
|
||||
self.assertEqual(tags["V1R1C1"]["object_type"], "commit")
|
||||
|
||||
def test_authoritative_branches_include_exact_oids(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
branches = {item["name"]: item["oid"] for item in authoritative_branches()}
|
||||
self.assertIn("main", branches)
|
||||
self.assertIn("patch/context", branches)
|
||||
self.assertEqual(len(branches["main"]), 40)
|
||||
|
||||
def test_runtime_cwd_changes_do_not_change_results(self):
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
first = authoritative_head_oid()
|
||||
os.chdir(str(self.runtime))
|
||||
second = authoritative_head_oid()
|
||||
self.assertEqual(first, second)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
def test_missing_configuration_fails_closed(self):
|
||||
with patch.dict(os.environ, {}, clear=True), self.assertRaises(AuthoritativeGitError):
|
||||
verify_repository()
|
||||
|
||||
def test_missing_repository_fails_closed(self):
|
||||
with patch.dict(os.environ, {**self.env, "AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(self.root / "missing")}, clear=False):
|
||||
with self.assertRaises(AuthoritativeGitError):
|
||||
verify_repository()
|
||||
|
||||
def test_non_bare_repository_fails_closed(self):
|
||||
with patch.dict(os.environ, self.env, clear=False), self.assertRaises(AuthoritativeGitError):
|
||||
verify_repository(self.seed)
|
||||
|
||||
def test_repository_identity_mismatch_fails_closed(self):
|
||||
with patch.dict(os.environ, {**self.env, "AIOS_GITOPS_REPOSITORY_IDENTITY": "other/repo"}, clear=False):
|
||||
with self.assertRaises(AuthoritativeGitError):
|
||||
verify_repository()
|
||||
|
||||
def test_git_command_failure_is_not_an_empty_success(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
with self.assertRaises(AuthoritativeGitError) as ctx:
|
||||
run_authoritative_git(["rev-parse", "--verify", "refs/heads/no-such-branch"])
|
||||
self.assertIn("GIT_REF_QUERY_FAILED", str(ctx.exception))
|
||||
self.assertNotIn("tags=[]", str(ctx.exception))
|
||||
|
||||
def test_commit_input_rejects_git_parameter_injection(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
with self.assertRaises(ValueError):
|
||||
authoritative_commit("1" * 39 + ";")
|
||||
|
||||
def test_repository_snapshot_exposes_authoritative_head(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
snapshot = repository_snapshot()
|
||||
self.assertTrue(snapshot["repository_available"])
|
||||
self.assertEqual(snapshot["repository_identity"], "aios/aios")
|
||||
self.assertEqual(len(snapshot["authoritative_head_oid"]), 40)
|
||||
|
||||
def _get(self, path):
|
||||
handler = object.__new__(api.H)
|
||||
handler.path = path
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
handler.sendt = Mock(side_effect=lambda data, ctype="text/plain; charset=utf-8", code=200: (data, code))
|
||||
with (
|
||||
patch.dict(os.environ, self.env, clear=False),
|
||||
patch.object(api, "WS", self.runtime),
|
||||
patch.object(api, "AUTHORITATIVE_GIT_DIR", self.bare),
|
||||
patch.object(api, "REPOSITORY_IDENTITY", "aios/aios"),
|
||||
):
|
||||
api.H.do_GET(handler)
|
||||
args = handler.sendj.call_args.args
|
||||
return args[0], args[1] if len(args) > 1 else 200
|
||||
|
||||
def test_api_list_versions_uses_authoritative_tags(self):
|
||||
body, code = self._get("/api/ver")
|
||||
self.assertEqual(code, 200)
|
||||
self.assertTrue(body["repository_available"])
|
||||
self.assertTrue(body["local_authoritative_tags"])
|
||||
self.assertEqual(body["head"], body["authoritative_head_oid"])
|
||||
self.assertIn("V1R1C0", body["tags"])
|
||||
|
||||
def test_api_list_patches_uses_authoritative_branches(self):
|
||||
body, code = self._get("/api/patch")
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("patch/context", body["branches"])
|
||||
self.assertTrue(body["authoritative_branches"])
|
||||
|
||||
def test_api_unavailable_does_not_return_empty_lists(self):
|
||||
handler = object.__new__(api.H)
|
||||
handler.path = "/api/ver"
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
with patch.dict(os.environ, {}, clear=True), patch.object(api, "WS", self.runtime), patch.object(api, "AUTHORITATIVE_GIT_DIR", None), patch.object(api, "REPOSITORY_IDENTITY", ""):
|
||||
api.H.do_GET(handler)
|
||||
body, code = handler.sendj.call_args.args
|
||||
self.assertEqual(code, 503)
|
||||
self.assertFalse(body["repository_available"])
|
||||
self.assertIn("query_error", body)
|
||||
|
||||
def test_api_runtime_status_contains_release_and_repository_fields(self):
|
||||
handler = object.__new__(api.H)
|
||||
handler.path = "/api/run"
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
real_run = subprocess.run
|
||||
def service_or_real(*args, **kwargs):
|
||||
command = args[0] if args else kwargs.get("args", [])
|
||||
if command and command[0] == "systemctl":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="active\n", stderr="")
|
||||
return real_run(*args, **kwargs)
|
||||
with (
|
||||
patch.dict(os.environ, self.env, clear=False),
|
||||
patch.object(api, "WS", self.runtime),
|
||||
patch.object(api, "AUTHORITATIVE_GIT_DIR", self.bare),
|
||||
patch.object(api, "REPOSITORY_IDENTITY", "aios/aios"),
|
||||
patch.object(api.subprocess, "run", side_effect=service_or_real),
|
||||
):
|
||||
api.H.do_GET(handler)
|
||||
body, code = handler.sendj.call_args.args
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIsNone(body["workspace_branch"])
|
||||
self.assertTrue(body["authoritative_git_dir_configured"])
|
||||
self.assertEqual(body["authoritative_head_oid"], body["authoritative_head_oid"])
|
||||
|
||||
def test_authoritative_head_is_independent_of_runtime_directory(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(str(self.runtime))
|
||||
self.assertEqual(authoritative_head_oid(), authoritative_head_oid())
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
def test_authoritative_api_is_read_only(self):
|
||||
before = subprocess.run(["git", "--git-dir", str(self.bare), "show-ref"], check=True, capture_output=True, text=True).stdout
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
authoritative_tags()
|
||||
authoritative_branches()
|
||||
after = subprocess.run(["git", "--git-dir", str(self.bare), "show-ref"], check=True, capture_output=True, text=True).stdout
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_error_text_does_not_include_credential_values(self):
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
with self.assertRaises(AuthoritativeGitError) as ctx:
|
||||
run_authoritative_git(["rev-parse", "--verify", "https://user:password@example.invalid/x"])
|
||||
self.assertNotIn("password", str(ctx.exception).lower())
|
||||
|
||||
def test_cli_publisher_requires_and_uses_authoritative_repository(self):
|
||||
args = SimpleNamespace(
|
||||
workspace=str(self.runtime),
|
||||
remote="gitea",
|
||||
gitea_api="http://127.0.0.1:3000/api/v1",
|
||||
gitea_repo="aios/aios",
|
||||
gitea_token_file="/dev/null",
|
||||
)
|
||||
with patch.dict(os.environ, self.env, clear=False):
|
||||
publisher = build_publisher(args)
|
||||
self.assertEqual(publisher.git.authoritative_repo, self.bare)
|
||||
|
||||
def test_cli_publisher_fails_closed_without_authoritative_repository(self):
|
||||
args = SimpleNamespace(
|
||||
workspace=str(self.runtime),
|
||||
remote="gitea",
|
||||
gitea_api="http://127.0.0.1:3000/api/v1",
|
||||
gitea_repo="aios/aios",
|
||||
gitea_token_file="/dev/null",
|
||||
)
|
||||
with patch.dict(os.environ, {}, clear=True), self.assertRaises(RuntimeError):
|
||||
build_publisher(args)
|
||||
|
||||
def test_authoritative_diff_supports_branch_tag_and_exact_oid(self):
|
||||
with patch.dict(os.environ, self.env, clear=True):
|
||||
by_ref = authoritative_diff("V1R1C0", "patch/context")
|
||||
main_oid = validate_git_revision("refs/heads/main", "from")
|
||||
patch_oid = validate_git_revision("refs/heads/patch/context", "to")
|
||||
by_oid = authoritative_diff(main_oid, patch_oid)
|
||||
self.assertIn("CHANGE", by_ref["stdout"])
|
||||
self.assertEqual(by_ref["stdout"], by_oid["stdout"])
|
||||
self.assertEqual(by_ref["code"], 0)
|
||||
|
||||
def test_authoritative_diff_is_cwd_independent(self):
|
||||
old = os.getcwd()
|
||||
try:
|
||||
with patch.dict(os.environ, self.env, clear=True):
|
||||
first = authoritative_diff("V1R1C0", "patch/context")["stdout"]
|
||||
os.chdir(self.runtime)
|
||||
second = authoritative_diff("V1R1C0", "patch/context")["stdout"]
|
||||
self.assertEqual(first, second)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
def test_revision_injection_and_revision_expressions_are_rejected(self):
|
||||
with patch.dict(os.environ, self.env, clear=True):
|
||||
for value in ("--help", "--output=x", "HEAD~1", "main^{tree}", "@{1}", ":/secret", "", " main"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
validate_git_revision(value, "from")
|
||||
|
||||
def test_valid_but_missing_revision_is_not_found(self):
|
||||
with patch.dict(os.environ, self.env, clear=True), self.assertRaises(AuthoritativeGitNotFound):
|
||||
validate_git_revision("missing-branch", "from")
|
||||
|
||||
def test_diff_output_limit_is_fail_closed(self):
|
||||
with patch.dict(os.environ, self.env, clear=True), patch.object(authoritative_git_module, "_MAX_OUTPUT", 1):
|
||||
with self.assertRaises(AuthoritativeGitOutputTooLarge):
|
||||
authoritative_diff("V1R1C0", "patch/context")
|
||||
|
||||
def test_api_diff_uses_authoritative_repository(self):
|
||||
body, code = self._get("/api/diff?from=V1R1C0&to=patch/context")
|
||||
self.assertEqual(code, 200)
|
||||
self.assertIn("CHANGE", body["diff"]["stdout"])
|
||||
self.assertEqual(len(body["diff"]["from_oid"]), 40)
|
||||
|
||||
def test_api_diff_status_mapping(self):
|
||||
_, code = self._get("/api/diff?from=--help&to=patch/context")
|
||||
self.assertEqual(code, 400)
|
||||
_, code = self._get("/api/diff?from=missing-branch&to=patch/context")
|
||||
self.assertEqual(code, 404)
|
||||
with patch.object(api, "authoritative_diff", side_effect=AuthoritativeGitOutputTooLarge("GIT_OUTPUT_LIMIT_EXCEEDED")):
|
||||
_, code = self._get("/api/diff?from=V1R1C0&to=patch/context")
|
||||
self.assertEqual(code, 413)
|
||||
|
||||
def test_http_publisher_fails_closed_without_authoritative_repository(self):
|
||||
with patch.dict(os.environ, {}, clear=True), patch.object(api, "AUTHORITATIVE_GIT_DIR", None), patch.object(api, "REPOSITORY_IDENTITY", ""):
|
||||
with self.assertRaises(RuntimeError):
|
||||
api.publication_publisher()
|
||||
|
||||
def test_http_publisher_fails_closed_on_identity_mismatch(self):
|
||||
with patch.dict(os.environ, self.env, clear=True), patch.object(api, "AUTHORITATIVE_GIT_DIR", self.bare), patch.object(api, "REPOSITORY_IDENTITY", "other/repo"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
api.publication_publisher()
|
||||
|
||||
def test_writer_guard_remains_false(self):
|
||||
decision = api.evaluate_git_write(self.runtime, {"AIOS_GITOPS_WRITES_ENABLED": "false"})
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual(decision.status_code, 423)
|
||||
+361
-118
@@ -1,17 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, call, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.environ.setdefault("AIOS_GITOPS_WORKSPACE", str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "runtime" / "agent-os"))
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"aios_chatgpt_actions_api",
|
||||
ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py",
|
||||
@@ -20,168 +24,407 @@ assert SPEC and SPEC.loader
|
||||
api = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(api)
|
||||
|
||||
TARGET = "3dd622b5a515f1fa4ef72b9f8ae9ce48ac703f24"
|
||||
EVIDENCE = "8cdca463aff9707edee00e20a3b56884130f6978"
|
||||
MAIN = "a281bfeacd990134dc2ebff5a7b9465756bc4bea"
|
||||
TAG_OID = "1" * 40
|
||||
TARGET = "1" * 40
|
||||
EVIDENCE = "2" * 40
|
||||
TAG_OID = "3" * 40
|
||||
MAIN = "4" * 40
|
||||
|
||||
|
||||
class TagActionTests(unittest.TestCase):
|
||||
def handler(self, role: str = "owner"):
|
||||
handler = object.__new__(api.H)
|
||||
handler.need = Mock(return_value={"account": "owner", "role": role, "scopes": {"tag:create"}})
|
||||
handler.need = Mock(
|
||||
return_value={"account": "owner", "role": role, "scopes": {"tag:create"}}
|
||||
)
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
return handler
|
||||
|
||||
def complete_request(self):
|
||||
return {
|
||||
"version": "V9R9C9",
|
||||
"target_oid": TARGET,
|
||||
"evidence_closure_oid": EVIDENCE,
|
||||
"approval_plan_id": "owner-approved-test",
|
||||
"execute": True,
|
||||
}
|
||||
|
||||
def test_tag_publication_status_rejects_invalid_versions_without_reading(self):
|
||||
invalid_queries = (
|
||||
"../current",
|
||||
"../../versions/V2R1C3",
|
||||
"%2e%2e%2fcurrent",
|
||||
"/etc/secret",
|
||||
r"..\current",
|
||||
"",
|
||||
)
|
||||
for value in invalid_queries:
|
||||
with self.subTest(value=value):
|
||||
handler = object.__new__(api.H)
|
||||
handler.path = f"/api/tag-publication-status?version={value}"
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
with patch.object(api, "publication_publisher") as publisher:
|
||||
handler.do_GET()
|
||||
body = handler.sendj.call_args.args[0]
|
||||
code = (
|
||||
handler.sendj.call_args.args[1]
|
||||
if len(handler.sendj.call_args.args) > 1
|
||||
else handler.sendj.call_args.kwargs.get("code", 200)
|
||||
)
|
||||
self.assertEqual(code, 400)
|
||||
self.assertEqual(body, {"error": "invalid release version"})
|
||||
if value:
|
||||
self.assertNotIn(value, str(body))
|
||||
self.assertNotIn("/data/", str(body))
|
||||
self.assertNotIn("BAIT-MUST-NOT-BE-READ", str(body))
|
||||
publisher.assert_not_called()
|
||||
|
||||
def test_tag_publication_status_accepts_canonical_versions(self):
|
||||
for version in ("V2R1C3", "V2R1C3-rc.1_build"):
|
||||
with self.subTest(version=version):
|
||||
handler = object.__new__(api.H)
|
||||
handler.path = f"/api/tag-publication-status?version={version}"
|
||||
handler.sendj = Mock(side_effect=lambda data, code=200: (data, code))
|
||||
publisher = Mock()
|
||||
publisher.get_tag_publication_status.return_value = {
|
||||
"version": version,
|
||||
"state": "UNKNOWN",
|
||||
}
|
||||
with patch.object(api, "publication_publisher", return_value=publisher):
|
||||
handler.do_GET()
|
||||
body = handler.sendj.call_args.args[0]
|
||||
code = (
|
||||
handler.sendj.call_args.args[1]
|
||||
if len(handler.sendj.call_args.args) > 1
|
||||
else handler.sendj.call_args.kwargs.get("code", 200)
|
||||
)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertEqual(body["version"], version)
|
||||
publisher.get_tag_publication_status.assert_called_once_with(version)
|
||||
|
||||
def test_resolve_commit_oid_requires_full_exact_commit(self):
|
||||
with patch.object(api, "sh", return_value={"code": 0, "stdout": TARGET, "stderr": "", "cmd": []}):
|
||||
self.assertEqual(api.resolve_commit_oid(TARGET.upper()), TARGET)
|
||||
for value in (None, "3dd622b", "z" * 40):
|
||||
with patch.object(api, "authoritative_commit", return_value=TARGET):
|
||||
self.assertEqual(api.resolve_commit_oid(TARGET), TARGET)
|
||||
for value in (None, "1234567", "z" * 40):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
api.resolve_commit_oid(value)
|
||||
with patch.object(api, "sh", return_value={"code": 1, "stdout": "", "stderr": "missing", "cmd": []}), self.assertRaises(ValueError):
|
||||
api.resolve_commit_oid(TARGET)
|
||||
|
||||
def test_owner_execution_tags_explicit_target_without_backend_confirm_loop(self):
|
||||
def test_production_tag_requires_all_governance_fields(self):
|
||||
handler = self.handler()
|
||||
git_calls = []
|
||||
for missing in ("target_oid", "evidence_closure_oid", "approval_plan_id"):
|
||||
payload = self.complete_request()
|
||||
payload.pop(missing)
|
||||
handler.tag(payload)
|
||||
body, code = handler.sendj.call_args.args
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn("required", body)
|
||||
|
||||
def test_owner_execution_uses_atomic_publisher_and_never_changes_main(self):
|
||||
handler = self.handler()
|
||||
publication = {
|
||||
"state": "BASELINE_UPDATED",
|
||||
"tag_oid": TAG_OID,
|
||||
"remote_tag_oid": TAG_OID,
|
||||
"remote_peeled_target_oid": TARGET,
|
||||
"gitea_listing_visible": True,
|
||||
}
|
||||
|
||||
def fake_sh(args, check=False):
|
||||
git_calls.append(args)
|
||||
if args[:2] == ["merge-base", "--is-ancestor"]:
|
||||
return {"code": 0, "stdout": "", "stderr": "", "cmd": ["git", *args]}
|
||||
return {"code": 0, "stdout": "", "stderr": "", "cmd": []}
|
||||
if args == ["rev-parse", "refs/heads/main"]:
|
||||
return {"code": 0, "stdout": MAIN, "stderr": "", "cmd": ["git", *args]}
|
||||
if args == ["rev-parse", "refs/tags/V2R1C2"]:
|
||||
return {"code": 0, "stdout": TAG_OID, "stderr": "", "cmd": ["git", *args]}
|
||||
return {"code": 0, "stdout": MAIN, "stderr": "", "cmd": []}
|
||||
if args == ["rev-parse", "HEAD"]:
|
||||
return {"code": 0, "stdout": EVIDENCE, "stderr": "", "cmd": ["git", *args]}
|
||||
return {"code": 0, "stdout": "", "stderr": "", "cmd": ["git", *args]}
|
||||
return {"code": 0, "stdout": EVIDENCE, "stderr": "", "cmd": []}
|
||||
return {"code": 0, "stdout": "", "stderr": "", "cmd": []}
|
||||
|
||||
publisher = Mock()
|
||||
publisher.publish.return_value = publication
|
||||
with (
|
||||
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
|
||||
patch.object(api, "existing_tag_target", side_effect=[None, TARGET]),
|
||||
patch.object(api, "sh", side_effect=fake_sh),
|
||||
patch.object(api, "sync_tag", return_value={"status": "synced"}),
|
||||
patch.object(api, "audit") as audit,
|
||||
):
|
||||
handler.tag({
|
||||
"version": "V2R1C2", "target_oid": TARGET,
|
||||
"evidence_closure_oid": EVIDENCE, "approval_plan_id": "nl-approved",
|
||||
"execute": True, "message": "approved",
|
||||
})
|
||||
|
||||
self.assertIn(["tag", "-a", "V2R1C2", TARGET, "-m", "approved"], git_calls)
|
||||
payload = handler.sendj.call_args.args[0]
|
||||
self.assertEqual(payload["status"], "tagged")
|
||||
self.assertEqual(payload["target_oid"], TARGET)
|
||||
self.assertEqual(payload["tag_oid"], TAG_OID)
|
||||
self.assertFalse(payload["main_changed"])
|
||||
self.assertFalse(payload["force_push"])
|
||||
audit.assert_called_once()
|
||||
|
||||
def test_existing_matching_tag_is_idempotent(self):
|
||||
handler = self.handler()
|
||||
|
||||
def fake_sh(args, check=False):
|
||||
values = {
|
||||
("merge-base", "--is-ancestor", TARGET, EVIDENCE): "",
|
||||
("rev-parse", "refs/heads/main"): MAIN,
|
||||
("rev-parse", "refs/tags/V2R1C2"): TAG_OID,
|
||||
("rev-parse", "HEAD"): EVIDENCE,
|
||||
}
|
||||
return {"code": 0, "stdout": values.get(tuple(args), ""), "stderr": "", "cmd": ["git", *args]}
|
||||
|
||||
with (
|
||||
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
|
||||
patch.object(api, "existing_tag_target", side_effect=[TARGET, TARGET]),
|
||||
patch.object(api, "sh", side_effect=fake_sh) as git,
|
||||
patch.object(api, "sync_tag", return_value={"status": "synced"}),
|
||||
patch.object(api, "required_authoritative_git_dir", return_value=Path("/tmp/authoritative.git")),
|
||||
patch.object(api, "GiteaTagClient"),
|
||||
patch.object(api, "GitTagBackend"),
|
||||
patch.object(api, "TagPublisher", return_value=publisher),
|
||||
patch.object(api, "audit"),
|
||||
):
|
||||
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
|
||||
self.assertEqual(handler.sendj.call_args.args[0]["status"], "tagged_existing")
|
||||
self.assertFalse(any(c.args[0][0] == "tag" for c in git.call_args_list))
|
||||
handler.tag(self.complete_request())
|
||||
result = handler.sendj.call_args.args[0]
|
||||
self.assertEqual(result["status"], "BASELINE_UPDATED")
|
||||
self.assertEqual(result["target_oid"], TARGET)
|
||||
self.assertEqual(result["tag_oid"], TAG_OID)
|
||||
self.assertFalse(result["main_changed"])
|
||||
self.assertFalse(result["force_push"])
|
||||
|
||||
def test_existing_different_tag_fails_closed(self):
|
||||
def test_atomic_publisher_conflict_returns_reconciliation_required(self):
|
||||
handler = self.handler()
|
||||
wrong = "2" * 40
|
||||
publisher = Mock()
|
||||
publisher.publish.side_effect = api.PublicationConflict("different remote OID")
|
||||
with (
|
||||
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
|
||||
patch.object(api, "sh", return_value={"code": 0, "stdout": "", "stderr": "", "cmd": []}),
|
||||
patch.object(api, "existing_tag_target", return_value=wrong),
|
||||
patch.object(
|
||||
api,
|
||||
"sh",
|
||||
side_effect=[
|
||||
{"code": 0, "stdout": "", "stderr": "", "cmd": []},
|
||||
{"code": 0, "stdout": MAIN, "stderr": "", "cmd": []},
|
||||
],
|
||||
),
|
||||
patch.object(api, "required_authoritative_git_dir", return_value=Path("/tmp/authoritative.git")),
|
||||
patch.object(api, "GiteaTagClient"),
|
||||
patch.object(api, "GitTagBackend"),
|
||||
patch.object(api, "TagPublisher", return_value=publisher),
|
||||
patch.object(api, "audit"),
|
||||
):
|
||||
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
|
||||
payload, code = handler.sendj.call_args.args
|
||||
handler.tag(self.complete_request())
|
||||
body, code = handler.sendj.call_args.args
|
||||
self.assertEqual(code, 409)
|
||||
self.assertEqual(payload["status"], "tag_target_conflict")
|
||||
self.assertEqual(payload["target_oid"], wrong)
|
||||
self.assertEqual(body["status"], "RECONCILIATION_REQUIRED")
|
||||
|
||||
def test_evidence_lineage_mismatch_fails_closed(self):
|
||||
handler = self.handler()
|
||||
def test_non_owner_request_persists_complete_target_binding(self):
|
||||
handler = self.handler(role="chatgpt_bot")
|
||||
with (
|
||||
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
|
||||
patch.object(api, "sh", return_value={"code": 1, "stdout": "", "stderr": "", "cmd": []}),
|
||||
):
|
||||
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "execute": True})
|
||||
payload, code = handler.sendj.call_args.args
|
||||
self.assertEqual(code, 409)
|
||||
self.assertEqual(payload["error"], "evidence_lineage_mismatch")
|
||||
|
||||
def test_non_owner_request_persists_target_binding(self):
|
||||
handler = self.handler(role="user")
|
||||
with (
|
||||
patch.object(api, "resolve_commit_oid", side_effect=[TARGET, EVIDENCE]),
|
||||
patch.object(api, "sh", return_value={"code": 0, "stdout": "", "stderr": "", "cmd": []}),
|
||||
patch.object(api, "checkout", return_value="request/tag-v2r1c2"),
|
||||
patch.object(api, "write_json", return_value="baseline/tag_requests/V2R1C2.json") as write_json,
|
||||
patch.object(
|
||||
api, "sh", return_value={"code": 0, "stdout": "", "stderr": "", "cmd": []}
|
||||
),
|
||||
patch.object(api, "checkout", return_value="request/tag-v9r9c9"),
|
||||
patch.object(
|
||||
api,
|
||||
"write_json",
|
||||
return_value="baseline/tag_requests/V9R9C9.json",
|
||||
) as write_json,
|
||||
patch.object(api, "commit", return_value={"status": "committed"}),
|
||||
):
|
||||
handler.tag({"version": "V2R1C2", "target_oid": TARGET, "evidence_closure_oid": EVIDENCE, "approval_plan_id": "nl-approved", "execute": True})
|
||||
request = write_json.call_args.args[1]
|
||||
self.assertEqual(request["target_oid"], TARGET)
|
||||
self.assertEqual(request["evidence_closure_oid"], EVIDENCE)
|
||||
self.assertEqual(request["approval_plan_id"], "nl-approved")
|
||||
|
||||
def test_openapi_requires_target_and_keeps_platform_confirmation(self):
|
||||
schema = (ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
self.assertIn("required: [version, target_oid]", schema)
|
||||
self.assertIn("x-openai-isConsequential: true", schema)
|
||||
self.assertIn("never implicitly at workspace HEAD", schema)
|
||||
|
||||
def test_real_git_tag_targets_approved_commit_not_newer_workspace_head(self):
|
||||
def run(cwd: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=cwd, check=True, text=True, capture_output=True).stdout.strip()
|
||||
handler.tag(self.complete_request())
|
||||
saved = write_json.call_args.args[1]
|
||||
self.assertEqual(saved["target_oid"], TARGET)
|
||||
self.assertEqual(saved["evidence_closure_oid"], EVIDENCE)
|
||||
self.assertEqual(saved["approval_plan_id"], "owner-approved-test")
|
||||
|
||||
def test_linked_worktree_branch_sync_verifies_instead_of_self_fetch(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
bare = root / "repo.git"
|
||||
work = root / "work"
|
||||
seed = root / "seed"
|
||||
worktree = root / "writer"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", "-b", "main", str(work)], check=True, capture_output=True)
|
||||
run(work, "config", "user.email", "test@example.invalid")
|
||||
run(work, "config", "user.name", "Tag Test")
|
||||
(work / "release.txt").write_text("candidate\n", encoding="utf-8")
|
||||
run(work, "add", "release.txt"); run(work, "commit", "-m", "candidate")
|
||||
target = run(work, "rev-parse", "HEAD")
|
||||
(work / "evidence.txt").write_text("closure\n", encoding="utf-8")
|
||||
run(work, "add", "evidence.txt"); run(work, "commit", "-m", "evidence")
|
||||
evidence = run(work, "rev-parse", "HEAD")
|
||||
run(work, "remote", "add", "origin", str(bare)); run(work, "push", "origin", "main")
|
||||
subprocess.run(["git", "init", "-b", "main", str(seed)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(seed), "config", "user.name", "test"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(seed), "config", "user.email", "test@example.invalid"],
|
||||
check=True,
|
||||
)
|
||||
(seed / "x").write_text("x", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(seed), "add", "x"], check=True)
|
||||
subprocess.run(["git", "-C", str(seed), "commit", "-m", "seed"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", str(seed), "push", str(bare), "main"], check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "--git-dir", str(bare), "worktree", "add", "-b", "patch/test", str(worktree), "main"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
old_ws, old_bare = api.WS, api.BARE_REPO
|
||||
api.WS, api.BARE_REPO = work, bare
|
||||
api.WS, api.BARE_REPO = worktree, bare
|
||||
try:
|
||||
handler = self.handler()
|
||||
with patch.object(api, "audit"):
|
||||
handler.tag({"version": "V9R9C9", "target_oid": target, "evidence_closure_oid": evidence, "execute": True, "message": "sandbox"})
|
||||
payload = handler.sendj.call_args.args[0]
|
||||
self.assertEqual(payload["target_oid"], target)
|
||||
self.assertEqual(run(work, "rev-parse", "HEAD"), evidence)
|
||||
self.assertEqual(run(work, "rev-parse", "V9R9C9^{}"), target)
|
||||
self.assertEqual(subprocess.run(["git", "--git-dir", str(bare), "rev-parse", "V9R9C9^{}"], check=True, text=True, capture_output=True).stdout.strip(), target)
|
||||
result = api.sync_branch("patch/test")
|
||||
finally:
|
||||
api.WS, api.BARE_REPO = old_ws, old_bare
|
||||
self.assertEqual(result["status"], "verified")
|
||||
self.assertEqual(result["target"], "authoritative-bare")
|
||||
|
||||
def test_create_existing_branch_never_resets_it(self):
|
||||
calls = []
|
||||
|
||||
def fake_sh(args, check=False):
|
||||
calls.append(args)
|
||||
if args[:2] == ["rev-parse", "--verify"]:
|
||||
return {"code": 0, "stdout": MAIN, "stderr": "", "cmd": []}
|
||||
return {"code": 0, "stdout": "", "stderr": "", "cmd": []}
|
||||
|
||||
with patch.object(api, "ensure_git"), patch.object(api, "sh", side_effect=fake_sh):
|
||||
self.assertEqual(api.checkout("patch/existing", "main", True), "patch/existing")
|
||||
self.assertIn(["checkout", "patch/existing"], calls)
|
||||
self.assertFalse(any(call[:2] == ["checkout", "-B"] for call in calls))
|
||||
self.assertFalse(any(call[:2] == ["checkout", "-b"] for call in calls))
|
||||
|
||||
def test_release_script_has_no_ignored_push_or_direct_main_update(self):
|
||||
script = (ROOT / "scripts" / "release_version.sh").read_text(encoding="utf-8")
|
||||
self.assertNotIn('git push origin "$VERSION" || true', script)
|
||||
self.assertNotIn("git checkout main", script)
|
||||
self.assertIn("approval_plan_id required", script)
|
||||
self.assertIn("git push \"$BRANCH_REMOTE\"", script)
|
||||
self.assertNotIn("RELEASE_DONE", script)
|
||||
self.assertNotIn("merge-base --is-ancestor", script)
|
||||
self.assertIn('"versions/$VERSION.json"', script)
|
||||
self.assertNotIn('"baseline/tag_publications/$VERSION.json"', script)
|
||||
|
||||
def test_openapi_requires_complete_tag_governance_evidence(self):
|
||||
schema = (
|
||||
ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"required: [version, target_oid, evidence_closure_oid, approval_plan_id]",
|
||||
schema,
|
||||
)
|
||||
self.assertIn("x-openai-isConsequential: true", schema)
|
||||
self.assertIn("Remote push pending", schema)
|
||||
self.assertIn("operationId: getReleaseConsistency", schema)
|
||||
self.assertIn("operationId: getTagPublicationStatus", schema)
|
||||
self.assertIn("operationId: runReleaseAcceptance", schema)
|
||||
document = yaml.safe_load(schema)
|
||||
self.assertIn("/api/release-consistency", document["paths"])
|
||||
status_parameters = document["paths"]["/api/tag-publication-status"]["get"][
|
||||
"parameters"
|
||||
]
|
||||
self.assertEqual(
|
||||
status_parameters[0]["schema"]["pattern"],
|
||||
r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$",
|
||||
)
|
||||
publication_states = document["components"]["schemas"]["PublicationState"][
|
||||
"enum"
|
||||
]
|
||||
self.assertIn("LOCAL_TAG_CREATED", publication_states)
|
||||
self.assertIn("BASELINE_UPDATE_PENDING", publication_states)
|
||||
self.assertNotIn("TAG_CREATED_LOCAL", publication_states)
|
||||
|
||||
def test_documented_normal_state_sequence_matches_canonical_contract(self):
|
||||
document = (
|
||||
ROOT / "docs" / "gitops" / "gitea-tag-publication-atomicity.md"
|
||||
).read_text(encoding="utf-8")
|
||||
normal_section = document.split("## Legacy state migration", 1)[0]
|
||||
self.assertIn("LOCAL_TAG_CREATED", normal_section)
|
||||
self.assertIn("BASELINE_UPDATE_PENDING", normal_section)
|
||||
self.assertNotIn("TAG_CREATED_LOCAL", normal_section)
|
||||
|
||||
def test_delivery_manifest_is_derived_from_git_without_self_reference(self):
|
||||
generator = ROOT / "scripts" / "generate_delivery_manifest.py"
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
repo = Path(td)
|
||||
subprocess.run(
|
||||
["git", "init", "-q", "-b", "main"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.invalid"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "test"], cwd=repo, check=True
|
||||
)
|
||||
(repo / "base.txt").write_text("base", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "base.txt"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "base"], cwd=repo, check=True
|
||||
)
|
||||
base_oid = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
||||
).strip()
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "patch/manifest-test"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
(repo / "scripts").mkdir()
|
||||
shutil.copy2(generator, repo / "scripts" / generator.name)
|
||||
(repo / "source.txt").write_text(
|
||||
"no credential content", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "source one"], cwd=repo, check=True
|
||||
)
|
||||
(repo / "source.txt").write_text(
|
||||
"no credential content\nsecond", encoding="utf-8"
|
||||
)
|
||||
subprocess.run(["git", "add", "source.txt"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "source two"], cwd=repo, check=True
|
||||
)
|
||||
source_commit = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
||||
).strip()
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(repo / "scripts" / generator.name),
|
||||
"--base",
|
||||
"main",
|
||||
"--output",
|
||||
"delivery.json",
|
||||
],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
manifest = json.loads((repo / "delivery.json").read_text())
|
||||
expected_commits = (
|
||||
subprocess.check_output(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--reverse",
|
||||
"--format=%H",
|
||||
f"{base_oid}..{source_commit}",
|
||||
],
|
||||
cwd=repo,
|
||||
text=True,
|
||||
)
|
||||
.strip()
|
||||
.splitlines()
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["base"], "main")
|
||||
self.assertEqual(manifest["base_oid"], base_oid)
|
||||
self.assertEqual(manifest["source_branch"], "patch/manifest-test")
|
||||
self.assertEqual(manifest["source_commit"], source_commit)
|
||||
self.assertEqual(manifest["commits"], expected_commits)
|
||||
self.assertEqual(manifest["commit_count"], len(expected_commits))
|
||||
self.assertEqual(manifest["file_count"], len(manifest["files"]))
|
||||
self.assertEqual(
|
||||
manifest["manifest_path"],
|
||||
"delivery.json",
|
||||
)
|
||||
self.assertNotIn(manifest["manifest_path"], manifest["files"])
|
||||
self.assertEqual(
|
||||
subprocess.check_output(
|
||||
["git", "rev-parse", f"{manifest['source_commit']}^{{commit}}"],
|
||||
cwd=repo,
|
||||
text=True,
|
||||
).strip(),
|
||||
source_commit,
|
||||
)
|
||||
self.assertNotIn("credential content", json.dumps(manifest))
|
||||
|
||||
def test_read_only_release_interfaces_are_allowlisted(self):
|
||||
self.assertIn("/api/release-consistency", api.READ_ONLY_GET_PATHS)
|
||||
self.assertIn("/api/tag-publication-status", api.READ_ONLY_GET_PATHS)
|
||||
self.assertIn("/api/release-acceptance", api.READ_ONLY_GET_PATHS)
|
||||
|
||||
def test_release_consistency_query_requires_full_governance_fingerprint(self):
|
||||
with self.assertRaises(ValueError):
|
||||
api.publication_request_from_query({"version": ["V9R9C9"]})
|
||||
parsed = api.publication_request_from_query(
|
||||
{
|
||||
"version": ["V9R9C9"],
|
||||
"target_oid": [TARGET],
|
||||
"evidence_closure_oid": [EVIDENCE],
|
||||
"approval_plan_id": ["owner-approved-test"],
|
||||
}
|
||||
)
|
||||
self.assertEqual(parsed.target_oid, TARGET)
|
||||
self.assertEqual(parsed.approval_plan_id, "owner-approved-test")
|
||||
|
||||
def test_committed_security_incident_record_contains_no_secret(self):
|
||||
record = (
|
||||
ROOT
|
||||
/ "security"
|
||||
/ "incidents"
|
||||
/ "GITEA-TOKEN-33-EXPOSURE-20260723.redacted.json"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertFalse(api.content_has_secret(record))
|
||||
self.assertNotIn("token_plain", record)
|
||||
self.assertNotIn("Authorization", record)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,872 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
import gitea_tag_publication as publication_module # noqa: E402
|
||||
|
||||
from gitea_tag_publication import ( # noqa: E402
|
||||
PublicationConflict,
|
||||
PublicationPending,
|
||||
PublicationReconciliationRequired,
|
||||
PublicationRequest,
|
||||
PublicationState,
|
||||
PublicationStore,
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
TagPublisher,
|
||||
)
|
||||
|
||||
|
||||
TARGET = "1" * 40
|
||||
EVIDENCE = "2" * 40
|
||||
TAG_OID = "3" * 40
|
||||
WRONG = "4" * 40
|
||||
|
||||
|
||||
class FakeGit:
|
||||
def __init__(self):
|
||||
self.local = None
|
||||
self.remote = None
|
||||
self.push_error = None
|
||||
self.remote_error = None
|
||||
self.lineage_error = None
|
||||
self.pushes = 0
|
||||
self.remote_checks = 0
|
||||
|
||||
def ensure_local_annotated_tag(self, request):
|
||||
if self.local and self.local != (TAG_OID, request.target_oid):
|
||||
raise PublicationConflict("local tag conflict")
|
||||
self.local = (TAG_OID, request.target_oid)
|
||||
return self.local
|
||||
|
||||
def remote_tag(self, remote, version):
|
||||
self.remote_checks += 1
|
||||
if self.remote_error:
|
||||
raise self.remote_error
|
||||
return self.remote
|
||||
|
||||
def push_tag(self, remote, version):
|
||||
self.pushes += 1
|
||||
if self.push_error:
|
||||
raise self.push_error
|
||||
self.remote = self.local
|
||||
|
||||
def validate_evidence_lineage(self, request):
|
||||
if self.lineage_error:
|
||||
raise self.lineage_error
|
||||
return None
|
||||
|
||||
def repository_identity(self, remote, expected_repository):
|
||||
return expected_repository
|
||||
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self):
|
||||
self.repository = "aios/aios"
|
||||
self.lookup = (TAG_OID, TARGET)
|
||||
self.visible = True
|
||||
self.error = None
|
||||
self.lookup_checks = 0
|
||||
self.listing_checks = 0
|
||||
|
||||
def lookup_tag(self, version):
|
||||
self.lookup_checks += 1
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.lookup
|
||||
|
||||
def tag_visible_in_listing(self, version, expected_target_oid):
|
||||
self.listing_checks += 1
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.visible
|
||||
|
||||
def repository_identity(self):
|
||||
return self.repository
|
||||
|
||||
|
||||
def request():
|
||||
return PublicationRequest(
|
||||
version="V9R9C9",
|
||||
target_oid=TARGET,
|
||||
evidence_closure_oid=EVIDENCE,
|
||||
approval_plan_id="owner-approved-test",
|
||||
)
|
||||
|
||||
|
||||
class TagPublicationTests(unittest.TestCase):
|
||||
def publisher(self, root: Path, git=None, gitea=None):
|
||||
baseline = root / "baseline" / "current.json"
|
||||
baseline.parent.mkdir(parents=True)
|
||||
baseline.write_text(
|
||||
json.dumps({"current_baseline": "V9R9C8", "status": "PRODUCTION_DEPLOYED"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return TagPublisher(
|
||||
workspace=root,
|
||||
git=git or FakeGit(),
|
||||
gitea=gitea or FakeGitea(),
|
||||
remote="gitea",
|
||||
)
|
||||
|
||||
def test_publication_store_rejects_path_traversal_without_reading(self):
|
||||
invalid_versions = (
|
||||
"../current",
|
||||
"../../versions/V2R1C3",
|
||||
"%2e%2e%2fcurrent",
|
||||
"/etc/secret",
|
||||
r"..\current",
|
||||
"V2R1C3-..",
|
||||
"",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "saga"
|
||||
root.mkdir()
|
||||
bait = root.parent / "current.json"
|
||||
bait.write_text('{"secret":"BAIT-MUST-NOT-BE-READ"}', encoding="utf-8")
|
||||
store = PublicationStore(Path(td), state_root=root)
|
||||
for version in invalid_versions:
|
||||
with self.subTest(version=version):
|
||||
with patch.object(
|
||||
Path,
|
||||
"read_text",
|
||||
side_effect=AssertionError("state file read attempted"),
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "^invalid release version$"
|
||||
):
|
||||
store.load(version)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "^invalid release version$"
|
||||
):
|
||||
store.path(version)
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "^invalid release version$"
|
||||
):
|
||||
with store.lock(version):
|
||||
self.fail("invalid version acquired a publication lock")
|
||||
self.assertEqual(
|
||||
bait.read_text(encoding="utf-8"),
|
||||
'{"secret":"BAIT-MUST-NOT-BE-READ"}',
|
||||
)
|
||||
|
||||
def test_publication_store_accepts_canonical_release_versions(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td) / "saga"
|
||||
store = PublicationStore(Path(td), state_root=root)
|
||||
for version in ("V2R1C3", "V2R1C3-rc.1_build"):
|
||||
with self.subTest(version=version):
|
||||
self.assertEqual(store.path(version).parent, root.resolve())
|
||||
self.assertEqual(store.path(version).name, f"{version}.json")
|
||||
|
||||
def test_new_publication_uses_canonical_local_and_pending_states(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
transitions = []
|
||||
record = publisher.store.record
|
||||
|
||||
def observe(publication_request, state, **evidence):
|
||||
transitions.append(state.value)
|
||||
return record(publication_request, state, **evidence)
|
||||
|
||||
with patch.object(publisher.store, "record", side_effect=observe):
|
||||
publisher.publish(request())
|
||||
|
||||
self.assertIn(PublicationState.LOCAL_TAG_CREATED.value, transitions)
|
||||
self.assertNotIn("TAG_CREATED_LOCAL", transitions)
|
||||
self.assertLess(
|
||||
transitions.index(PublicationState.GITEA_API_CONFIRMED.value),
|
||||
transitions.index(PublicationState.BASELINE_UPDATE_PENDING.value),
|
||||
)
|
||||
self.assertLess(
|
||||
transitions.index(PublicationState.BASELINE_UPDATE_PENDING.value),
|
||||
transitions.index(PublicationState.BASELINE_UPDATED.value),
|
||||
)
|
||||
|
||||
def test_legacy_local_tag_state_is_normalized_on_read_and_next_write(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
publisher = self.publisher(root)
|
||||
path = publisher.store.path(request().version)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**request().__dict__,
|
||||
"state": "TAG_CREATED_LOCAL",
|
||||
"tag_oid": TAG_OID,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = publisher.get_tag_publication_status(request().version)
|
||||
self.assertEqual(loaded["state"], "LOCAL_TAG_CREATED")
|
||||
self.assertEqual(
|
||||
json.loads(path.read_text(encoding="utf-8"))["state"],
|
||||
"TAG_CREATED_LOCAL",
|
||||
)
|
||||
|
||||
publisher.store.record(request(), PublicationState.LOCAL_TAG_CREATED)
|
||||
persisted = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(persisted["state"], "LOCAL_TAG_CREATED")
|
||||
self.assertNotIn("TAG_CREATED_LOCAL", json.dumps(persisted))
|
||||
|
||||
def test_unknown_persisted_state_fails_closed_without_mutation(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
publisher = self.publisher(root, git=git)
|
||||
path = publisher.store.path(request().version)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original = {
|
||||
**request().__dict__,
|
||||
"state": "UNRECOGNIZED_LEGACY_STATE",
|
||||
"tag_oid": TAG_OID,
|
||||
}
|
||||
path.write_text(json.dumps(original), encoding="utf-8")
|
||||
|
||||
status = publisher.get_tag_publication_status(request().version)
|
||||
self.assertEqual(
|
||||
status["state"], PublicationState.RECONCILIATION_REQUIRED.value
|
||||
)
|
||||
self.assertTrue(status["fail_closed"])
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), original)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_evidence_lineage_conflict_requires_manual_owner_review(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.lineage_error = PublicationConflict("evidence lineage mismatch")
|
||||
publisher = self.publisher(root, git=git)
|
||||
|
||||
with self.assertRaises(PublicationConflict):
|
||||
publisher.publish(request())
|
||||
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
||||
)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_push_failure_does_not_update_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.push_error = RuntimeError("push rejected")
|
||||
publisher = self.publisher(root, git=git)
|
||||
with self.assertRaises(PublicationPending):
|
||||
publisher.publish(request())
|
||||
baseline = json.loads((root / "baseline/current.json").read_text())
|
||||
state = publisher.store.load("V9R9C9")
|
||||
self.assertEqual(baseline["current_baseline"], "V9R9C8")
|
||||
self.assertEqual(state["state"], PublicationState.REMOTE_PUSH_PENDING.value)
|
||||
|
||||
def test_deployed_record_with_missing_remote_tag_fails_closed(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
publisher = self.publisher(root, git=git)
|
||||
publisher.store.record(
|
||||
request(),
|
||||
PublicationState.PRODUCTION_DEPLOYED,
|
||||
tag_oid=TAG_OID,
|
||||
deployment_evidence="sha256:" + ("a" * 64),
|
||||
)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(
|
||||
publisher.store.load("V9R9C9")["state"],
|
||||
"MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_gitea_api_failure_does_not_deploy_or_update_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
gitea.error = RuntimeError("gitea unavailable")
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(request())
|
||||
baseline = json.loads((root / "baseline/current.json").read_text())
|
||||
state = publisher.store.load("V9R9C9")
|
||||
self.assertEqual(baseline["current_baseline"], "V9R9C8")
|
||||
self.assertNotEqual(state["state"], PublicationState.PRODUCTION_DEPLOYED.value)
|
||||
self.assertEqual(state["state"], PublicationState.RECONCILIATION_REQUIRED.value)
|
||||
|
||||
def test_remote_unreachable_recovers_idempotently(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote_error = RuntimeError("network down")
|
||||
publisher = self.publisher(root, git=git)
|
||||
with self.assertRaises(PublicationPending):
|
||||
publisher.publish(request())
|
||||
git.remote_error = None
|
||||
result = publisher.reconcile(request())
|
||||
self.assertEqual(result["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
self.assertEqual(git.pushes, 1)
|
||||
|
||||
def test_same_name_different_remote_oid_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (WRONG, TARGET)
|
||||
publisher = self.publisher(root, git=git)
|
||||
with self.assertRaises(PublicationConflict):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(
|
||||
publisher.store.load("V9R9C9")["state"],
|
||||
PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
||||
)
|
||||
|
||||
def test_annotated_tag_requires_exact_object_and_peeled_target(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, WRONG)
|
||||
publisher = self.publisher(root, git=git)
|
||||
with self.assertRaises(PublicationConflict):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_local_bare_success_but_gitea_failure_is_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
gitea.visible = False
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(
|
||||
json.loads((root / "baseline/current.json").read_text())["current_baseline"],
|
||||
"V9R9C8",
|
||||
)
|
||||
|
||||
def test_restart_recovery_uses_persisted_state(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.push_error = RuntimeError("temporary")
|
||||
first = self.publisher(root, git=git)
|
||||
with self.assertRaises(PublicationPending):
|
||||
first.publish(request())
|
||||
git.push_error = None
|
||||
second = TagPublisher(root, git, FakeGitea(), "gitea")
|
||||
result = second.reconcile(request())
|
||||
self.assertEqual(result["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
|
||||
def test_baseline_write_failure_preserves_update_pending(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
publisher = self.publisher(root, git=git)
|
||||
atomic_write = publication_module.atomic_json_write
|
||||
|
||||
def fail_baseline(path, payload):
|
||||
if path == publisher.baseline_path:
|
||||
raise OSError("baseline write failed")
|
||||
return atomic_write(path, payload)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
publication_module, "atomic_json_write", side_effect=fail_baseline
|
||||
),
|
||||
self.assertRaises(PublicationReconciliationRequired),
|
||||
):
|
||||
publisher.publish(request())
|
||||
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.BASELINE_UPDATE_PENDING.value,
|
||||
)
|
||||
self.assertEqual(
|
||||
json.loads(publisher.baseline_path.read_text())["current_baseline"],
|
||||
"V9R9C8",
|
||||
)
|
||||
self.assertFalse(
|
||||
(root / "versions" / f"{request().version}.json").exists()
|
||||
)
|
||||
|
||||
def test_registry_write_failure_recovers_after_restart_without_tag_push(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
publisher = self.publisher(root, git=git)
|
||||
registry_path = root / "versions" / f"{request().version}.json"
|
||||
atomic_write = publication_module.atomic_json_write
|
||||
|
||||
def fail_registry(path, payload):
|
||||
if path == registry_path:
|
||||
raise OSError("registry write failed")
|
||||
return atomic_write(path, payload)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
publication_module, "atomic_json_write", side_effect=fail_registry
|
||||
),
|
||||
self.assertRaises(PublicationReconciliationRequired),
|
||||
):
|
||||
publisher.publish(request())
|
||||
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.BASELINE_UPDATE_PENDING.value,
|
||||
)
|
||||
self.assertEqual(
|
||||
json.loads(publisher.baseline_path.read_text())["current_baseline"],
|
||||
request().version,
|
||||
)
|
||||
self.assertFalse(registry_path.exists())
|
||||
|
||||
recovered = TagPublisher(root, git, FakeGitea(), "gitea")
|
||||
result = recovered.reconcile(request())
|
||||
self.assertEqual(result["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
self.assertTrue(registry_path.exists())
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_pending_recovery_fills_missing_baseline_from_matching_registry(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
publisher.store.record(
|
||||
request(),
|
||||
PublicationState.BASELINE_UPDATE_PENDING,
|
||||
tag_oid=TAG_OID,
|
||||
)
|
||||
registry_path = root / "versions" / f"{request().version}.json"
|
||||
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
**request().__dict__,
|
||||
"tag_oid": TAG_OID,
|
||||
"tag_object": TAG_OID,
|
||||
"registered": True,
|
||||
"enabled": True,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = publisher.reconcile(request())
|
||||
|
||||
self.assertEqual(result["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
self.assertEqual(
|
||||
json.loads(publisher.baseline_path.read_text())["current_baseline"],
|
||||
request().version,
|
||||
)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
self.assertGreater(git.remote_checks, 0)
|
||||
self.assertGreater(gitea.lookup_checks, 0)
|
||||
self.assertGreater(gitea.listing_checks, 0)
|
||||
|
||||
def test_pending_recovery_revalidates_remote_and_conflict_requires_owner(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (WRONG, TARGET)
|
||||
publisher = self.publisher(root, git=git)
|
||||
publisher.store.record(
|
||||
request(),
|
||||
PublicationState.BASELINE_UPDATE_PENDING,
|
||||
tag_oid=TAG_OID,
|
||||
)
|
||||
|
||||
with self.assertRaises(PublicationConflict):
|
||||
publisher.reconcile(request())
|
||||
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
||||
)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_reconciliation_is_idempotent_and_never_marks_deployed(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
publisher = self.publisher(root, git=git)
|
||||
first = publisher.publish(request())
|
||||
second = publisher.reconcile(request())
|
||||
self.assertEqual(first["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
self.assertEqual(second["state"], PublicationState.BASELINE_UPDATED.value)
|
||||
baseline = json.loads((root / "baseline/current.json").read_text())
|
||||
self.assertEqual(baseline["status"], PublicationState.BASELINE_UPDATED.value)
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_mark_deployed_requires_baseline_updated_and_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
publisher = self.publisher(root)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.mark_production_deployed(request(), "")
|
||||
publisher.publish(request())
|
||||
result = publisher.mark_production_deployed(
|
||||
request(), "sha256:" + ("a" * 64)
|
||||
)
|
||||
self.assertEqual(result["state"], PublicationState.PRODUCTION_DEPLOYED.value)
|
||||
|
||||
def test_deployed_fingerprint_change_requires_manual_owner_review(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
publisher = self.publisher(root, git=git)
|
||||
publisher.store.record(
|
||||
request(),
|
||||
PublicationState.PRODUCTION_DEPLOYED,
|
||||
tag_oid=TAG_OID,
|
||||
deployment_evidence="sha256:" + ("a" * 64),
|
||||
)
|
||||
changed = PublicationRequest(
|
||||
request().version,
|
||||
WRONG,
|
||||
request().evidence_closure_oid,
|
||||
request().approval_plan_id,
|
||||
)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(changed)
|
||||
state = publisher.store.load(request().version)
|
||||
self.assertEqual(
|
||||
state["state"], PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value
|
||||
)
|
||||
self.assertIn("target_oid", state["conflict_fields"])
|
||||
self.assertEqual(git.pushes, 0)
|
||||
|
||||
def test_deployed_gitea_tag_object_change_requires_manual_review(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
gitea.lookup = (WRONG, TARGET)
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
publisher.store.record(
|
||||
request(),
|
||||
PublicationState.PRODUCTION_DEPLOYED,
|
||||
tag_oid=TAG_OID,
|
||||
deployment_evidence="sha256:" + ("a" * 64),
|
||||
)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.publish(request())
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
||||
)
|
||||
|
||||
def test_mark_deployed_rejects_different_approval_plan_id(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
publisher = self.publisher(root)
|
||||
publisher.publish(request())
|
||||
changed = PublicationRequest(
|
||||
request().version,
|
||||
request().target_oid,
|
||||
request().evidence_closure_oid,
|
||||
"different-owner-plan",
|
||||
)
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.mark_production_deployed(
|
||||
changed, "sha256:" + ("a" * 64)
|
||||
)
|
||||
self.assertEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.MANUAL_OWNER_REVIEW_REQUIRED.value,
|
||||
)
|
||||
|
||||
def test_state_survives_writer_worktree_deletion(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
base = Path(td)
|
||||
first_workspace = base / "writer-a"
|
||||
first_workspace.mkdir()
|
||||
state_root = base / "durable-state"
|
||||
publisher = TagPublisher(
|
||||
first_workspace,
|
||||
FakeGit(),
|
||||
FakeGitea(),
|
||||
"gitea",
|
||||
state_root=state_root,
|
||||
)
|
||||
publisher.store.record(request(), PublicationState.REMOTE_PUSH_PENDING)
|
||||
shutil.rmtree(first_workspace)
|
||||
second_workspace = base / "writer-b"
|
||||
second_workspace.mkdir()
|
||||
recovered = TagPublisher(
|
||||
second_workspace,
|
||||
FakeGit(),
|
||||
FakeGitea(),
|
||||
"gitea",
|
||||
state_root=state_root,
|
||||
)
|
||||
self.assertEqual(
|
||||
recovered.get_tag_publication_status(request().version)["state"],
|
||||
PublicationState.REMOTE_PUSH_PENDING.value,
|
||||
)
|
||||
|
||||
def test_duplicate_publication_lock_serializes_writers(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
state_root = root / "durable-state"
|
||||
first = TagPublisher(
|
||||
root, FakeGit(), FakeGitea(), "gitea", state_root=state_root
|
||||
)
|
||||
second = TagPublisher(
|
||||
root, FakeGit(), FakeGitea(), "gitea", state_root=state_root
|
||||
)
|
||||
entered = threading.Event()
|
||||
|
||||
def wait_for_lock():
|
||||
with second.store.lock(request().version):
|
||||
entered.set()
|
||||
|
||||
with first.store.lock(request().version):
|
||||
worker = threading.Thread(target=wait_for_lock)
|
||||
worker.start()
|
||||
time.sleep(0.05)
|
||||
self.assertFalse(entered.is_set())
|
||||
worker.join(timeout=2)
|
||||
self.assertTrue(entered.is_set())
|
||||
|
||||
def test_registry_baseline_mismatch_blocks_deployment(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
publisher = self.publisher(root)
|
||||
publisher.publish(request())
|
||||
registry_path = root / "versions" / f"{request().version}.json"
|
||||
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
registry["target_oid"] = WRONG
|
||||
registry_path.write_text(json.dumps(registry), encoding="utf-8")
|
||||
with self.assertRaises(PublicationReconciliationRequired):
|
||||
publisher.mark_production_deployed(
|
||||
request(), "sha256:" + ("a" * 64)
|
||||
)
|
||||
self.assertNotEqual(
|
||||
publisher.store.load(request().version)["state"],
|
||||
PublicationState.PRODUCTION_DEPLOYED.value,
|
||||
)
|
||||
|
||||
def test_release_consistency_is_unknown_and_fail_closed_on_api_failure(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
git = FakeGit()
|
||||
git.remote = (TAG_OID, TARGET)
|
||||
gitea = FakeGitea()
|
||||
publisher = self.publisher(root, git=git, gitea=gitea)
|
||||
publisher.publish(request())
|
||||
gitea.error = RuntimeError("Authorization: token super-secret")
|
||||
result = publisher.run_release_acceptance(request())
|
||||
self.assertEqual(result["status"], "UNKNOWN")
|
||||
self.assertFalse(result["accepted"])
|
||||
self.assertTrue(result["fail_closed"])
|
||||
self.assertNotIn("super-secret", result["error"])
|
||||
|
||||
def test_release_consistency_fingerprint_failure_is_read_only(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
publisher = self.publisher(root)
|
||||
publisher.publish(request())
|
||||
before = publisher.store.load(request().version)
|
||||
changed = PublicationRequest(
|
||||
request().version,
|
||||
WRONG,
|
||||
request().evidence_closure_oid,
|
||||
request().approval_plan_id,
|
||||
)
|
||||
result = publisher.get_release_consistency(changed)
|
||||
after = publisher.store.load(request().version)
|
||||
self.assertEqual(result["status"], "FAIL")
|
||||
self.assertTrue(result["fail_closed"])
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_cli_evidence_lineage_mismatch_is_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
subprocess.run(["git", "init", "-q", "-b", "patch/test"], cwd=root, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.invalid"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "test"], cwd=root, check=True)
|
||||
(root / "a").write_text("a", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "a"], cwd=root, check=True)
|
||||
subprocess.run(["git", "commit", "-q", "-m", "a"], cwd=root, check=True)
|
||||
target = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=root, text=True
|
||||
).strip()
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "--orphan", "unrelated"], cwd=root, check=True
|
||||
)
|
||||
subprocess.run(["git", "rm", "-q", "-rf", "."], cwd=root, check=True)
|
||||
(root / "b").write_text("b", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "b"], cwd=root, check=True)
|
||||
subprocess.run(["git", "commit", "-q", "-m", "b"], cwd=root, check=True)
|
||||
evidence = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=root, text=True
|
||||
).strip()
|
||||
authoritative = root / "authoritative.git"
|
||||
subprocess.run(["git", "init", "-q", "--bare", str(authoritative)], check=True)
|
||||
subprocess.run(
|
||||
["git", "--git-dir", str(authoritative), "config", "remote.gitea.url", "https://g.cnnetmkt.top/git/aios/aios.git"],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "-C", str(root), "remote", "add", "authority", str(authoritative)], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "push", "-q", "authority", "--all"], check=True)
|
||||
module = ROOT / "active-runtime" / "app" / "gitea_tag_publication.py"
|
||||
cli_env = {
|
||||
**os.environ,
|
||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(authoritative),
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
}
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(module),
|
||||
"publish",
|
||||
"--workspace",
|
||||
str(root),
|
||||
"--version",
|
||||
"V9R9C9",
|
||||
"--target-oid",
|
||||
target,
|
||||
"--evidence-closure-oid",
|
||||
evidence,
|
||||
"--approval-plan-id",
|
||||
"owner-plan",
|
||||
"--gitea-api",
|
||||
"http://127.0.0.1:1/api/v1",
|
||||
"--gitea-repo",
|
||||
"aios/aios",
|
||||
"--gitea-token-file",
|
||||
str(root / "missing-token"),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=cli_env,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("evidence lineage mismatch", result.stdout)
|
||||
|
||||
def test_release_script_uses_shared_lineage_validation(self):
|
||||
script = (ROOT / "scripts" / "release_version.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("gitea_tag_publication.py\" publish", script)
|
||||
self.assertNotIn("merge-base --is-ancestor", script)
|
||||
|
||||
def test_error_redaction_hides_authorization_and_token_values(self):
|
||||
from gitea_tag_publication import redact
|
||||
|
||||
message = redact(
|
||||
"Authorization: Bearer abcdefghijklmnop token=super-secret "
|
||||
"https://user:password@example.invalid/repo.git"
|
||||
)
|
||||
self.assertNotIn("abcdefghijklmnop", message)
|
||||
self.assertNotIn("super-secret", message)
|
||||
self.assertNotIn("password", message)
|
||||
|
||||
def test_remote_parser_requires_annotated_tag_object_and_peeled_target(self):
|
||||
backend = GitTagBackend(Path("."))
|
||||
with (
|
||||
patch.object(backend, "_network_remote_url", return_value="ssh://gitea/repo"),
|
||||
patch.object(
|
||||
backend,
|
||||
"run",
|
||||
return_value=(
|
||||
f"{TAG_OID}\trefs/tags/V9R9C9\n"
|
||||
f"{TARGET}\trefs/tags/V9R9C9^{{}}"
|
||||
),
|
||||
),
|
||||
):
|
||||
self.assertEqual(backend.remote_tag("gitea", "V9R9C9"), (TAG_OID, TARGET))
|
||||
with (
|
||||
patch.object(backend, "_network_remote_url", return_value="ssh://gitea/repo"),
|
||||
patch.object(
|
||||
backend,
|
||||
"run",
|
||||
return_value=f"{TARGET}\trefs/tags/V9R9C9",
|
||||
),
|
||||
self.assertRaises(PublicationConflict),
|
||||
):
|
||||
backend.remote_tag("gitea", "V9R9C9")
|
||||
|
||||
def test_local_path_remote_cannot_count_as_gitea(self):
|
||||
backend = GitTagBackend(Path("."))
|
||||
with (
|
||||
patch.object(backend, "run", return_value="/data/aios-gitops/repo.git"),
|
||||
self.assertRaises(PublicationConflict),
|
||||
):
|
||||
backend._network_remote_url("origin")
|
||||
|
||||
def test_process_config_remote_url_is_resolved_for_subprocesses(self):
|
||||
backend = GitTagBackend(Path("."))
|
||||
with (
|
||||
patch.object(backend, "run", side_effect=RuntimeError("no such remote")),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "remote.gitea-local.url",
|
||||
"GIT_CONFIG_VALUE_0": "http://127.0.0.1:3000/aios/aios.git",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
self.assertEqual(
|
||||
backend._network_remote_url("gitea-local"),
|
||||
"http://127.0.0.1:3000/aios/aios.git",
|
||||
)
|
||||
|
||||
def test_gitea_lookup_and_listing_use_authenticated_api(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
token = Path(td) / "token"
|
||||
token.write_text("test-token", encoding="utf-8")
|
||||
client = GiteaTagClient("https://gitea.invalid/api/v1", "aios/aios", token)
|
||||
paths = []
|
||||
|
||||
def fake_get(path):
|
||||
paths.append(path)
|
||||
if "/git/refs/tags/" in path:
|
||||
return {"object": {"type": "tag", "sha": TAG_OID}}
|
||||
if "/git/tags/" in path:
|
||||
return {"object": {"type": "commit", "sha": TARGET}}
|
||||
return [{"name": "V9R9C9", "commit": {"sha": TARGET}}]
|
||||
|
||||
with patch.object(client, "_get", side_effect=fake_get):
|
||||
self.assertEqual(client.lookup_tag("V9R9C9"), (TAG_OID, TARGET))
|
||||
self.assertTrue(client.tag_visible_in_listing("V9R9C9", TARGET))
|
||||
self.assertTrue(any("/git/refs/tags/" in path for path in paths))
|
||||
self.assertTrue(any("/tags?limit=" in path for path in paths))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from gitea_tag_publication import ( # noqa: E402
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
PublicationRequest,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.getenv("AIOS_GITOPS_LIVE_GITEA_TEST") == "1",
|
||||
"set AIOS_GITOPS_LIVE_GITEA_TEST=1 on the isolated writer host",
|
||||
)
|
||||
class LiveTemporaryGiteaIntegrationTest(unittest.TestCase):
|
||||
def test_authenticated_ref_api_and_listing_against_temporary_repository(self):
|
||||
api_url = os.getenv(
|
||||
"AIOS_GITOPS_GITEA_API_URL", "http://127.0.0.1:3000/api/v1"
|
||||
).rstrip("/")
|
||||
credential = Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_LIVE_GITEA_TOKEN_FILE",
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_GITEA_TOKEN_FILE",
|
||||
"/etc/aios-gitops/credentials/gitea-git-http",
|
||||
),
|
||||
)
|
||||
)
|
||||
token_line = int(os.getenv("AIOS_GITOPS_LIVE_GITEA_TOKEN_LINE", "1"))
|
||||
token = credential.read_text(encoding="utf-8").splitlines()[token_line - 1].strip()
|
||||
name = "tag-atomicity-integration-" + uuid.uuid4().hex[:12]
|
||||
owner = os.getenv("AIOS_GITOPS_LIVE_GITEA_OWNER", "gitops-bot")
|
||||
|
||||
def api_request(method: str, path: str, payload=None):
|
||||
body = (
|
||||
json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
api_url + path,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"token {token}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw.decode("utf-8")) if raw else None
|
||||
|
||||
created = False
|
||||
try:
|
||||
repository = api_request(
|
||||
"POST",
|
||||
"/user/repos",
|
||||
{"name": name, "private": True, "auto_init": False},
|
||||
)
|
||||
created = True
|
||||
self.assertEqual(repository["full_name"], f"{owner}/{name}")
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
work = Path(td)
|
||||
subprocess.run(["git", "init", "-q", "-b", "patch/live"], cwd=work, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "gitops-test@example.invalid"],
|
||||
cwd=work,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "gitops-integration"],
|
||||
cwd=work,
|
||||
check=True,
|
||||
)
|
||||
(work / "release").write_text("target\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "release"], cwd=work, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "target"], cwd=work, check=True
|
||||
)
|
||||
target = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=work, text=True
|
||||
).strip()
|
||||
(work / "evidence").write_text("closure\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "evidence"], cwd=work, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "evidence"], cwd=work, check=True
|
||||
)
|
||||
evidence = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=work, text=True
|
||||
).strip()
|
||||
remote_url = f"http://127.0.0.1:3000/{owner}/{name}.git"
|
||||
subprocess.run(["git", "remote", "add", "gitea", remote_url], cwd=work, check=True)
|
||||
username_file = work / ".fixture-user"
|
||||
username_file.write_text(owner + "\n", encoding="utf-8")
|
||||
askpass = work / ".fixture-askpass"
|
||||
askpass.write_text(
|
||||
"#!/bin/sh\n"
|
||||
'case \"$1\" in\n'
|
||||
f' *Username*) sed -n 1p "{username_file}" ;;\n'
|
||||
f' *) sed -n {token_line}p "{credential}" ;;\n'
|
||||
"esac\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
askpass.chmod(0o700)
|
||||
git_env = {
|
||||
**os.environ,
|
||||
"GIT_ASKPASS": str(askpass),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
subprocess.run(
|
||||
["git", "push", "gitea", "HEAD:refs/heads/patch/live"],
|
||||
cwd=work,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=git_env,
|
||||
)
|
||||
version = "V0R0C1-LIVE-" + uuid.uuid4().hex[:8]
|
||||
release = PublicationRequest(version, target, evidence, "live-integration")
|
||||
backend = GitTagBackend(work)
|
||||
os.environ["GIT_ASKPASS"] = str(askpass)
|
||||
os.environ["GIT_TERMINAL_PROMPT"] = "0"
|
||||
backend.validate_evidence_lineage(release)
|
||||
tag_oid, peeled = backend.ensure_local_annotated_tag(release)
|
||||
backend.push_tag("gitea", version)
|
||||
self.assertEqual(backend.remote_tag("gitea", version), (tag_oid, peeled))
|
||||
client = GiteaTagClient(
|
||||
api_url,
|
||||
f"{owner}/{name}",
|
||||
credential,
|
||||
token_line=token_line,
|
||||
)
|
||||
self.assertEqual(client.repository_identity(), f"{owner}/{name}")
|
||||
self.assertEqual(client.lookup_tag(version), (tag_oid, target))
|
||||
self.assertTrue(client.tag_visible_in_listing(version, target))
|
||||
finally:
|
||||
if created:
|
||||
api_request("DELETE", f"/repos/{owner}/{name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,417 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import aios_isolated_writer_policy as writer_policy
|
||||
import isolated_writer_path_policy as path_policy
|
||||
from isolated_writer_authz import AuthorizationError, _validate_grant
|
||||
|
||||
|
||||
TARGET = "deploy/systemd/aios-codex-review-test-worker.service"
|
||||
BRANCH = "patch/codex-review-test-worker-20260724"
|
||||
AUDIENCE = "aios-codex-review-worker-writer.service"
|
||||
|
||||
VALID_UNIT = """\
|
||||
[Unit]
|
||||
Description=AIOS Codex Review/Test Worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aiman
|
||||
Group=gitops
|
||||
WorkingDirectory=/usr/libexec/aios-codex-review-test-worker/current
|
||||
Environment=AIOS_CODEX_WORKER_MODE=shadow
|
||||
Environment=AIOS_TASK_PLANE_EXECUTION_ENABLED=false
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RuntimeDirectory=aios-codex-review-test-worker
|
||||
StateDirectory=aios-codex-review-test-worker
|
||||
CacheDirectory=aios-codex-review-test-worker
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadOnlyPaths=/usr/libexec/aios-codex-review-test-worker
|
||||
ReadWritePaths=/run/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/lib/aios-codex-review-test-worker
|
||||
ReadWritePaths=/var/cache/aios-codex-review-test-worker
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
def document(*, paths=None, **updates):
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"policy_id": "codex-review-worker-path-policy-v1",
|
||||
"audience": AUDIENCE,
|
||||
"repository_identity": "aios/aios",
|
||||
"allowed_branch": BRANCH,
|
||||
"allowed_operations": {
|
||||
"commit_file.atomic": {
|
||||
"exact_paths": paths or [TARGET],
|
||||
"max_content_bytes": 32768,
|
||||
"content_profile": "systemd-codex-review-worker-v1",
|
||||
}
|
||||
},
|
||||
}
|
||||
value.update(updates)
|
||||
return value
|
||||
|
||||
|
||||
class ExactPathPolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(os.environ)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.policy_dir = self.root / "policies"
|
||||
self.policy_dir.mkdir(mode=0o750)
|
||||
self.policy_file = self.policy_dir / "writer.json"
|
||||
self.workspace = self.root / "workspace"
|
||||
self.workspace.mkdir(mode=0o700)
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT": "required",
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE": AUDIENCE,
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
"AIOS_GITOPS_ALLOWED_BRANCH": BRANCH,
|
||||
}
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
path_policy.clear_path_policy_cache()
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved)
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_policy(self, value=None):
|
||||
payload = document() if value is None else value
|
||||
raw = (
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n"
|
||||
)
|
||||
self.policy_file.write_text(raw, encoding="utf-8")
|
||||
self.policy_file.chmod(0o640)
|
||||
return path_policy.load_path_policy(
|
||||
self.policy_file,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def configured(self, loaded):
|
||||
return mock.patch.object(
|
||||
path_policy,
|
||||
"configured_path_policy",
|
||||
return_value=loaded,
|
||||
)
|
||||
|
||||
def test_exact_systemd_path_and_exact_token_path_are_allowed(self):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded):
|
||||
self.assertEqual(TARGET, writer_policy.validate_path(TARGET, self.workspace))
|
||||
writer_policy.validate_content_for_path(TARGET, VALID_UNIT)
|
||||
|
||||
def test_token_path_cannot_expand_service_policy(self):
|
||||
loaded = self.write_policy(document(paths=["deploy/systemd/other.service"]))
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_service_path_rejected"
|
||||
):
|
||||
writer_policy.validate_path(TARGET, self.workspace)
|
||||
|
||||
def test_service_policy_cannot_expand_token_path(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
grant = {
|
||||
"schema_version": 2,
|
||||
"audience": AUDIENCE,
|
||||
"source_cidrs": ["127.0.0.1/32"],
|
||||
"created_at": (now - timedelta(seconds=5)).isoformat(),
|
||||
"not_before": (now - timedelta(seconds=2)).isoformat(),
|
||||
"expires_at": (now + timedelta(minutes=5)).isoformat(),
|
||||
"revoked": False,
|
||||
"allowed_operations": ["commit_file.atomic"],
|
||||
"allowed_branch": BRANCH,
|
||||
"allowed_paths": ["deploy/systemd/other.service"],
|
||||
"approved_base_oid": "a" * 40,
|
||||
}
|
||||
with mock.patch(
|
||||
"isolated_writer_authz._git_is_ancestor", return_value=True
|
||||
), self.assertRaisesRegex(AuthorizationError, "writer_token_path_rejected"):
|
||||
_validate_grant(
|
||||
grant,
|
||||
audience=AUDIENCE,
|
||||
source_ip="127.0.0.1",
|
||||
operation="commit_file.atomic",
|
||||
branch=BRANCH,
|
||||
path=TARGET,
|
||||
workspace=self.workspace,
|
||||
current_time=now,
|
||||
)
|
||||
|
||||
def test_other_service_file_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_service_path_rejected"
|
||||
):
|
||||
writer_policy.validate_path(
|
||||
"deploy/systemd/other.service", self.workspace
|
||||
)
|
||||
|
||||
def test_path_traversal_is_rejected(self):
|
||||
for value in ("deploy/systemd/../x", "../deploy/systemd/x"):
|
||||
with self.subTest(value=value), self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(value, self.workspace)
|
||||
|
||||
def test_url_encoded_traversal_is_rejected(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(
|
||||
"deploy/systemd/%2e%2e/x", self.workspace
|
||||
)
|
||||
|
||||
def test_symlink_policy_file_is_rejected(self):
|
||||
self.write_policy()
|
||||
link = self.policy_dir / "link.json"
|
||||
link.symlink_to(self.policy_file)
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError, "isolated_writer_path_policy_symlink_rejected"
|
||||
):
|
||||
path_policy.load_path_policy(
|
||||
link,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def test_world_writable_policy_file_is_rejected(self):
|
||||
self.write_policy()
|
||||
self.policy_file.chmod(0o646)
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_permissions_rejected",
|
||||
):
|
||||
path_policy.load_path_policy(
|
||||
self.policy_file,
|
||||
expected_owner_uid=os.getuid(),
|
||||
expected_group_gid=os.getgid(),
|
||||
)
|
||||
|
||||
def test_unknown_policy_field_is_rejected(self):
|
||||
value = document()
|
||||
value["unexpected"] = True
|
||||
with self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_fields_rejected",
|
||||
):
|
||||
self.write_policy(value)
|
||||
|
||||
def test_audience_mismatch_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
os.environ["AIOS_GITOPS_PATH_POLICY_FILE"] = str(self.policy_file)
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "other.service"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_audience_rejected",
|
||||
):
|
||||
path_policy.configured_path_policy()
|
||||
|
||||
def test_branch_mismatch_is_rejected(self):
|
||||
loaded = self.write_policy()
|
||||
os.environ["AIOS_GITOPS_PATH_POLICY_FILE"] = str(self.policy_file)
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/other"
|
||||
path_policy.clear_path_policy_cache()
|
||||
with mock.patch.object(
|
||||
path_policy, "load_path_policy", return_value=loaded
|
||||
), self.assertRaisesRegex(
|
||||
path_policy.PathPolicyError,
|
||||
"isolated_writer_path_policy_branch_rejected",
|
||||
):
|
||||
path_policy.configured_path_policy()
|
||||
|
||||
def assert_profile_rejected(self, content):
|
||||
loaded = self.write_policy()
|
||||
with self.configured(loaded), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_systemd_profile_rejected"
|
||||
):
|
||||
writer_policy.validate_content_for_path(TARGET, content)
|
||||
|
||||
def test_disallowed_execution_and_credential_directives_are_rejected(self):
|
||||
directives = (
|
||||
"ExecStartPre=/usr/bin/id",
|
||||
"ExecStartPost=/usr/bin/id",
|
||||
"ExecReload=/usr/bin/id",
|
||||
"ExecStop=/usr/bin/id",
|
||||
"ExecStopPost=/usr/bin/id",
|
||||
"ExecCondition=/usr/bin/true",
|
||||
"EnvironmentFile=/run/worker.env",
|
||||
"PassEnvironment=PATH",
|
||||
"LoadCredential=worker:/run/worker",
|
||||
"SetCredential=worker:value",
|
||||
"ImportCredential=worker",
|
||||
"StandardInput=socket",
|
||||
"StandardOutput=socket",
|
||||
)
|
||||
for directive in directives:
|
||||
with self.subTest(directive=directive):
|
||||
self.assert_profile_rejected(
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
f"Restart=on-failure\n{directive}",
|
||||
)
|
||||
)
|
||||
|
||||
def test_disallowed_namespace_and_capability_directives_are_rejected(self):
|
||||
directives = (
|
||||
"RootDirectory=/srv/worker",
|
||||
"RootImage=/srv/worker.raw",
|
||||
"ReadWritePaths=/",
|
||||
"BindPaths=/etc:/etc",
|
||||
"BindReadOnlyPaths=/etc:/etc",
|
||||
"TemporaryFileSystem=/etc",
|
||||
"CapabilityBoundingSet=CAP_SYS_ADMIN",
|
||||
"AmbientCapabilities=CAP_SYS_ADMIN",
|
||||
"DeviceAllow=/dev/sda rw",
|
||||
"SupplementaryGroups=docker",
|
||||
"Sockets=unsafe.socket",
|
||||
)
|
||||
for directive in directives:
|
||||
with self.subTest(directive=directive):
|
||||
self.assert_profile_rejected(
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
f"Restart=on-failure\n{directive}",
|
||||
)
|
||||
)
|
||||
|
||||
def test_duplicate_single_directives_and_sections_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("User=aiman", "User=aiman\nUser=aiman"),
|
||||
VALID_UNIT.replace(
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test\n"
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
),
|
||||
VALID_UNIT + "\n[Service]\nType=simple\n",
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_empty_reset_continuation_crlf_nul_and_long_lines_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace(
|
||||
"ExecStart=/usr/bin/python3 -m task_plane.workers.codex_review_test",
|
||||
"ExecStart=",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
"Restart=on-failure\n continuation-injection",
|
||||
),
|
||||
VALID_UNIT.replace("\n", "\r\n"),
|
||||
VALID_UNIT.replace("Restart=on-failure", "Restart=on-failure\x00"),
|
||||
VALID_UNIT.replace(
|
||||
"Description=AIOS Codex Review/Test Worker",
|
||||
"Description=" + ("A" * 4097),
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(kind=repr(value[:80])):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_unknown_environment_and_production_execution_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace(
|
||||
"Environment=PYTHONDONTWRITEBYTECODE=1",
|
||||
"Environment=UNKNOWN_KEY=value",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=false",
|
||||
"AIOS_TASK_PLANE_EXECUTION_ENABLED=true",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"Environment=PYTHONDONTWRITEBYTECODE=1",
|
||||
"Environment=Authorization: Bearer placeholder",
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_identity_working_directory_and_path_prefix_bypasses_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("User=aiman", "User=root"),
|
||||
VALID_UNIT.replace(
|
||||
"/usr/libexec/aios-codex-review-test-worker/current",
|
||||
"/data/aios-gitops/worktrees/worker",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"ReadWritePaths=/run/aios-codex-review-test-worker",
|
||||
"ReadWritePaths=/etc",
|
||||
),
|
||||
VALID_UNIT.replace(
|
||||
"ReadOnlyPaths=/data/aios-gitops/repo.git",
|
||||
"ReadOnlyPaths=/data",
|
||||
),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_unknown_section_directive_and_unicode_lookalike_are_rejected(self):
|
||||
bad_values = (
|
||||
VALID_UNIT.replace("[Install]", "[Socket]"),
|
||||
VALID_UNIT.replace(
|
||||
"Restart=on-failure",
|
||||
"Restart=on-failure\nProtectClock=true",
|
||||
),
|
||||
VALID_UNIT.replace("ExecStart=", "\u0415xecStart="),
|
||||
)
|
||||
for value in bad_values:
|
||||
with self.subTest(value=value):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_invalid_unicode_surrogate_and_missing_final_newline_are_rejected(self):
|
||||
for value in (VALID_UNIT + "\ud800", VALID_UNIT.rstrip("\n")):
|
||||
with self.subTest(value=repr(value[-30:])):
|
||||
self.assert_profile_rejected(value)
|
||||
|
||||
def test_writer_request_cannot_modify_policy_file(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
writer_policy.validate_path(
|
||||
"etc/aios-gitops/writer-path-policies/codex-review-worker.json",
|
||||
self.workspace,
|
||||
)
|
||||
|
||||
def test_coarse_file_and_commit_endpoints_remain_forbidden(self):
|
||||
for endpoint in ("/api/file", "/api/commit"):
|
||||
with self.subTest(endpoint=endpoint), self.assertRaisesRegex(
|
||||
PermissionError, "isolated_writer_operation_forbidden"
|
||||
):
|
||||
writer_policy.validate_endpoint(
|
||||
"POST", endpoint, self.workspace, self.root
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,174 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
import aios_isolated_writer_policy as policy
|
||||
|
||||
|
||||
class IsolatedWriterPolicyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(os.environ)
|
||||
os.environ["AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT"] = "required"
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BRANCH"] = "patch/aios-agent-task-control-plane-20260724"
|
||||
os.environ["AIOS_GITOPS_ALLOWED_BASE_OID"] = "a8e97868976b0dce17b7cb8487eadc1d9c687622"
|
||||
os.environ["AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED"] = "2"
|
||||
os.environ["AIOS_GITOPS_SERVICE_AUDIENCE"] = "test-isolated-writer.service"
|
||||
os.environ["AIOS_GITOPS_TOKEN_REGISTRY_V2"] = "/tmp/test-writer-token-registry.sqlite3"
|
||||
os.environ["AIOS_GITOPS_WRITER_OPERATION_LEDGER"] = "/tmp/test-writer-operations.sqlite3"
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved)
|
||||
|
||||
def test_only_expected_branch_is_accepted(self):
|
||||
policy.validate_branch_request({
|
||||
"branch": os.environ["AIOS_GITOPS_ALLOWED_BRANCH"],
|
||||
"base": "main",
|
||||
})
|
||||
for branch in ("patch/other", "main", ""):
|
||||
with self.subTest(branch=branch), self.assertRaises(PermissionError):
|
||||
policy.validate_branch_request({"branch": branch, "base": "main"})
|
||||
|
||||
def test_write_paths_are_scoped_and_canonical(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
workspace = Path(root)
|
||||
self.assertEqual(
|
||||
policy.validate_path("active-runtime/app/x.py", workspace),
|
||||
"active-runtime/app/x.py",
|
||||
)
|
||||
self.assertEqual(policy.validate_path("docs/x.md", workspace), "docs/x.md")
|
||||
self.assertEqual(
|
||||
policy.validate_path("scripts/task-plane/run.sh", workspace),
|
||||
"scripts/task-plane/run.sh",
|
||||
)
|
||||
self.assertEqual(
|
||||
policy.validate_path("reports/task-plane.json", workspace),
|
||||
"reports/task-plane.json",
|
||||
)
|
||||
rejected = (
|
||||
"",
|
||||
"../docs/x.md",
|
||||
"/docs/x.md",
|
||||
"docs/../x.md",
|
||||
"%2e%2e/docs/x.md",
|
||||
"%2Fdocs/x.md",
|
||||
r"C:\temp\x",
|
||||
r"\\server\share\x",
|
||||
"./docs/x.md",
|
||||
"docs//x.md",
|
||||
"docs/./x.md",
|
||||
"docs/x.md\x00tail",
|
||||
"baseline/current.json",
|
||||
)
|
||||
for value in rejected:
|
||||
with self.subTest(value=value), self.assertRaises(PermissionError):
|
||||
policy.validate_path(value, workspace)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "symlink creation requires Windows privilege")
|
||||
def test_external_symlink_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as outside:
|
||||
workspace = Path(root)
|
||||
(workspace / "docs").symlink_to(Path(outside), target_is_directory=True)
|
||||
with self.assertRaises(PermissionError):
|
||||
policy.validate_path("docs/escape.md", workspace)
|
||||
|
||||
def test_force_and_forbidden_endpoints_are_rejected(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
policy.validate_branch_request({
|
||||
"branch": os.environ["AIOS_GITOPS_ALLOWED_BRANCH"],
|
||||
"force": True,
|
||||
})
|
||||
for endpoint in (
|
||||
"/api/tag",
|
||||
"/api/rollback",
|
||||
"/api/model",
|
||||
"/api/model-priority",
|
||||
"/api/baseline",
|
||||
"/api/registry",
|
||||
"/api/deploy",
|
||||
):
|
||||
self.assertNotIn(endpoint, policy.WRITE_ENDPOINTS)
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "authoritative writer runs on POSIX")
|
||||
def test_approved_base_is_ancestor_even_when_main_drifts(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
workspace = Path(root) / "writer"
|
||||
workspace.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "task-plane@example.invalid"],
|
||||
cwd=workspace,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Task Plane Test"],
|
||||
cwd=workspace,
|
||||
check=True,
|
||||
)
|
||||
(workspace / "README.md").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=workspace, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "base"], cwd=workspace, check=True, capture_output=True)
|
||||
base = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=workspace, text=True
|
||||
).strip()
|
||||
branch = os.environ["AIOS_GITOPS_ALLOWED_BRANCH"]
|
||||
subprocess.run(["git", "checkout", "-b", branch], cwd=workspace, check=True, capture_output=True)
|
||||
(workspace / "README.md").write_text("branch\n", encoding="utf-8")
|
||||
subprocess.run(["git", "commit", "-am", "branch"], cwd=workspace, check=True, capture_output=True)
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_ALLOWED_BASE_OID": base,
|
||||
"AIOS_GITOPS_WRITES_ENABLED": "true",
|
||||
"AIOS_GITOPS_RUNTIME_ROLE": "isolated-writer",
|
||||
"AIOS_GITOPS_WORKSPACE_ROLE": "isolated-writer",
|
||||
"AIOS_GITOPS_REPOSITORY_IDENTITY": "aios/aios",
|
||||
"AIOS_GITOPS_AUTHORITATIVE_GIT_DIR": str(workspace / ".git"),
|
||||
"AIOS_GITOPS_APPROVED_WRITER_ROOT": str(Path(root)),
|
||||
}
|
||||
)
|
||||
for name in policy._DENY_FLAGS:
|
||||
os.environ[name] = "false"
|
||||
policy.require_service_invariants(workspace, workspace / ".git")
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX audit mode and O_NOFOLLOW contract")
|
||||
def test_audit_path_is_contained_durable_and_redacted(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
approved = Path(root) / "approved"
|
||||
approved.mkdir(mode=0o750)
|
||||
log = approved / "writer.jsonl"
|
||||
os.environ["AIOS_GITOPS_APPROVED_AUDIT_ROOT"] = str(approved)
|
||||
os.environ["AIOS_GITOPS_AUDIT_LOG"] = str(log)
|
||||
policy.append_audit_record(
|
||||
{
|
||||
"action": "commit",
|
||||
"Authorization": "Bearer must-not-appear",
|
||||
"payload": "token=must-not-appear",
|
||||
}
|
||||
)
|
||||
text = log.read_text(encoding="utf-8")
|
||||
self.assertNotIn("must-not-appear", text)
|
||||
self.assertEqual(0o600, log.stat().st_mode & 0o777)
|
||||
os.environ["AIOS_GITOPS_AUDIT_LOG"] = str(Path(root) / "escape.jsonl")
|
||||
with self.assertRaises(PermissionError):
|
||||
policy.approved_audit_log_path()
|
||||
|
||||
|
||||
class SecretScannerBoundaryTests(unittest.TestCase):
|
||||
def test_task_control_plane_branch_is_not_an_api_key(self):
|
||||
import aios_chatgpt_actions_api as api
|
||||
|
||||
payload = (
|
||||
'{"branch":"patch/aios-agent-task-control-plane-20260724",'
|
||||
'"created_by":"agent-task-plane-writer"}'
|
||||
)
|
||||
self.assertFalse(api.content_has_secret(payload))
|
||||
|
||||
def test_real_openai_style_key_is_still_rejected(self):
|
||||
import aios_chatgpt_actions_api as api
|
||||
|
||||
payload = '{"value":"sk-abcdefghijklmnopqrstuvwxyz123456"}'
|
||||
self.assertTrue(api.content_has_secret(payload))
|
||||
@@ -0,0 +1,471 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
|
||||
import aios_isolated_writer_policy as policy
|
||||
from isolated_writer_authz import AuthorizationError, authorize, operation_for
|
||||
from isolated_writer_commit_file import (
|
||||
AtomicCommitFile,
|
||||
CommitFileError,
|
||||
OperationLedger,
|
||||
SimulatedCrash,
|
||||
)
|
||||
from isolated_writer_token_registry import TokenRegistry, token_digest
|
||||
|
||||
|
||||
BRANCH = "patch/codex-review-test-worker-20260724"
|
||||
AUDIENCE = "aios-codex-review-worker-writer.service"
|
||||
TASK_ID = "AIOS-CODEX-WRITER-SMOKE-20260724"
|
||||
TARGET_PATH = "docs/reliability/codex-review-worker-writer-smoke.json"
|
||||
|
||||
|
||||
def run(cwd, *args):
|
||||
return subprocess.check_output(args, cwd=cwd, text=True).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyGrant:
|
||||
token_id: str = "test-token-id"
|
||||
task_id: str = TASK_ID
|
||||
operation: str = "commit_file.atomic"
|
||||
record: dict = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.record is None:
|
||||
self.record = {"audience": AUDIENCE, "_source_ip": "127.0.0.1"}
|
||||
|
||||
|
||||
class RepositoryCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = dict(os.environ)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.authoritative = self.root / "authoritative.git"
|
||||
self.gitea = self.root / "gitea.git"
|
||||
self.seed = self.root / "seed"
|
||||
self.workspace = self.root / "writer"
|
||||
subprocess.run(["git", "init", "--bare", str(self.authoritative)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(self.seed)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.email", "writer@example.invalid"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Writer Test"], cwd=self.seed, check=True)
|
||||
(self.seed / "README.md").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "base"], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(["git", "branch", "-M", "main"], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "remote", "add", "origin", str(self.authoritative)], cwd=self.seed, check=True)
|
||||
subprocess.run(["git", "push", "origin", "main"], cwd=self.seed, check=True, capture_output=True)
|
||||
self.base = run(self.seed, "git", "rev-parse", "HEAD")
|
||||
subprocess.run(["git", "checkout", "-b", BRANCH], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(["git", "push", "origin", BRANCH], cwd=self.seed, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "--git-dir", str(self.authoritative), "worktree", "add", str(self.workspace), BRANCH],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["git", "config", "user.email", "writer@example.invalid"], cwd=self.workspace, check=True)
|
||||
subprocess.run(["git", "config", "user.name", "Writer Test"], cwd=self.workspace, check=True)
|
||||
subprocess.run(["git", "clone", "--bare", str(self.authoritative), str(self.gitea)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "remote", "add", "gitea-http", str(self.gitea)], cwd=self.workspace, check=True)
|
||||
self.registry = TokenRegistry(self.root / "registry.sqlite3")
|
||||
os.environ.update({
|
||||
"AIOS_GITOPS_ALLOWED_BRANCH": BRANCH,
|
||||
"AIOS_GITOPS_TOKEN_POLICY_VERSION_REQUIRED": "2",
|
||||
"AIOS_GITOPS_SERVICE_AUDIENCE": AUDIENCE,
|
||||
"AIOS_GITOPS_ISOLATED_WRITER_ENFORCEMENT": "required",
|
||||
})
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.clear()
|
||||
os.environ.update(self.saved)
|
||||
self.temp.cleanup()
|
||||
|
||||
def grant(self, **updates):
|
||||
now = datetime.now(timezone.utc)
|
||||
grant = {
|
||||
"token_id": "smoke-" + hashlib.sha256(os.urandom(16)).hexdigest()[:12],
|
||||
"audience": AUDIENCE,
|
||||
"task_id": TASK_ID,
|
||||
"source_cidrs": ["127.0.0.1/32", "::1/128"],
|
||||
"allowed_branch": BRANCH,
|
||||
"approved_base_oid": self.base,
|
||||
"allowed_operations": [
|
||||
"runtime.status.read",
|
||||
"baseline.read",
|
||||
"patches.read",
|
||||
"commit_file.atomic",
|
||||
],
|
||||
"allowed_paths": [TARGET_PATH],
|
||||
"not_before": (now - timedelta(seconds=2)).isoformat(),
|
||||
"expires_at": (now + timedelta(minutes=20)).isoformat(),
|
||||
"max_uses": {
|
||||
"runtime.status.read": 2,
|
||||
"baseline.read": 2,
|
||||
"patches.read": 2,
|
||||
"commit_file.atomic": 4,
|
||||
},
|
||||
"issued_by": "owner-authorized-local-maintenance-executor",
|
||||
"created_at": (now - timedelta(seconds=3)).isoformat(),
|
||||
}
|
||||
grant.update(updates)
|
||||
return grant
|
||||
|
||||
def issue(self, **updates):
|
||||
return self.registry.issue(self.grant(**updates))
|
||||
|
||||
def auth(self, token, **updates):
|
||||
args = {
|
||||
"method": "GET",
|
||||
"path": "/api/runtime-status",
|
||||
"source_ip": "127.0.0.1",
|
||||
"workspace": self.workspace,
|
||||
}
|
||||
args.update(updates)
|
||||
return authorize(self.registry, token, **args)
|
||||
|
||||
def request(self, key="writer-smoke-0001", content='{"status":"READY"}\n'):
|
||||
return {
|
||||
"branch": BRANCH,
|
||||
"path": TARGET_PATH,
|
||||
"content": content,
|
||||
"message": "chore(task-plane): verify Codex writer endpoint",
|
||||
"expected_head_oid": run(self.workspace, "git", "rev-parse", "HEAD"),
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
def writer(self, crash=None, remote="gitea-http", audit=None):
|
||||
return AtomicCommitFile(
|
||||
workspace=self.workspace,
|
||||
bare_repo=self.authoritative,
|
||||
ledger=OperationLedger(self.root / "operations.sqlite3"),
|
||||
remote=remote,
|
||||
audit=audit or (lambda record: None),
|
||||
crash_after_state=crash,
|
||||
)
|
||||
|
||||
|
||||
class TokenPolicyV2Tests(RepositoryCase):
|
||||
def test_v1_coarse_scope_token_is_rejected(self):
|
||||
token, record = self.issue()
|
||||
record["schema_version"] = 1
|
||||
with self.registry._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE token_grants SET grant_json=? WHERE token_id=?",
|
||||
(json.dumps(record), record["token_id"]),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_policy_v2_required"):
|
||||
self.auth(token)
|
||||
|
||||
def test_audience_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(audience="other.service")
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_audience_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_source_ip_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(source_cidrs=["192.0.2.0/24"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_source_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_branch_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(allowed_branch="patch/other")
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_branch_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_operation_mismatch_is_rejected(self):
|
||||
token, _ = self.issue(allowed_operations=["baseline.read"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_operation_rejected"):
|
||||
self.auth(token)
|
||||
|
||||
def test_path_mismatch_is_rejected(self):
|
||||
token, _ = self.issue()
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_path_rejected"):
|
||||
self.auth(
|
||||
token,
|
||||
method="POST",
|
||||
path="/api/commit-file",
|
||||
branch=BRANCH,
|
||||
target_path="docs/other.json",
|
||||
)
|
||||
|
||||
def test_expired_token_is_rejected(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
token, _ = self.issue(
|
||||
created_at=(now - timedelta(minutes=10)).isoformat(),
|
||||
not_before=(now - timedelta(minutes=9)).isoformat(),
|
||||
expires_at=(now - timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_expired"):
|
||||
self.auth(token)
|
||||
|
||||
def test_not_before_token_is_rejected(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
token, _ = self.issue(
|
||||
created_at=now.isoformat(),
|
||||
not_before=(now + timedelta(minutes=1)).isoformat(),
|
||||
expires_at=(now + timedelta(minutes=5)).isoformat(),
|
||||
)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_not_yet_valid"):
|
||||
self.auth(token)
|
||||
|
||||
def test_revoked_token_is_rejected(self):
|
||||
token, grant = self.issue()
|
||||
self.assertTrue(self.registry.revoke(grant["token_id"]))
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_revoked"):
|
||||
self.auth(token)
|
||||
|
||||
def test_operation_use_count_is_atomic_and_exhausted(self):
|
||||
token, _ = self.issue(max_uses={
|
||||
"runtime.status.read": 1,
|
||||
"baseline.read": 1,
|
||||
"patches.read": 1,
|
||||
"commit_file.atomic": 1,
|
||||
})
|
||||
self.auth(token)
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_operation_exhausted"):
|
||||
self.auth(token)
|
||||
|
||||
def test_concurrent_use_count_never_exceeds_limit(self):
|
||||
token, _ = self.issue(max_uses={
|
||||
"runtime.status.read": 2,
|
||||
"baseline.read": 1,
|
||||
"patches.read": 1,
|
||||
"commit_file.atomic": 1,
|
||||
})
|
||||
successes, failures = [], []
|
||||
|
||||
def invoke():
|
||||
try:
|
||||
successes.append(self.auth(token))
|
||||
except AuthorizationError as exc:
|
||||
failures.append(exc.code)
|
||||
|
||||
threads = [threading.Thread(target=invoke) for _ in range(6)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(2, len(successes))
|
||||
self.assertEqual(4, failures.count("writer_token_operation_exhausted"))
|
||||
|
||||
def test_registry_does_not_store_plaintext_token(self):
|
||||
token, _ = self.issue()
|
||||
registry_bytes = b"".join(
|
||||
item.read_bytes()
|
||||
for item in self.registry.path.parent.glob(self.registry.path.name + "*")
|
||||
if item.is_file()
|
||||
)
|
||||
self.assertNotIn(token.encode(), registry_bytes)
|
||||
with self.registry._connect() as db:
|
||||
stored = db.execute("SELECT token_digest FROM token_grants").fetchone()[0]
|
||||
self.assertEqual(token_digest(token), stored)
|
||||
|
||||
def test_coarse_routes_have_no_operation_mapping(self):
|
||||
for path in ("/api/file", "/api/commit", "/api/tag", "/api/rollback", "/api/model"):
|
||||
with self.subTest(path=path), self.assertRaises(AuthorizationError) as captured:
|
||||
operation_for("POST", path)
|
||||
self.assertEqual(403, captured.exception.status)
|
||||
|
||||
def test_route_mapping_is_fixed_not_request_controlled(self):
|
||||
self.assertEqual("runtime.status.read", operation_for("GET", "/api/runtime-status"))
|
||||
self.assertEqual("baseline.read", operation_for("GET", "/api/baseline"))
|
||||
self.assertEqual("patches.read", operation_for("GET", "/api/patches"))
|
||||
self.assertEqual("commit_file.atomic", operation_for("POST", "/api/commit-file"))
|
||||
|
||||
def test_x_forwarded_for_cannot_replace_socket_peer(self):
|
||||
token, _ = self.issue(source_cidrs=["203.0.113.9/32"])
|
||||
with self.assertRaisesRegex(AuthorizationError, "writer_token_source_rejected"):
|
||||
self.auth(token, source_ip="127.0.0.1")
|
||||
|
||||
|
||||
class AtomicCommitFileTests(RepositoryCase):
|
||||
def test_commit_file_succeeds_and_verifies_bare_and_gitea(self):
|
||||
result = self.writer().execute(DummyGrant(), self.request())
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
self.assertEqual(
|
||||
result["commit_oid"],
|
||||
run(self.root, "git", "--git-dir", str(self.gitea), "rev-parse", f"refs/heads/{BRANCH}"),
|
||||
)
|
||||
|
||||
def test_same_idempotency_key_returns_same_commit(self):
|
||||
request = self.request()
|
||||
first = self.writer().execute(DummyGrant(), request)
|
||||
count = run(self.workspace, "git", "rev-list", "--count", "HEAD")
|
||||
second = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(first["commit_oid"], second["commit_oid"])
|
||||
self.assertEqual(count, run(self.workspace, "git", "rev-list", "--count", "HEAD"))
|
||||
self.assertTrue(second["idempotent"])
|
||||
|
||||
def test_same_key_with_different_payload_returns_409(self):
|
||||
request = self.request()
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request["content"] = '{"status":"DIFFERENT"}\n'
|
||||
with self.assertRaises(CommitFileError) as captured:
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(409, captured.exception.status)
|
||||
|
||||
def test_same_key_from_different_task_returns_409(self):
|
||||
request = self.request()
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
other = DummyGrant(token_id="other-token", task_id="OTHER-TASK")
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_idempotency_owner_conflict"):
|
||||
self.writer().execute(other, request)
|
||||
|
||||
def test_file_written_crash_is_recoverable(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="FILE_WRITTEN").execute(DummyGrant(), request)
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
|
||||
def test_committed_crash_is_recoverable_without_second_commit(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="COMMITTED").execute(DummyGrant(), request)
|
||||
committed = run(self.workspace, "git", "rev-parse", "HEAD")
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(committed, result["commit_oid"])
|
||||
|
||||
def test_pushed_crash_only_reverifies(self):
|
||||
request = self.request()
|
||||
with self.assertRaises(SimulatedCrash):
|
||||
self.writer(crash="PUSHED").execute(DummyGrant(), request)
|
||||
result = self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("COMPLETED", result["state"])
|
||||
|
||||
def test_push_failure_is_failed_recoverable(self):
|
||||
request = self.request()
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_gitea_push_failed"):
|
||||
self.writer(remote="missing-remote").execute(DummyGrant(), request)
|
||||
row = OperationLedger(self.root / "operations.sqlite3").get(request["idempotency_key"])
|
||||
self.assertEqual("FAILED_RECOVERABLE", row["state"])
|
||||
|
||||
def test_branch_head_conflict_requires_manual_review(self):
|
||||
request = self.request()
|
||||
ledger = OperationLedger(self.root / "operations.sqlite3")
|
||||
ledger.get_or_create(DummyGrant(), request, __import__("isolated_writer_commit_file").canonical_digest(request))
|
||||
(self.workspace / "README.md").write_text("drift\n", encoding="utf-8")
|
||||
subprocess.run(["git", "commit", "-am", "drift"], cwd=self.workspace, check=True, capture_output=True)
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_branch_head_conflict"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual("MANUAL_REVIEW_REQUIRED", ledger.get(request["idempotency_key"])["state"])
|
||||
|
||||
def test_extra_staged_file_is_rejected(self):
|
||||
(self.workspace / "README.md").write_text("staged\n", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=self.workspace, check=True)
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_extra_staged_files_rejected"):
|
||||
self.writer().execute(DummyGrant(), self.request())
|
||||
|
||||
def test_path_traversal_and_protected_roots_are_rejected(self):
|
||||
for path in (
|
||||
"../docs/x", "%2e%2e/docs/x", "/etc/x", r"C:\temp\x",
|
||||
r"\\server\share\x", "baseline/current.json", "runtime/x",
|
||||
):
|
||||
request = self.request(key="writer-path-" + hashlib.sha256(path.encode()).hexdigest()[:12])
|
||||
request["path"] = path
|
||||
with self.subTest(path=path), self.assertRaises((PermissionError, CommitFileError)):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_audit_contains_ids_but_not_content_or_authorization(self):
|
||||
records = []
|
||||
request = self.request(content='{"value":"non-secret-body-marker"}\n')
|
||||
self.writer(audit=records.append).execute(DummyGrant(), request)
|
||||
encoded = json.dumps(records)
|
||||
self.assertIn("test-token-id", encoded)
|
||||
self.assertNotIn("non-secret-body-marker", encoded)
|
||||
self.assertNotIn("Authorization", encoded)
|
||||
|
||||
def test_concurrent_same_idempotency_creates_one_commit(self):
|
||||
request = self.request()
|
||||
results, errors = [], []
|
||||
|
||||
def invoke():
|
||||
try:
|
||||
results.append(self.writer().execute(DummyGrant(), request))
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=invoke) for _ in range(4)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertFalse(errors)
|
||||
self.assertEqual(1, len({item["commit_oid"] for item in results}))
|
||||
|
||||
def test_different_operations_are_serialized_by_branch_lock(self):
|
||||
active = 0
|
||||
maximum = 0
|
||||
guard = threading.Lock()
|
||||
|
||||
class Probe(AtomicCommitFile):
|
||||
def _execute_locked(inner, grant, data, digest):
|
||||
nonlocal active, maximum
|
||||
with guard:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
time.sleep(0.05)
|
||||
with guard:
|
||||
active -= 1
|
||||
return {"state": "PROBED"}
|
||||
|
||||
probe = Probe(
|
||||
workspace=self.workspace,
|
||||
bare_repo=self.authoritative,
|
||||
ledger=OperationLedger(self.root / "probe.sqlite3"),
|
||||
remote="gitea-http",
|
||||
audit=lambda record: None,
|
||||
)
|
||||
requests = [self.request(key=f"writer-lock-{index:04d}") for index in range(3)]
|
||||
threads = [threading.Thread(target=probe.execute, args=(DummyGrant(), item)) for item in requests]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
self.assertEqual(1, maximum)
|
||||
|
||||
def test_invalid_expected_head_and_idempotency_are_rejected(self):
|
||||
request = self.request()
|
||||
request["expected_head_oid"] = "short"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_expected_head_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request = self.request()
|
||||
request["idempotency_key"] = "../bad"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_idempotency_key_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_invalid_message_and_nul_content_are_rejected(self):
|
||||
request = self.request()
|
||||
request["message"] = "bad\nmessage"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_message_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
request = self.request()
|
||||
request["content"] = "bad\x00content"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_content_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
def test_force_flags_are_403_and_unknown_fields_are_rejected(self):
|
||||
for field in ("force", "force_push"):
|
||||
request = self.request(key=f"writer-force-{field}")
|
||||
request[field] = True
|
||||
with self.subTest(field=field), self.assertRaises(CommitFileError) as captured:
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
self.assertEqual(403, captured.exception.status)
|
||||
request = self.request(key="writer-extra-field")
|
||||
request["operation"] = "commit_file.atomic"
|
||||
with self.assertRaisesRegex(CommitFileError, "writer_request_fields_rejected"):
|
||||
self.writer().execute(DummyGrant(), request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from silent_release_controller import ( # noqa: E402
|
||||
AcceptanceEvidence,
|
||||
ApprovalEnvelope,
|
||||
ApprovalError,
|
||||
ApprovalLedger,
|
||||
DeploymentController,
|
||||
DuplicateTagConflict,
|
||||
ReleaseAcceptanceEngine,
|
||||
ReleaseCandidate,
|
||||
ReleaseCandidateStore,
|
||||
ReleaseState,
|
||||
SilentReleaseController,
|
||||
SilentReleasePolicy,
|
||||
TagApprovalEnvelope,
|
||||
TagController,
|
||||
redact,
|
||||
)
|
||||
|
||||
|
||||
COMMIT = "1" * 40
|
||||
TREE = "2" * 40
|
||||
ARTIFACT = "3" * 64
|
||||
MANIFEST = "4" * 64
|
||||
TAG_OID = "5" * 40
|
||||
TARGET = "6" * 40
|
||||
|
||||
|
||||
def candidate() -> ReleaseCandidate:
|
||||
return ReleaseCandidate(
|
||||
candidate_id="rc-20260723-001",
|
||||
source_commit=COMMIT,
|
||||
tree_oid=TREE,
|
||||
artifact_sha256=ARTIFACT,
|
||||
manifest_sha256=MANIFEST,
|
||||
)
|
||||
|
||||
|
||||
def passing_evidence() -> AcceptanceEvidence:
|
||||
return AcceptanceEvidence(
|
||||
local_main_oid=COMMIT,
|
||||
bare_main_oid=COMMIT,
|
||||
gitea_main_oid=COMMIT,
|
||||
repository_identity="aios/aios",
|
||||
artifact_sha256=ARTIFACT,
|
||||
manifest_sha256=MANIFEST,
|
||||
manifest_source_commit=COMMIT,
|
||||
manifest_tree_oid=TREE,
|
||||
tests={"passed": True, "summary": "81 passed"},
|
||||
canary={
|
||||
"passed": True,
|
||||
"runtime_without_git": True,
|
||||
"writes_enabled": False,
|
||||
},
|
||||
rollback_ready=True,
|
||||
authoritative_git_dir="/data/aios-gitops/repo.git",
|
||||
authoritative_git_readable=True,
|
||||
)
|
||||
|
||||
|
||||
class FakeProbe:
|
||||
def __init__(self, evidence=None, error=None):
|
||||
self.evidence = evidence or passing_evidence()
|
||||
self.error = error
|
||||
self.calls = 0
|
||||
|
||||
def collect(self, release_candidate):
|
||||
self.calls += 1
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.evidence
|
||||
|
||||
|
||||
class FakeBuilder:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def build(self, source_commit):
|
||||
self.calls += 1
|
||||
self.source_commit = source_commit
|
||||
return candidate()
|
||||
|
||||
|
||||
class FakeDeployment:
|
||||
def __init__(self, health=True, restart_error=None):
|
||||
self.health = health
|
||||
self.restart_error = restart_error
|
||||
self.switched = 0
|
||||
self.restarted = 0
|
||||
self.rolled_back = 0
|
||||
|
||||
def atomic_switch(self, release_candidate):
|
||||
self.switched += 1
|
||||
return {"previous_target": "old", "new_target": release_candidate.candidate_id}
|
||||
|
||||
def restart(self):
|
||||
self.restarted += 1
|
||||
if self.restart_error:
|
||||
raise self.restart_error
|
||||
|
||||
def health_check(self, release_candidate):
|
||||
return {
|
||||
"passed": self.health,
|
||||
"runtime_commit": release_candidate.source_commit,
|
||||
"writes_enabled": False,
|
||||
"authoritative_head_oid": release_candidate.source_commit,
|
||||
"repository_identity": "aios/aios",
|
||||
}
|
||||
|
||||
def rollback(self, switch_evidence):
|
||||
self.rolled_back += 1
|
||||
return {"passed": True, "writes_enabled": False}
|
||||
|
||||
|
||||
class FakeTagAdapter:
|
||||
def __init__(self):
|
||||
self.current = None
|
||||
self.creates = 0
|
||||
|
||||
def lookup(self, version):
|
||||
return self.current
|
||||
|
||||
def create(self, version, tag_oid, target_oid):
|
||||
self.creates += 1
|
||||
self.current = (tag_oid, target_oid)
|
||||
|
||||
def verify(self, version):
|
||||
return self.current
|
||||
|
||||
|
||||
class SilentReleaseControllerTests(unittest.TestCase):
|
||||
def test_top_level_controller_automates_prepare_then_waits_for_owner(self):
|
||||
builder = FakeBuilder()
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
store = ReleaseCandidateStore(Path(td) / "candidates")
|
||||
controller = SilentReleaseController(
|
||||
builder=builder,
|
||||
acceptance=ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
),
|
||||
deployment=DeploymentController(
|
||||
FakeDeployment(),
|
||||
ApprovalLedger(Path(td) / "approvals"),
|
||||
SilentReleasePolicy.default(),
|
||||
store,
|
||||
allow_legacy_unsigned_approval=True,
|
||||
),
|
||||
store=store,
|
||||
policy=SilentReleasePolicy.default(),
|
||||
)
|
||||
prepared = controller.prepare(COMMIT)
|
||||
self.assertEqual(builder.calls, 1)
|
||||
self.assertEqual(prepared.state, ReleaseState.READY_FOR_APPROVAL)
|
||||
self.assertEqual(prepared.approval_state, "PENDING")
|
||||
self.assertEqual(
|
||||
store.load(prepared.candidate_id).state,
|
||||
ReleaseState.READY_FOR_APPROVAL,
|
||||
)
|
||||
|
||||
def test_candidate_model_round_trips_and_enforces_state_machine(self):
|
||||
value = candidate()
|
||||
self.assertEqual(value.state, ReleaseState.BUILDING)
|
||||
value.transition(ReleaseState.VERIFYING)
|
||||
restored = ReleaseCandidate.from_dict(value.to_dict())
|
||||
self.assertEqual(restored.state, ReleaseState.VERIFYING)
|
||||
with self.assertRaisesRegex(ValueError, "invalid release transition"):
|
||||
restored.transition(ReleaseState.VERIFIED)
|
||||
|
||||
def test_acceptance_engine_moves_verified_candidate_to_ready(self):
|
||||
value = candidate()
|
||||
result = ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
self.assertTrue(result.ready)
|
||||
self.assertEqual(value.state, ReleaseState.READY_FOR_APPROVAL)
|
||||
self.assertTrue(value.rollback_ready)
|
||||
self.assertTrue(value.tests["passed"])
|
||||
self.assertTrue(value.canary["runtime_without_git"])
|
||||
self.assertEqual(value.git_consistency["bare_main_oid"], COMMIT)
|
||||
self.assertEqual(value.gitea_consistency["main_oid"], COMMIT)
|
||||
|
||||
def test_artifact_mismatch_fails_closed(self):
|
||||
evidence = replace(passing_evidence(), artifact_sha256="9" * 64)
|
||||
value = candidate()
|
||||
result = ReleaseAcceptanceEngine(
|
||||
FakeProbe(evidence), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
self.assertFalse(result.ready)
|
||||
self.assertEqual(value.state, ReleaseState.FAILED)
|
||||
self.assertIn("artifact_sha256", result.failed_checks)
|
||||
|
||||
def test_gitea_unavailable_fails_closed(self):
|
||||
value = candidate()
|
||||
result = ReleaseAcceptanceEngine(
|
||||
FakeProbe(error=RuntimeError("GITEA_UNAVAILABLE")),
|
||||
expected_repository="aios/aios",
|
||||
).evaluate(value)
|
||||
self.assertFalse(result.ready)
|
||||
self.assertEqual(value.state, ReleaseState.FAILED)
|
||||
self.assertIn("GITEA_UNAVAILABLE", value.failure_reason)
|
||||
|
||||
def test_git_unavailable_fails_closed(self):
|
||||
value = candidate()
|
||||
result = ReleaseAcceptanceEngine(
|
||||
FakeProbe(error=RuntimeError("AUTHORITATIVE_GIT_UNAVAILABLE")),
|
||||
expected_repository="aios/aios",
|
||||
).evaluate(value)
|
||||
self.assertFalse(result.ready)
|
||||
self.assertEqual(value.state, ReleaseState.FAILED)
|
||||
self.assertIn("AUTHORITATIVE_GIT_UNAVAILABLE", value.failure_reason)
|
||||
|
||||
def test_runtime_with_git_metadata_is_rejected(self):
|
||||
evidence = replace(
|
||||
passing_evidence(),
|
||||
canary={"passed": True, "runtime_without_git": False},
|
||||
)
|
||||
value = candidate()
|
||||
result = ReleaseAcceptanceEngine(
|
||||
FakeProbe(evidence), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
self.assertFalse(result.ready)
|
||||
self.assertIn("runtime_without_git", result.failed_checks)
|
||||
|
||||
def test_runtime_switch_requires_one_bound_owner_approval(self):
|
||||
value = candidate()
|
||||
ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
envelope = ApprovalEnvelope.for_candidate(
|
||||
value,
|
||||
approval_id="approval-001",
|
||||
owner_id="owner",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
deployment = FakeDeployment()
|
||||
controller = DeploymentController(
|
||||
deployment,
|
||||
ApprovalLedger(Path(td) / "approvals"),
|
||||
SilentReleasePolicy.default(),
|
||||
allow_legacy_unsigned_approval=True,
|
||||
)
|
||||
result = controller.deploy(value, envelope)
|
||||
self.assertEqual(value.state, ReleaseState.VERIFIED)
|
||||
self.assertTrue(result["verified"])
|
||||
self.assertEqual(deployment.switched, 1)
|
||||
self.assertEqual(deployment.restarted, 1)
|
||||
self.assertEqual(deployment.rolled_back, 0)
|
||||
|
||||
def test_failed_health_check_rolls_back(self):
|
||||
value = candidate()
|
||||
ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
envelope = ApprovalEnvelope.for_candidate(
|
||||
value,
|
||||
approval_id="approval-rollback",
|
||||
owner_id="owner",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
deployment = FakeDeployment(health=False)
|
||||
result = DeploymentController(
|
||||
deployment,
|
||||
ApprovalLedger(Path(td) / "approvals"),
|
||||
SilentReleasePolicy.default(),
|
||||
allow_legacy_unsigned_approval=True,
|
||||
).deploy(value, envelope)
|
||||
self.assertFalse(result["verified"])
|
||||
self.assertEqual(value.state, ReleaseState.ROLLED_BACK)
|
||||
self.assertEqual(deployment.rolled_back, 1)
|
||||
|
||||
def test_approval_replay_is_rejected(self):
|
||||
value = candidate()
|
||||
ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
).evaluate(value)
|
||||
envelope = ApprovalEnvelope.for_candidate(
|
||||
value,
|
||||
approval_id="approval-replay",
|
||||
owner_id="owner",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
ledger = ApprovalLedger(Path(td) / "approvals")
|
||||
controller = DeploymentController(
|
||||
FakeDeployment(),
|
||||
ledger,
|
||||
SilentReleasePolicy.default(),
|
||||
allow_legacy_unsigned_approval=True,
|
||||
)
|
||||
controller.deploy(value, envelope)
|
||||
second = candidate()
|
||||
ReleaseAcceptanceEngine(
|
||||
FakeProbe(), expected_repository="aios/aios"
|
||||
).evaluate(second)
|
||||
with self.assertRaisesRegex(ApprovalError, "already consumed"):
|
||||
controller.deploy(second, envelope)
|
||||
|
||||
def test_tag_controller_is_separate_and_rejects_duplicate_conflict(self):
|
||||
adapter = FakeTagAdapter()
|
||||
adapter.current = (TAG_OID, TARGET)
|
||||
controller = TagController(
|
||||
adapter,
|
||||
SilentReleasePolicy.default(),
|
||||
ledger=None,
|
||||
allow_legacy_unsigned_approval=True,
|
||||
)
|
||||
envelope = TagApprovalEnvelope(
|
||||
approval_id="tag-approval-001",
|
||||
owner_id="owner",
|
||||
version="V9R9C9",
|
||||
tag_oid="7" * 40,
|
||||
target_oid=TARGET,
|
||||
action="production_tag",
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
)
|
||||
approved = controller.approve(envelope)
|
||||
with self.assertRaises(DuplicateTagConflict):
|
||||
controller.execute(approved)
|
||||
self.assertEqual(adapter.creates, 0)
|
||||
|
||||
def test_policy_separates_automatic_owner_and_forbidden_actions(self):
|
||||
policy = SilentReleasePolicy.default()
|
||||
self.assertTrue(policy.is_automatic("canary"))
|
||||
self.assertTrue(policy.requires_owner("production_runtime_switch"))
|
||||
self.assertTrue(policy.requires_owner("production_tag"))
|
||||
self.assertTrue(policy.is_forbidden("force_push"))
|
||||
self.assertTrue(policy.is_forbidden("tag_rewrite"))
|
||||
self.assertTrue(policy.is_forbidden("direct_main_push"))
|
||||
self.assertTrue(policy.is_forbidden("baseline_mutation"))
|
||||
self.assertTrue(policy.is_forbidden("registry_mutation"))
|
||||
|
||||
def test_redaction_removes_credentials_from_errors(self):
|
||||
message = (
|
||||
"Authorization: Bearer super-secret-token "
|
||||
"https://bot:password@example.invalid/repo.git token=another-secret"
|
||||
)
|
||||
safe = redact(message)
|
||||
self.assertNotIn("super-secret-token", safe)
|
||||
self.assertNotIn("password", safe)
|
||||
self.assertNotIn("another-secret", safe)
|
||||
self.assertIn("REDACTED", safe)
|
||||
|
||||
def test_candidate_store_persists_without_secret_fields(self):
|
||||
value = candidate()
|
||||
value.tests = {"passed": True, "Authorization": "Bearer hidden"}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
store = ReleaseCandidateStore(Path(td))
|
||||
store.save(value)
|
||||
raw = store.path(value.candidate_id).read_text(encoding="utf-8")
|
||||
self.assertNotIn("Bearer hidden", raw)
|
||||
loaded = store.load(value.candidate_id)
|
||||
self.assertEqual(loaded.candidate_id, value.candidate_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,728 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from silent_release_hardening import ( # noqa: E402
|
||||
CapabilityAuthorizationError,
|
||||
CapabilityController,
|
||||
CapabilityLedger,
|
||||
ClaimProbeResult,
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
BuilderAttestationVerifier,
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ProbeEvidence,
|
||||
ReleasePlan,
|
||||
SignedApprovalVerifier,
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
TagPlan,
|
||||
TagPublicationProbeEvidence,
|
||||
TrustedCandidateBundle,
|
||||
TrustedReleasePolicy,
|
||||
classify_tag_publication,
|
||||
)
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState # noqa: E402
|
||||
from silent_release_execution import ( # noqa: E402
|
||||
LocalTestApprovalSigner,
|
||||
LocalTestBuilderSigner,
|
||||
PersistentSilentReleaseController,
|
||||
ShadowActionHandler,
|
||||
SignedTrustedBuilderAdapter,
|
||||
UnavailableProductionApprovalBackend,
|
||||
)
|
||||
from build_silent_release_artifact import ( # noqa: E402
|
||||
redact_output,
|
||||
secret_scan,
|
||||
)
|
||||
|
||||
|
||||
COMMIT = "1" * 40
|
||||
TAG_OID = "2" * 40
|
||||
|
||||
|
||||
def approval() -> SignedReleasePlanApproval:
|
||||
return SignedReleasePlanApproval(
|
||||
release_plan_id="release-plan-readiness-001",
|
||||
candidate_id="rc-readiness-001",
|
||||
source_commit=COMMIT,
|
||||
tree_oid="3" * 40,
|
||||
artifact_sha256="4" * 64,
|
||||
manifest_sha256="5" * 64,
|
||||
deployment_plan_sha256="6" * 64,
|
||||
policy_sha256="7" * 64,
|
||||
approval_plan_id="owner-plan-readiness-001",
|
||||
evidence_closure_oid="8" * 40,
|
||||
owner_approval_record_oid="9" * 40,
|
||||
capabilities=(
|
||||
"runtime_switch",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize",
|
||||
),
|
||||
expires_at="2099-01-01T00:00:00Z",
|
||||
nonce="nonce-readiness-000000001",
|
||||
key_id="owner-key-readiness-1",
|
||||
signature="test-signature",
|
||||
)
|
||||
|
||||
|
||||
def tag_plan() -> TagPlan:
|
||||
return TagPlan(
|
||||
version="shadow-readiness-001",
|
||||
tag_type="annotated",
|
||||
expected_tag_oid=TAG_OID,
|
||||
expected_target_oid=COMMIT,
|
||||
evidence_closure_oid="8" * 40,
|
||||
approval_plan_id="owner-plan-readiness-001",
|
||||
repository_identity="aios/aios",
|
||||
gitea_owner="aios",
|
||||
gitea_repository="aios",
|
||||
current_remote_tag_oid=None,
|
||||
remote_absent_evidence_sha256="a" * 64,
|
||||
verification_contract="git+api+listing/v1",
|
||||
)
|
||||
|
||||
|
||||
def make_failed_claim(
|
||||
ledger: CapabilityLedger,
|
||||
*,
|
||||
operation: str,
|
||||
) -> dict:
|
||||
claimed = ledger.claim(
|
||||
approval(),
|
||||
"runtime_switch",
|
||||
operation=operation,
|
||||
executor_id="worker-initial",
|
||||
lease_seconds=10,
|
||||
now=100,
|
||||
)
|
||||
ledger.fail_recoverable(
|
||||
approval(),
|
||||
"runtime_switch",
|
||||
operation=operation,
|
||||
claim_id=claimed["claim_id"],
|
||||
reason="adapter_error",
|
||||
)
|
||||
return claimed
|
||||
|
||||
|
||||
def test_failed_recoverable_not_applied_retries_once(tmp_path):
|
||||
ledger = CapabilityLedger(
|
||||
tmp_path / "ledger", maximum_probe_age_seconds=30
|
||||
)
|
||||
make_failed_claim(ledger, operation="runtime-retry")
|
||||
calls: list[str] = []
|
||||
controller = CapabilityController(
|
||||
capability="runtime_switch",
|
||||
ledger=ledger,
|
||||
adapter=lambda: calls.append("adapter") or {"verified": True},
|
||||
recovery_probe=lambda: ClaimProbeResult.NOT_APPLIED,
|
||||
executor_id="worker-recovery",
|
||||
)
|
||||
|
||||
assert controller.execute(
|
||||
approval(), operation="runtime-retry"
|
||||
)["status"] == "EXECUTED"
|
||||
assert controller.execute(
|
||||
approval(), operation="runtime-retry"
|
||||
)["status"] == "IDEMPOTENT"
|
||||
assert calls == ["adapter"]
|
||||
|
||||
|
||||
def test_failed_recoverable_applied_verified_skips_adapter(tmp_path):
|
||||
ledger = CapabilityLedger(tmp_path / "ledger")
|
||||
make_failed_claim(ledger, operation="runtime-applied")
|
||||
calls: list[str] = []
|
||||
controller = CapabilityController(
|
||||
capability="runtime_switch",
|
||||
ledger=ledger,
|
||||
adapter=lambda: calls.append("adapter") or {"verified": True},
|
||||
recovery_probe=lambda: ClaimProbeResult.APPLIED_VERIFIED,
|
||||
executor_id="worker-recovery",
|
||||
)
|
||||
|
||||
assert controller.execute(
|
||||
approval(), operation="runtime-applied"
|
||||
)["status"] == "IDEMPOTENT"
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("probe", "expected"),
|
||||
(
|
||||
(
|
||||
ClaimProbeResult.INDETERMINATE,
|
||||
"RECONCILIATION_REQUIRED",
|
||||
),
|
||||
(
|
||||
ClaimProbeResult.CONFLICT,
|
||||
"MANUAL_OWNER_REVIEW_REQUIRED",
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_failed_recoverable_unknown_or_conflict_stops_writes(
|
||||
tmp_path, probe, expected
|
||||
):
|
||||
ledger = CapabilityLedger(tmp_path / "ledger")
|
||||
make_failed_claim(ledger, operation=f"runtime-{probe.value.lower()}")
|
||||
calls: list[str] = []
|
||||
controller = CapabilityController(
|
||||
capability="runtime_switch",
|
||||
ledger=ledger,
|
||||
adapter=lambda: calls.append("adapter") or {"verified": True},
|
||||
recovery_probe=lambda: probe,
|
||||
executor_id="worker-recovery",
|
||||
)
|
||||
|
||||
assert controller.execute(
|
||||
approval(), operation=f"runtime-{probe.value.lower()}"
|
||||
)["status"] == expected
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_probe_must_bind_operation_and_be_fresh(tmp_path):
|
||||
ledger = CapabilityLedger(
|
||||
tmp_path / "ledger", maximum_probe_age_seconds=10
|
||||
)
|
||||
make_failed_claim(ledger, operation="runtime-freshness")
|
||||
record = ledger.read(
|
||||
approval(), "runtime_switch", operation="runtime-freshness"
|
||||
)
|
||||
assert record is not None
|
||||
|
||||
with pytest.raises(
|
||||
CapabilityAuthorizationError, match="operation digest"
|
||||
):
|
||||
ledger.claim(
|
||||
approval(),
|
||||
"runtime_switch",
|
||||
operation="runtime-freshness",
|
||||
executor_id="worker-recovery",
|
||||
lease_seconds=10,
|
||||
now=120,
|
||||
side_effect_probe=lambda: ProbeEvidence(
|
||||
result=ClaimProbeResult.NOT_APPLIED,
|
||||
operation_digest="0" * 64,
|
||||
observed_at=120,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(CapabilityAuthorizationError, match="freshness"):
|
||||
ledger.claim(
|
||||
approval(),
|
||||
"runtime_switch",
|
||||
operation="runtime-freshness",
|
||||
executor_id="worker-recovery",
|
||||
lease_seconds=10,
|
||||
now=120,
|
||||
side_effect_probe=lambda: ProbeEvidence(
|
||||
result=ClaimProbeResult.NOT_APPLIED,
|
||||
operation_digest=record["operation_digest"],
|
||||
observed_at=100,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_probe_journal_is_hash_chained_and_tamper_fails_closed(tmp_path):
|
||||
ledger = CapabilityLedger(tmp_path / "ledger")
|
||||
make_failed_claim(ledger, operation="runtime-journal")
|
||||
ledger.claim(
|
||||
approval(),
|
||||
"runtime_switch",
|
||||
operation="runtime-journal",
|
||||
executor_id="worker-recovery",
|
||||
lease_seconds=10,
|
||||
now=101,
|
||||
side_effect_probe=lambda: ClaimProbeResult.INDETERMINATE,
|
||||
)
|
||||
operation_path = ledger._operation_path(
|
||||
approval(), "runtime_switch", "runtime-journal"
|
||||
)
|
||||
records = ledger.verify_probe_journal(operation_path)
|
||||
assert records[0]["payload"]["result"] == "INDETERMINATE"
|
||||
journal = ledger._probe_journal_path(operation_path)
|
||||
payload = json.loads(journal.read_text(encoding="utf-8"))
|
||||
payload["payload"]["result"] = "NOT_APPLIED"
|
||||
journal.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
||||
with pytest.raises(Exception, match="hash chain"):
|
||||
ledger.verify_probe_journal(operation_path)
|
||||
|
||||
|
||||
def tag_evidence(**overrides) -> TagPublicationProbeEvidence:
|
||||
values = {
|
||||
"repository_identity": "aios/aios",
|
||||
"remote_reachable": True,
|
||||
"gitea_api_reachable": True,
|
||||
"gitea_listing_reachable": True,
|
||||
"remote_tag_oid": TAG_OID,
|
||||
"annotated_tag_object_oid": TAG_OID,
|
||||
"remote_peeled_target_oid": COMMIT,
|
||||
"gitea_api_tag_oid": TAG_OID,
|
||||
"gitea_api_peeled_target_oid": COMMIT,
|
||||
"gitea_listing_visible": True,
|
||||
"gitea_listing_tag_oid": TAG_OID,
|
||||
"gitea_listing_peeled_target_oid": COMMIT,
|
||||
"observed_at": 100,
|
||||
}
|
||||
values.update(overrides)
|
||||
return TagPublicationProbeEvidence(**values)
|
||||
|
||||
|
||||
def test_tag_probe_exact_absence_is_not_applied():
|
||||
evidence = tag_evidence(
|
||||
remote_tag_oid=None,
|
||||
annotated_tag_object_oid=None,
|
||||
remote_peeled_target_oid=None,
|
||||
gitea_api_tag_oid=None,
|
||||
gitea_api_peeled_target_oid=None,
|
||||
gitea_listing_visible=False,
|
||||
gitea_listing_tag_oid=None,
|
||||
gitea_listing_peeled_target_oid=None,
|
||||
)
|
||||
assert (
|
||||
classify_tag_publication(evidence, tag_plan())
|
||||
== ClaimProbeResult.NOT_APPLIED
|
||||
)
|
||||
|
||||
|
||||
def test_tag_probe_exact_publication_is_applied_verified():
|
||||
assert (
|
||||
classify_tag_publication(tag_evidence(), tag_plan())
|
||||
== ClaimProbeResult.APPLIED_VERIFIED
|
||||
)
|
||||
|
||||
|
||||
def test_tag_probe_unavailable_or_divergent_is_indeterminate():
|
||||
assert (
|
||||
classify_tag_publication(
|
||||
tag_evidence(gitea_api_reachable=False), tag_plan()
|
||||
)
|
||||
== ClaimProbeResult.INDETERMINATE
|
||||
)
|
||||
assert (
|
||||
classify_tag_publication(
|
||||
tag_evidence(gitea_listing_tag_oid=None), tag_plan()
|
||||
)
|
||||
== ClaimProbeResult.INDETERMINATE
|
||||
)
|
||||
|
||||
|
||||
def test_tag_probe_same_name_different_oid_is_conflict():
|
||||
assert (
|
||||
classify_tag_publication(
|
||||
tag_evidence(remote_tag_oid="f" * 40), tag_plan()
|
||||
)
|
||||
== ClaimProbeResult.CONFLICT
|
||||
)
|
||||
assert (
|
||||
classify_tag_publication(
|
||||
tag_evidence(remote_peeled_target_oid="e" * 40), tag_plan()
|
||||
)
|
||||
== ClaimProbeResult.CONFLICT
|
||||
)
|
||||
|
||||
|
||||
def readiness_release_plan(source_commit: str = COMMIT) -> ReleasePlan:
|
||||
policy_sha = "7" * 64
|
||||
return ReleasePlan(
|
||||
deployment=DeploymentPlan(
|
||||
service="aios-silent-release-controller",
|
||||
release_path="/usr/libexec/aios-silent-release-shadow/release-1",
|
||||
release_root="/usr/libexec/aios-silent-release-shadow",
|
||||
cutover_link="/usr/libexec/aios-silent-release-shadow/current",
|
||||
dropin_path=(
|
||||
"/usr/libexec/aios-silent-release-shadow/release-1/"
|
||||
"deploy/systemd/aios-silent-release-controller.service"
|
||||
),
|
||||
dropin_sha256="a" * 64,
|
||||
systemd_unit_sha256="a" * 64,
|
||||
rollback_path=(
|
||||
"/usr/libexec/aios-silent-release-shadow/rollback-1"
|
||||
),
|
||||
rollback_sha256="b" * 64,
|
||||
source_commit=source_commit,
|
||||
tree_oid="3" * 40,
|
||||
artifact_sha256="4" * 64,
|
||||
manifest_sha256="5" * 64,
|
||||
package_closure_sha256="c" * 64,
|
||||
authoritative_git_dir="/data/aios-gitops/repo.git",
|
||||
repository_identity="aios/aios",
|
||||
writer_expected=False,
|
||||
baseline_before_sha256="d" * 64,
|
||||
registry_before_sha256="e" * 64,
|
||||
production_tag_refset_before_sha256="f" * 64,
|
||||
health_contract_version="shadow-health/v1",
|
||||
timeout_policy={"health_seconds": 10},
|
||||
retry_policy={"health_attempts": 1},
|
||||
policy_sha256=policy_sha,
|
||||
health_contract={"shadow_mode": True},
|
||||
),
|
||||
tag=TagPlan(
|
||||
version="shadow-readiness-001",
|
||||
tag_type="annotated",
|
||||
expected_tag_oid=TAG_OID,
|
||||
expected_target_oid=source_commit,
|
||||
evidence_closure_oid="8" * 40,
|
||||
approval_plan_id="owner-plan-readiness-001",
|
||||
repository_identity="aios/aios",
|
||||
gitea_owner="aios",
|
||||
gitea_repository="aios",
|
||||
current_remote_tag_oid=None,
|
||||
remote_absent_evidence_sha256="a" * 64,
|
||||
verification_contract="shadow-only/v1",
|
||||
),
|
||||
finalization=FinalizationPlan(
|
||||
version="shadow-readiness-001",
|
||||
expected_tag_oid=TAG_OID,
|
||||
expected_target_oid=source_commit,
|
||||
expected_previous_baseline_sha256="d" * 64,
|
||||
registry_expected_record={
|
||||
"shadow": True,
|
||||
"target_oid": source_commit,
|
||||
},
|
||||
baseline_expected_record={
|
||||
"shadow": True,
|
||||
"target_oid": source_commit,
|
||||
},
|
||||
final_consistency_contract="shadow-simulated/v1",
|
||||
),
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
|
||||
|
||||
def readiness_candidate(source_commit: str = COMMIT) -> ReleaseCandidate:
|
||||
return ReleaseCandidate(
|
||||
candidate_id="rc-shadow-" + source_commit[:12],
|
||||
source_commit=source_commit,
|
||||
tree_oid="3" * 40,
|
||||
artifact_sha256="4" * 64,
|
||||
manifest_sha256="5" * 64,
|
||||
tests={"passed": True, "source": "trusted-builder"},
|
||||
canary={"passed": True, "without_git": True},
|
||||
rollback_ready=True,
|
||||
git_consistency={"consistent": True},
|
||||
gitea_consistency={"consistent": True},
|
||||
approval_state="PENDING",
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
)
|
||||
|
||||
|
||||
def trusted_service(tmp_path):
|
||||
builder_signer = LocalTestBuilderSigner.generate()
|
||||
builder_public = builder_signer.public_key.public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
|
||||
def collector(intent):
|
||||
source_commit = str(intent["source_commit"])
|
||||
return TrustedCandidateBundle(
|
||||
candidate=readiness_candidate(source_commit),
|
||||
release_plan=readiness_release_plan(source_commit),
|
||||
checks={
|
||||
"authoritative_git": True,
|
||||
"artifact": True,
|
||||
"canary": True,
|
||||
"gitea": True,
|
||||
"manifest": True,
|
||||
"package_closure": True,
|
||||
"rollback": True,
|
||||
"secret_scan": True,
|
||||
"tests": True,
|
||||
},
|
||||
)
|
||||
|
||||
owner_private = Ed25519PrivateKey.generate()
|
||||
owner_public = owner_private.public_key().public_bytes(
|
||||
serialization.Encoding.Raw,
|
||||
serialization.PublicFormat.Raw,
|
||||
)
|
||||
verifier = SignedApprovalVerifier(
|
||||
ApprovalKeyRing({"shadow-owner-test-key": owner_public}),
|
||||
ApprovalRevocationStore(tmp_path / "revocations"),
|
||||
allowed_trust_domains={"aios.shadow"},
|
||||
)
|
||||
policy = TrustedReleasePolicy(
|
||||
services=frozenset({"aios-silent-release-controller"}),
|
||||
release_roots=frozenset(
|
||||
{"/usr/libexec/aios-silent-release-shadow"}
|
||||
),
|
||||
cutover_links=frozenset(
|
||||
{"/usr/libexec/aios-silent-release-shadow/current"}
|
||||
),
|
||||
dropin_paths=frozenset(
|
||||
{
|
||||
"/usr/libexec/aios-silent-release-shadow/release-1/"
|
||||
"deploy/systemd/aios-silent-release-controller.service"
|
||||
}
|
||||
),
|
||||
authoritative_git_dirs=frozenset(
|
||||
{"/data/aios-gitops/repo.git"}
|
||||
),
|
||||
repository_identities=frozenset({"aios/aios"}),
|
||||
)
|
||||
service = SilentReleaseActionService(
|
||||
tmp_path / "state",
|
||||
execution_enabled=False,
|
||||
approval_verifier=verifier,
|
||||
candidate_builder=SignedTrustedBuilderAdapter(
|
||||
collector, builder_signer
|
||||
),
|
||||
builder_attestation_verifier=BuilderAttestationVerifier(
|
||||
{builder_signer.key_id: builder_public}
|
||||
),
|
||||
trusted_policy=policy,
|
||||
expected_repository="aios/aios",
|
||||
policy_sha256="7" * 64,
|
||||
)
|
||||
return service, LocalTestApprovalSigner(
|
||||
"shadow-owner-test-key", owner_private
|
||||
)
|
||||
|
||||
|
||||
def signed_shadow_approval(
|
||||
service: SilentReleaseActionService,
|
||||
signer: LocalTestApprovalSigner,
|
||||
candidate_id: str,
|
||||
) -> SignedReleasePlanApproval:
|
||||
candidate = service.candidates.load(candidate_id)
|
||||
plan = service.release_plans.load(candidate_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
value = SignedReleasePlanApproval(
|
||||
release_plan_id="release-plan-shadow-readiness-001",
|
||||
candidate_id=candidate_id,
|
||||
source_commit=candidate.source_commit,
|
||||
tree_oid=candidate.tree_oid,
|
||||
artifact_sha256=candidate.artifact_sha256,
|
||||
manifest_sha256=candidate.manifest_sha256,
|
||||
deployment_plan_sha256=plan.deployment.sha256,
|
||||
release_plan_sha256=plan.sha256,
|
||||
policy_sha256=plan.policy_sha256,
|
||||
approval_plan_id=plan.tag.approval_plan_id,
|
||||
evidence_closure_oid=plan.tag.evidence_closure_oid,
|
||||
owner_approval_record_oid="9" * 40,
|
||||
capabilities=(
|
||||
"runtime_switch",
|
||||
"tag_publish",
|
||||
"baseline_registry_finalize",
|
||||
),
|
||||
owner_identity="owner:shadow-test",
|
||||
trust_domain="aios.shadow",
|
||||
issued_at=now.isoformat().replace("+00:00", "Z"),
|
||||
not_before=now.isoformat().replace("+00:00", "Z"),
|
||||
expires_at=(now + timedelta(minutes=5))
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
nonce="nonce-shadow-readiness-000001",
|
||||
key_id="shadow-owner-test-key",
|
||||
signature="",
|
||||
)
|
||||
return signer.sign(value)
|
||||
|
||||
|
||||
def test_trusted_builder_attestation_and_caller_evidence_gate(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
result = service.create_release_intent(
|
||||
{"event_id": "webhook-main-001", "source_commit": COMMIT}
|
||||
)
|
||||
acceptance = service.get_acceptance(result["candidate_id"])
|
||||
assert acceptance["attestation"]["builder_attestation_id"].startswith(
|
||||
"builder-attestation-"
|
||||
)
|
||||
assert acceptance["checks"]["tests"] is True
|
||||
with pytest.raises(
|
||||
CapabilityAuthorizationError, match="caller-supplied evidence"
|
||||
):
|
||||
service.create_release_intent(
|
||||
{
|
||||
"event_id": "webhook-main-002",
|
||||
"source_commit": COMMIT,
|
||||
"tests": {"passed": True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_builder_attestation_tamper_is_rejected(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
bundle = service.candidate_builder.build(
|
||||
{"event_id": "webhook-main-001", "source_commit": COMMIT}
|
||||
)
|
||||
tampered = dataclasses.replace(
|
||||
bundle.attestation, artifact_sha256="0" * 64
|
||||
)
|
||||
with pytest.raises(
|
||||
CapabilityAuthorizationError, match="does not bind"
|
||||
):
|
||||
dataclasses.replace(bundle, attestation=tampered).validate(
|
||||
service.builder_attestation_verifier
|
||||
)
|
||||
|
||||
|
||||
def test_production_approval_backend_missing_disables_execution():
|
||||
backend = UnavailableProductionApprovalBackend()
|
||||
assert backend.available is False
|
||||
with pytest.raises(
|
||||
CapabilityAuthorizationError, match="not configured"
|
||||
):
|
||||
backend.verify()
|
||||
|
||||
|
||||
def test_controller_duplicate_webhook_notification_and_restart_are_idempotent(
|
||||
tmp_path,
|
||||
):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
first_controller = PersistentSilentReleaseController(
|
||||
tmp_path / "state",
|
||||
service,
|
||||
main_probe=lambda: COMMIT,
|
||||
)
|
||||
first = first_controller.run_once()
|
||||
second = first_controller.run_once()
|
||||
restarted = PersistentSilentReleaseController(
|
||||
tmp_path / "state",
|
||||
service,
|
||||
main_probe=lambda: COMMIT,
|
||||
).run_once()
|
||||
assert first["intent"]["candidate_id"] == second["intent"]["candidate_id"]
|
||||
assert second["intent"]["idempotent"] is True
|
||||
assert first["notification"]["idempotent"] is False
|
||||
assert second["notification"]["idempotent"] is True
|
||||
assert restarted["notification"]["idempotent"] is True
|
||||
assert restarted["side_effects_executed"] is False
|
||||
|
||||
|
||||
def test_controller_startup_journal_failure_stops_before_main_event(tmp_path):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
service.sagas.create(
|
||||
"rc-shadow-" + COMMIT[:12], "release-plan-shadow-readiness-001"
|
||||
)
|
||||
journal = next(service.sagas.root.glob("*.events.jsonl"))
|
||||
journal.write_text('{"tampered":true}\n', encoding="utf-8")
|
||||
probes: list[str] = []
|
||||
result = PersistentSilentReleaseController(
|
||||
tmp_path / "state",
|
||||
service,
|
||||
main_probe=lambda: probes.append("main") or COMMIT,
|
||||
).run_once()
|
||||
assert result["status"] == "FAIL_CLOSED"
|
||||
assert result["intent"] is None
|
||||
assert result["side_effects_executed"] is False
|
||||
assert probes == []
|
||||
|
||||
|
||||
def test_action_http_shadow_flow_has_zero_side_effects(tmp_path):
|
||||
service, signer = trusted_service(tmp_path)
|
||||
handler = type(
|
||||
"TestShadowHandler",
|
||||
(ShadowActionHandler,),
|
||||
{"service": service, "controller": None},
|
||||
)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
def post(path, payload):
|
||||
request = urllib.request.Request(
|
||||
base + path,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return json.load(response)
|
||||
|
||||
try:
|
||||
intent = post(
|
||||
"/createReleaseIntent",
|
||||
{"event_id": "http-main-001", "source_commit": COMMIT},
|
||||
)
|
||||
candidate_id = intent["candidate_id"]
|
||||
approval = signed_shadow_approval(service, signer, candidate_id)
|
||||
accepted = post("/submitSignedApproval", approval.to_dict())
|
||||
deployed = post(
|
||||
"/startDeployment", {"candidate_id": candidate_id}
|
||||
)
|
||||
with urllib.request.urlopen(
|
||||
base + "/getReleasePlan?candidate_id=" + candidate_id,
|
||||
timeout=5,
|
||||
) as response:
|
||||
plan = json.load(response)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert accepted["status"] == "VERIFIED_AND_STORED"
|
||||
assert deployed == {
|
||||
"candidate_id": candidate_id,
|
||||
"execution_enabled": False,
|
||||
"shadow_mode": True,
|
||||
"side_effects": [],
|
||||
"side_effects_executed": False,
|
||||
"status": "SHADOW_COMPLETED",
|
||||
}
|
||||
assert plan["shadow_mode"] is True
|
||||
assert plan["side_effects_executed"] is False
|
||||
|
||||
|
||||
def test_secret_scan_redacts_placeholders_but_rejects_real_material(tmp_path):
|
||||
(tmp_path / "safe.txt").write_text(
|
||||
"Authorization: Bearer test-placeholder-token-000000",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert secret_scan(tmp_path) == {"findings": [], "passed": True}
|
||||
(tmp_path / "unsafe.txt").write_text(
|
||||
"-----BEGIN "
|
||||
+ "OPENSSH PRIVATE KEY-----\n"
|
||||
+ ("A" * 80)
|
||||
+ "\n-----END "
|
||||
+ "OPENSSH PRIVATE KEY-----",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = secret_scan(tmp_path)
|
||||
assert result["passed"] is False
|
||||
assert result["findings"] == ["unsafe.txt"]
|
||||
secret = "live-" + ("A" * 40)
|
||||
redacted = redact_output("Authorization: Bearer " + secret)
|
||||
assert secret not in redacted
|
||||
assert "<redacted>" in redacted
|
||||
(tmp_path / "unsafe.txt").write_text(
|
||||
"Authorization: " + "Bearer live-test-looking-secret-000001",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = secret_scan(tmp_path)
|
||||
assert result["passed"] is False
|
||||
assert result["findings"] == ["unsafe.txt"]
|
||||
|
||||
|
||||
def test_systemd_shadow_unit_has_both_gates_and_no_secret_environment():
|
||||
unit = (
|
||||
ROOT
|
||||
/ "deploy"
|
||||
/ "systemd"
|
||||
/ "aios-silent-release-controller.service"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED=false" in unit
|
||||
assert "AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false" in unit
|
||||
assert "--host 127.0.0.1" in unit
|
||||
assert "PRIVATE_KEY=" not in unit
|
||||
assert "TOKEN=" not in unit
|
||||
assert "PASSWORD=" not in unit
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user