feat(automation): enforce assessment scope and document activation blockers
This commit is contained in:
@@ -19,6 +19,7 @@ import time
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
import local_mode
|
||||
import assessment_adapter
|
||||
|
||||
|
||||
class GateError(RuntimeError):
|
||||
@@ -165,6 +166,13 @@ def validate_manifest(m, root=None):
|
||||
if check['id'] in check_ids or not check.get('argv'):
|
||||
raise GateError('Duplicate check or empty test command')
|
||||
check_ids.add(check['id'])
|
||||
if check.get('adapter') == 'assessment-evidence-inspection':
|
||||
if not m.get('executionScope') or not re.fullmatch(r'[0-9a-f]{64}',check.get('receiptSha256','')):
|
||||
raise GateError('Assessment adapter requires a restricted policy and pinned receipt')
|
||||
if check.get('configured') is not False:
|
||||
raise GateError('Evidence inspection cannot qualify the command-running composite adapter')
|
||||
if root and sha_file(under(root,check['receipt'])) != check['receiptSha256']:
|
||||
raise GateError('Assessment qualification receipt changed')
|
||||
if not all(isinstance(a, str) and '\x00' not in a for a in check['argv']):
|
||||
raise GateError('Command arguments must be strings')
|
||||
if check.get('report') and check['report']['format'] not in ('trx', 'jest', 'playwright', 'junit'):
|
||||
@@ -180,6 +188,17 @@ def validate_manifest(m, root=None):
|
||||
raise GateError('Supplemental prompt missing/changed')
|
||||
if sequences != sorted(set(sequences)):
|
||||
raise GateError('Queue sequence must be unique and ascending')
|
||||
scope = m.get('executionScope')
|
||||
if scope:
|
||||
stage = next((s for s in m['stages'] if s['id'] == scope.get('stage')), None)
|
||||
cp = next((x for x in stage['checkpoints'] if x['id'] == scope.get('checkpoint')), None) if stage else None
|
||||
if (scope.get('mode') != 'assessment-only' or scope.get('stage') != 'PHASE-10'
|
||||
or scope.get('checkpoint') != 'P10-00' or not cp
|
||||
or scope.get('reportDirectory') != 'docs/automation/stages/PHASE-10'
|
||||
or 'candidate-specific-cleanup-scope' not in cp['approvalsAfter']):
|
||||
raise GateError('Assessment policy requires an exact checkpoint and protected stop')
|
||||
if not re.fullmatch(r'[0-9a-f]{40}', scope.get('handoffCommit','')) or not SAFE_BRANCH.fullmatch(scope.get('handoffBranch','')):
|
||||
raise GateError('Invalid published handoff identity')
|
||||
|
||||
|
||||
class StateStore:
|
||||
@@ -553,6 +572,8 @@ class ProcessRunner:
|
||||
return data
|
||||
|
||||
def run_check(self, engine, check, directory):
|
||||
if check.get('adapter') == 'assessment-evidence-inspection':
|
||||
raise GateError('Evidence inspection cannot replace command-running composite qualification')
|
||||
if local_mode.single(engine.config):
|
||||
local_mode.require_unattended(engine.root,engine.config)
|
||||
working = engine.repo if check['cwd'] == '.' else under(engine.repo, check['cwd'])
|
||||
@@ -581,6 +602,9 @@ class Engine:
|
||||
self.root = Path(root).resolve()
|
||||
self.manifest = manifest
|
||||
self.config = config
|
||||
if manifest.get('executionScope'):
|
||||
self.config = dict(config)
|
||||
self.config['assessmentReportDirectory'] = manifest['executionScope']['reportDirectory']
|
||||
self.repo = Path(config['repository']).resolve()
|
||||
self.store = StateStore(self.root)
|
||||
self.runner = runner or ProcessRunner()
|
||||
@@ -621,6 +645,8 @@ class Engine:
|
||||
if kind in ('environment-qualified', 'roadmap-execution'):
|
||||
request.update(stage='QUEUE', checkpoint='POLICY', baselineCommit=self.manifest['baselineCommit'],
|
||||
candidateCommit=None, evidence=[], detail='Entire exact manifest; development only; no production authority')
|
||||
if self.manifest.get('executionScope'):
|
||||
request['executionScope'] = self.manifest['executionScope']
|
||||
path = self.root / 'requests' / (digest(request) + '.json')
|
||||
atomic(path, request)
|
||||
approval = self.approval_verifier(request)
|
||||
@@ -656,7 +682,7 @@ class Engine:
|
||||
return True
|
||||
|
||||
def policy_hash(self):
|
||||
files = ['controller.py', 'local_mode.py', 'Probe-JobjaktSandbox.ps1',
|
||||
files = ['controller.py', 'local_mode.py', 'assessment_adapter.py', 'Probe-JobjaktSandbox.ps1',
|
||||
'Approve-JobjaktBacklog.ps1', 'config.json', 'schemas/result.schema.json']
|
||||
return digest({name: sha_file(self.root / name) for name in files if (self.root / name).exists()})
|
||||
|
||||
@@ -683,6 +709,8 @@ class Engine:
|
||||
_,sid = local_mode.detect_identity()
|
||||
local_mode.validate_identity(self.config,sid)
|
||||
local_mode.require_unattended(self.root,self.config)
|
||||
if self.manifest.get('executionScope') and not local_mode.single(self.config):
|
||||
raise GateError('Restricted assessment filesystem profile currently qualifies single-user-local only')
|
||||
certificate = under(self.root, self.config['publicKeyCertificate'])
|
||||
if not certificate.is_file() or sha_file(certificate) != self.config['ownerCertificateSha256']:
|
||||
raise GateError('Owner public certificate is missing or differs from protected configuration pin')
|
||||
@@ -800,9 +828,17 @@ class Engine:
|
||||
if ss['entryBaseline'] is None:
|
||||
if self.git('status', '--porcelain', '--untracked-files=all'):
|
||||
raise GateError('New stage requires a clean worktree, including untracked files')
|
||||
self.verify_remote(self.manifest['baselineBranch'] if not any(
|
||||
x['status'] == 'COMPLETED' for x in state['stages'].values())
|
||||
else self.manifest['integrationBranch'], state['integrationCommit'])
|
||||
scope = self.manifest.get('executionScope')
|
||||
if scope and not any(x['status'] == 'COMPLETED' for x in state['stages'].values()):
|
||||
self.verify_remote(scope['handoffBranch'], scope['handoffCommit'])
|
||||
self.git('merge-base', '--is-ancestor', state['integrationCommit'], scope['handoffCommit'])
|
||||
changed = self.git('diff','--name-only',state['integrationCommit'],scope['handoffCommit']).splitlines()
|
||||
if any(not name.startswith('docs/automation/handoffs/pre-phase10/') for name in changed):
|
||||
raise GateError('Published handoff changed outside the evidence envelope')
|
||||
else:
|
||||
self.verify_remote(self.manifest['baselineBranch'] if not any(
|
||||
x['status'] == 'COMPLETED' for x in state['stages'].values())
|
||||
else self.manifest['integrationBranch'], state['integrationCommit'])
|
||||
baseline = state['integrationCommit']
|
||||
branches = self.git('for-each-ref', '--format=%(refname:short)', 'refs/heads').splitlines()
|
||||
if stage['branch'] in branches:
|
||||
@@ -923,7 +959,8 @@ checkpoint; complete means ready for independent checks. Do not infer acceptance
|
||||
return False
|
||||
ss.pop('requestedDecision')
|
||||
prompt = self.prompt(stage, cp, ss, state)
|
||||
if not cp['checks'] or any(not check.get('configured', True) for check in cp['checks']):
|
||||
if not cp['checks'] or any(check.get('adapter') == 'assessment-evidence-inspection'
|
||||
or not check.get('configured', True) for check in cp['checks']):
|
||||
raise GateError('Independent test adapters must be configured and owner-qualified before execution')
|
||||
ss['attempts'] += 1
|
||||
ss['sessions'] += 1
|
||||
@@ -937,6 +974,7 @@ checkpoint; complete means ready for independent checks. Do not infer acceptance
|
||||
sourceCommit=ss['checkpointBase'], manifestSha256=digest(self.manifest), at=utc()))
|
||||
self.store.write(state) # durable RUNNING before launching child
|
||||
result = self.runner.run_session(self, stage, cp, directory, prompt)
|
||||
self.verify_assessment_writes()
|
||||
ss['lastResult'] = result
|
||||
state['recentSessions'].append(dict(id=session_id, stage=stage['id'], checkpoint=cp['id'],
|
||||
outcome=result['outcome'], summary=redact(result['summary'])[:600], directory=str(directory)))
|
||||
@@ -963,6 +1001,7 @@ checkpoint; complete means ready for independent checks. Do not infer acceptance
|
||||
ss['verifiedChecks'] = []
|
||||
for check in cp['checks']:
|
||||
ss['verifiedChecks'].append(self.runner.run_check(self, check, directory / 'checks' / check['id']))
|
||||
self.verify_assessment_writes()
|
||||
ss['evidence'] = []
|
||||
for name in cp['requiredArtifacts']:
|
||||
artifact = under(self.repo, name)
|
||||
@@ -998,6 +1037,17 @@ checkpoint; complete means ready for independent checks. Do not infer acceptance
|
||||
self.store.write(state)
|
||||
return self.finish_checkpoint(stage, cp, ss, state)
|
||||
|
||||
def verify_assessment_writes(self):
|
||||
scope = self.manifest.get('executionScope')
|
||||
if not scope:
|
||||
return
|
||||
prefix = scope['reportDirectory'] + '/'
|
||||
changed = self.git('diff', '--name-only', 'HEAD', '-z').split('\0')
|
||||
changed += self.git('ls-files', '--others', '--exclude-standard', '-z').split('\0')
|
||||
deleted = self.git('diff', '--name-only', '--diff-filter=D', 'HEAD')
|
||||
if deleted or any(name and (not name.startswith(prefix) or not name.endswith('.md')) for name in changed):
|
||||
raise GateError('Read-only assessment modified a forbidden path or deleted a file')
|
||||
|
||||
def content_hash(self):
|
||||
# Git blob hashes include tracked modifications and untracked source without reading secrets into logs.
|
||||
files = self.git('ls-files', '--cached', '--others', '--exclude-standard', '-z').split('\0')
|
||||
@@ -1086,6 +1136,9 @@ checkpoint; complete means ready for independent checks. Do not infer acceptance
|
||||
self.store.write(state)
|
||||
return
|
||||
try:
|
||||
scope = self.manifest.get('executionScope')
|
||||
if scope and (stage['id'], cp['id']) != (scope['stage'], scope['checkpoint']):
|
||||
raise GateError('Restricted assessment scope ends after P10-00; new owner-scoped policy required')
|
||||
# Check source before requesting approval or modifying Git.
|
||||
self.prompt(stage, cp, ss, state)
|
||||
if ss['entryBaseline'] is None:
|
||||
@@ -1179,7 +1232,7 @@ def install(source, root, repository, mode='dedicated-runner'):
|
||||
if root.is_relative_to(repo) or repo.is_relative_to(root):
|
||||
raise GateError('Install control plane outside worker checkout')
|
||||
root.mkdir(parents=True)
|
||||
for name in ('controller.py', 'local_mode.py', 'backlog.json', 'config.example.json'):
|
||||
for name in ('controller.py', 'local_mode.py', 'assessment_adapter.py', 'backlog.json', 'config.example.json'):
|
||||
shutil.copyfile(source / name, root / name)
|
||||
for name in ('schemas', 'prompts'):
|
||||
shutil.copytree(source / name, root / name)
|
||||
@@ -1200,7 +1253,7 @@ def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=['init', 'validate', 'dry-run', 'start', 'resume', 'status',
|
||||
'pause', 'retry', 'current', 'logs', 'recover', 'configure',
|
||||
'setup','auth','auth-status','qualify','approve','prepare','key','enable-unattended'])
|
||||
'setup','auth','auth-status','qualify','approve','prepare','prepare-entry','key','enable-unattended'])
|
||||
parser.add_argument('--control-root', type=Path, required=True)
|
||||
parser.add_argument('--repository')
|
||||
parser.add_argument('--stage')
|
||||
@@ -1218,7 +1271,7 @@ def main(argv=None):
|
||||
and not read_json(root/'backlog.json')['executionEnabled']
|
||||
and not any(s['sessions'] for s in state['stages'].values())):
|
||||
with RepoLock(Path(existing['repository'])):
|
||||
for name in ('controller.py','local_mode.py'):
|
||||
for name in ('controller.py','local_mode.py','assessment_adapter.py'):
|
||||
shutil.copyfile(source/name,root/name)
|
||||
for name in ('Run-JobjaktBacklog.ps1','Approve-JobjaktBacklog.ps1','Probe-JobjaktSandbox.ps1',
|
||||
'Use-JobjaktBacklog.ps1','Register-JobjaktBacklogTask.ps1'):
|
||||
@@ -1326,6 +1379,34 @@ def main(argv=None):
|
||||
'-ApprovalPath',str(root/'approvals'/request.name),'-Thumbprint',thumb,'-ExpectedOwnerSid',sid,
|
||||
'-Disposition',disposition,'-Decision',decision])
|
||||
return result.returncode
|
||||
elif args.command == 'prepare-entry':
|
||||
with RepoLock(engine.repo):
|
||||
validate_manifest(engine.manifest,root)
|
||||
state=engine.store.read()
|
||||
scope=engine.manifest.get('executionScope')
|
||||
if not scope or not engine.manifest['executionEnabled']:
|
||||
raise GateError('Adopt the signed restricted assessment policy first')
|
||||
if (root/'active-process.json').exists() or state['recoveryRequired'] or any(s['sessions'] for s in state['stages'].values()):
|
||||
raise GateError('Entry preparation requires an unused reconciled queue')
|
||||
local_mode.require_unattended(root,engine.config)
|
||||
if engine.git('status','--porcelain','--untracked-files=all') or engine.git('rev-parse','HEAD') != engine.manifest['baselineCommit']:
|
||||
raise GateError('Assessment entry requires a clean exact baseline checkout')
|
||||
engine.verify_remote(scope['handoffBranch'],scope['handoffCommit'])
|
||||
engine.git('merge-base','--is-ancestor',engine.manifest['baselineCommit'],scope['handoffCommit'])
|
||||
stage=next(s for s in engine.manifest['stages'] if s['id']==scope['stage'])
|
||||
cp=next(x for x in stage['checkpoints'] if x['id']==scope['checkpoint'])
|
||||
engine.prompt(stage,cp,state['stages'][stage['id']],state)
|
||||
if not any(check['id']=='qualified-provider-browser-python-pdf-security-preservation'
|
||||
and check.get('configured') is True for check in cp['checks']):
|
||||
raise GateError('P10-00 composite adapter is not qualified')
|
||||
for check in cp['checks']:
|
||||
if check.get('adapter') == 'assessment-evidence-inspection' or not check.get('configured'):
|
||||
raise GateError('P10-00 composite adapter is not qualified')
|
||||
for kind in ['environment-qualified','roadmap-execution',*stage['requiredOwnerApprovals'],*cp['approvalsBefore']]:
|
||||
if not engine.request(stage,cp,kind,state):
|
||||
print('Protected entry request prepared; approve it and run prepare-entry again. No worker launched.')
|
||||
return 3
|
||||
print('All exact restricted entry approvals verified. Owner may deliberately run start. No worker launched.')
|
||||
elif args.command == 'prepare':
|
||||
with RepoLock(engine.repo):
|
||||
state = engine.store.read()
|
||||
|
||||
Reference in New Issue
Block a user