237 lines
12 KiB
Python
237 lines
12 KiB
Python
"""Same-login setup and native MXC qualification. Never grants roadmap authorization."""
|
|
import csv
|
|
from datetime import datetime, timezone, timedelta
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
|
|
PROFILE = 'jobjakt-local'
|
|
CATEGORIES = {'configuration', 'approvals', 'key-file', 'personal', 'normal-codex', 'other-repository', 'production-canary'}
|
|
|
|
|
|
def single(config):
|
|
return config.get('mode') == 'single-user-local'
|
|
|
|
|
|
def detect_identity():
|
|
result = subprocess.run(['whoami','/user','/fo','csv','/nh'],capture_output=True,text=True,check=True,timeout=15)
|
|
username,sid = next(csv.reader(result.stdout.splitlines()))
|
|
return username,sid
|
|
|
|
|
|
def validate_identity(config, sid):
|
|
if not sid.startswith('S-1-') or config['ownerSid'] != sid or config['runnerSid'] != sid:
|
|
raise ValueError('single-user-local requires the configured current Windows identity for both owner and runner')
|
|
|
|
|
|
def validate_config(root, config):
|
|
root = Path(root).resolve()
|
|
repo = Path(config['repository']).resolve()
|
|
auth = Path(config['workerCodeXHome']).resolve()
|
|
normal = Path(config['normalCodeXHome']).resolve()
|
|
temporary = Path(config['workerTemp']).resolve()
|
|
if root.is_relative_to(repo) or repo.is_relative_to(root):
|
|
raise ValueError('ControlRoot and worker must be disjoint')
|
|
if auth == normal or auth.is_relative_to(repo) or not auth.is_relative_to(root):
|
|
raise ValueError('Dedicated auth-only CODEX_HOME must be inside ControlRoot, outside worker, distinct from normal auth')
|
|
if (temporary.is_relative_to(root) or temporary.is_relative_to(normal) or temporary == repo
|
|
or root.is_relative_to(temporary) or normal.is_relative_to(temporary)):
|
|
raise ValueError('Use a separate disposable worker temp directory')
|
|
for name in config.get('toolReadRoots',[]):
|
|
tool=Path(name).resolve()
|
|
if any(tool.is_relative_to(private) or private.is_relative_to(tool) for private in (root,normal)):
|
|
raise ValueError('Tool roots cannot reopen protected control or normal Codex paths')
|
|
if config.get('trustedCodexOverrides'):
|
|
raise ValueError('single-user-local plugin/MCP access is disabled until independently qualified')
|
|
if config.get('executionPolicy') not in ('operator-controlled','qualified-unattended'):
|
|
raise ValueError('Unknown single-user execution policy')
|
|
for name in ('config.toml','plugins','skills','hooks.json','AGENTS.md'):
|
|
if (auth / name).exists():
|
|
raise ValueError('Auth-only CODEX_HOME contains unrelated config, hooks, skills or plugins')
|
|
for name in ('.codex/config.toml','.mcp.json'):
|
|
if (repo / name).exists():
|
|
raise ValueError('Worker has an unqualified repository Codex/MCP configuration')
|
|
|
|
|
|
def filesystem_rules(root, config):
|
|
repo = Path(config['repository']).resolve()
|
|
rules = {':root':'deny', ':minimal':'read', repo.as_posix():'write',
|
|
Path(config['workerTemp']).resolve().as_posix():'write'}
|
|
for name in config.get('toolReadRoots',[]):
|
|
path = Path(name).resolve()
|
|
if path == Path(path.anchor) or path == Path.home():
|
|
raise ValueError('Tool read roots cannot grant an entire drive or home directory')
|
|
rules[path.as_posix()] = 'read'
|
|
for path in (Path(root),Path(config['workerCodeXHome']),Path(config['normalCodeXHome']),
|
|
Path.home()/'.ssh',Path.home()/'AppData/Roaming/Microsoft/Crypto'):
|
|
rules[path.resolve().as_posix()] = 'deny'
|
|
for name in ('.env','.env.local','.env.production','appsettings.Production.json'):
|
|
rules[(repo/name).as_posix()] = 'deny'
|
|
rules[(repo/'.git').as_posix()] = 'read'
|
|
return rules
|
|
|
|
|
|
def permission_args(root, config, command):
|
|
validate_config(root,config)
|
|
table = '{' + ','.join(json.dumps(k)+'='+json.dumps(v) for k,v in filesystem_rules(root,config).items()) + '}'
|
|
args = ['-c','windows.sandbox="mxc"','-c','approval_policy="never"',
|
|
'-c','cli_auth_credentials_store="file"','-c','forced_login_method="chatgpt"',
|
|
'-c','features.apps=false','-c','features.plugins=false','-c','features.remote_plugin=false',
|
|
'-c','mcp_servers={}','-c','plugins={}','-c','web_search="disabled"',
|
|
'-c',f'default_permissions="{PROFILE}"',
|
|
'-c',f'permissions.{PROFILE}.filesystem={table}',
|
|
'-c',f'permissions.{PROFILE}.network.enabled=false']
|
|
if command == 'sandbox':
|
|
args += ['--include-managed-config','--permission-profile',PROFILE,'--']
|
|
return args
|
|
|
|
|
|
def binding(root,config):
|
|
value = {k:v for k,v in config.items() if k != 'executionPolicy'}
|
|
for name in ('local_mode.py','controller.py','Probe-JobjaktSandbox.ps1'):
|
|
source = Path(root)/name
|
|
if source.exists():
|
|
value[name+'Sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
return hashlib.sha256(json.dumps(value,sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def probes_passed(result):
|
|
files = result.get('files',[])
|
|
return (result.get('positiveControl') is True and result.get('networkDenied') is True
|
|
and result.get('signingKeyDenied') is True
|
|
and {f.get('category') for f in files} == CATEGORIES
|
|
and all(f.get('readDenied') is True and f.get('writeDenied') is True for f in files))
|
|
|
|
|
|
def require_unattended(root,config):
|
|
validate_config(root,config)
|
|
if config['executionPolicy'] != 'qualified-unattended':
|
|
raise ValueError('operator-controlled pause: no autonomous worker is launched; inspect requests and qualify MXC first')
|
|
path = Path(root)/'qualification.json'
|
|
if not path.is_file():
|
|
raise ValueError('Native sandbox qualification is missing')
|
|
report = json.loads(path.read_text(encoding='utf-8'))
|
|
issued = datetime.fromisoformat(report['issuedUtc'])
|
|
now = datetime.now(timezone.utc)
|
|
if (report.get('status') != 'PASS' or report.get('failure')
|
|
or report.get('binding') != binding(root,config) or not probes_passed(report.get('probes',{}))
|
|
or report.get('ownerKeyDenied') is not True
|
|
or issued > now+timedelta(minutes=5) or now-issued > timedelta(hours=24)):
|
|
raise ValueError('Native sandbox/key qualification failed, expired or changed; operator-controlled pause required')
|
|
|
|
|
|
def configure_current_user(root,config):
|
|
username,sid = detect_identity()
|
|
root = Path(root).resolve()
|
|
normal = Path(os.environ.get('CODEX_HOME',str(Path.home()/'.codex'))).resolve()
|
|
config.update(mode='single-user-local',executionPolicy='operator-controlled',
|
|
windowsUsername=username,ownerSid=sid,runnerSid=sid,normalCodeXHome=str(normal),
|
|
workerCodeXHome=str(root/'auth'), workerTemp=str(root.parent/(root.name+'-worker-temp')),
|
|
codexExecutable=shutil.which('codex') or 'codex',gitExecutable=shutil.which('git') or 'git',
|
|
powershellExecutable=shutil.which('pwsh') or 'pwsh',pythonExecutable=sys.executable,
|
|
trustedCodexOverrides=[],toolReadRoots=sorted({str(Path(sys.executable).parent),
|
|
str(Path(shutil.which('pwsh') or sys.executable).parent)}), ownerCertificateThumbprint=None)
|
|
validate_config(root,config)
|
|
Path(config['workerCodeXHome']).mkdir(parents=True,exist_ok=True)
|
|
Path(config['workerTemp']).mkdir(parents=True,exist_ok=True)
|
|
return config
|
|
|
|
|
|
def auth_command(config, status=False):
|
|
args = [config['codexExecutable'],'-c','cli_auth_credentials_store="file"',
|
|
'-c','forced_login_method="chatgpt"','login']
|
|
return args + (['status'] if status else [])
|
|
|
|
|
|
def qualify(engine):
|
|
"""Only synthetic canaries and a local TCP listener. No model or production contact."""
|
|
root,config = engine.root,engine.config
|
|
validate_config(root,config)
|
|
_,sid = detect_identity()
|
|
validate_identity(config,sid)
|
|
token = uuid.uuid4().hex
|
|
workspace = engine.repo/'.artifacts/orchestrator-probes'/token
|
|
outside = root.parent/('orchestrator-probes-'+token)
|
|
normal = Path(config['normalCodeXHome'])/('.jobjakt-probe-'+token)
|
|
workspace.mkdir(parents=True)
|
|
outside.mkdir()
|
|
paths = []
|
|
owned = []
|
|
key = None
|
|
report = dict(issuedUtc=datetime.now(timezone.utc).isoformat(),binding=binding(root,config),
|
|
status='BLOCKED',ownerKeyDenied=False,probes={},failure=None)
|
|
listener = socket.socket()
|
|
try:
|
|
for category in sorted(CATEGORIES):
|
|
path = normal if category == 'normal-codex' else (
|
|
root/('canary-'+token+'-'+category) if category in ('configuration','approvals','key-file')
|
|
else outside/category)
|
|
path.write_text('SYNTHETIC-'+token,encoding='utf-8')
|
|
owned.append(path)
|
|
with path.open('r+b'):
|
|
pass # prove a denial is not a missing file or preexisting host ACL failure
|
|
paths.append(dict(category=category,path=str(path)))
|
|
helper = root/'Probe-JobjaktSandbox.ps1'
|
|
setup = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',str(helper),
|
|
'-Action','CreateKey','-Tag',token],text=True,capture_output=True,timeout=30)
|
|
if setup.returncode:
|
|
raise ValueError('Temporary signing-key positive control failed: '+setup.stderr[:250])
|
|
key = json.loads(setup.stdout)
|
|
owner_key = None
|
|
if config.get('ownerCertificateThumbprint'):
|
|
positive = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',str(helper),
|
|
'-Action','TestOwnerKey','-Thumbprint',config['ownerCertificateThumbprint']],
|
|
text=True,capture_output=True,timeout=30)
|
|
if positive.returncode:
|
|
raise ValueError('Configured owner key is not usable in the trusted operator context')
|
|
owner_key = json.loads(positive.stdout)
|
|
if owner_key['certificateSha256'] != config['ownerCertificateSha256']:
|
|
raise ValueError('Configured owner private key does not match the pinned public certificate')
|
|
listener.bind(('127.0.0.1',0))
|
|
listener.listen(4)
|
|
with socket.create_connection(listener.getsockname(),timeout=2):
|
|
pass
|
|
accepted,_ = listener.accept()
|
|
accepted.close()
|
|
plan = dict(files=paths,port=listener.getsockname()[1],key=key,
|
|
ownerThumbprint=config.get('ownerCertificateThumbprint'),ownerKeyName=owner_key['keyName'] if owner_key else None)
|
|
(workspace/'plan.json').write_text(json.dumps(plan),encoding='utf-8')
|
|
shutil.copyfile(helper,workspace/'probe.ps1')
|
|
argv = [config['codexExecutable'],'sandbox','-C',str(engine.repo),
|
|
*permission_args(root,config,'sandbox'),config['powershellExecutable'],'-NoProfile',
|
|
'-File',str(workspace/'probe.ps1'),'-Action','Probe','-PlanPath',str(workspace/'plan.json')]
|
|
logs = root/'probe'/token
|
|
engine.runner.supervise(engine,argv,logs,45)
|
|
probe = json.loads((logs/'stdout.log').read_text(encoding='utf-8'))
|
|
report.update(probes=probe,ownerKeyDenied=probe.get('ownerKeyDenied') is True,
|
|
status='PASS' if probes_passed(probe) else 'FAIL')
|
|
report['scope'] = 'Synthetic filesystem/network/signing canaries; real owner key '+('probed' if owner_key else 'NOT_CONFIGURED')
|
|
except Exception as error:
|
|
report['failure'] = str(error)[:500]
|
|
finally:
|
|
listener.close()
|
|
if key:
|
|
cleanup = subprocess.run([config['powershellExecutable'],'-NoProfile','-File',
|
|
str(root/'Probe-JobjaktSandbox.ps1'),'-Action','RemoveKey','-Tag',token,
|
|
'-Thumbprint',key['thumbprint']],capture_output=True,text=True,timeout=30)
|
|
if cleanup.returncode:
|
|
report['status'] = 'BLOCKED'
|
|
report['failure'] = 'Canary certificate cleanup failed; inspect the recorded synthetic thumbprint'
|
|
for path in owned:
|
|
path.unlink(missing_ok=True)
|
|
# Only the exact disposable directories created above are removed.
|
|
if workspace.resolve().is_relative_to(engine.repo.resolve()/'.artifacts/orchestrator-probes'):
|
|
shutil.rmtree(workspace)
|
|
if outside.parent.resolve() == root.parent.resolve() and outside.name == 'orchestrator-probes-'+token:
|
|
shutil.rmtree(outside)
|
|
engine_file = root/'qualification.json'
|
|
from controller import atomic
|
|
atomic(engine_file,report)
|
|
return report
|