Files
jobtrackingapp/automation/local_mode.py
T

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