Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f5c9b8b9 | ||
|
|
bdd7cb1f8a | ||
|
|
d8bdb1d385 | ||
|
|
ef768192c3 | ||
|
|
c920390469 | ||
|
|
1f4066b86c | ||
|
|
beabd05396 | ||
|
|
5a4e952b39 |
@@ -16,6 +16,28 @@ from .agent_observability_store import AgentObservabilityStore
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
_ROLE_WHITELIST = {"user", "group_admin", "owner"}
|
||||
_authenticator = None # callable() -> {"user_id":..., "role":...} | None, injected by host
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the querying tenant.
|
||||
|
||||
The callable returns the authenticated principal (dict with "user_id" and
|
||||
a whitelisted "role") or None for anonymous. The client-supplied role and
|
||||
user/group ids are NOT trusted to escalate scope.
|
||||
"""
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def _principal():
|
||||
if _authenticator is not None:
|
||||
p = _authenticator()
|
||||
if isinstance(p, dict) and p.get("role") in _ROLE_WHITELIST:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
@@ -23,11 +45,30 @@ def record_agent_execution(payload: dict[str, Any]):
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
records = store.query(user_id, group_id, role)
|
||||
# The authenticated principal (tenant) is authoritative; the request params
|
||||
# role/user_id/group_id can never choose owner or another tenant.
|
||||
principal = _principal()
|
||||
pr_user = principal.get("user_id") if principal else None
|
||||
pr_role = principal.get("role", "user") if principal else "user"
|
||||
|
||||
if pr_role == "owner":
|
||||
# Server-verified owner may browse a requested scope.
|
||||
q_user, q_group, q_role = user_id, group_id, "owner"
|
||||
summary_scope = None
|
||||
elif pr_user:
|
||||
# Authenticated non-owner: strictly scoped to own tenant.
|
||||
q_user, q_group, q_role = pr_user, None, "user"
|
||||
summary_scope = pr_user
|
||||
else:
|
||||
# Anonymous: nothing scoped to them.
|
||||
q_user, q_group, q_role = None, None, "user"
|
||||
summary_scope = None
|
||||
|
||||
records = store.query(q_user, q_group, q_role)
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
"summary": store.load().summary(summary_scope),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,11 +8,15 @@ metadata are stored for Web UI statistics and audit.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
|
||||
# Constrain execution IDs (used as filename) to a single safe path segment.
|
||||
_SAFE_EXECUTION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
class AgentObservabilityStore:
|
||||
def __init__(self, root: str = "runtime/agent-observability"):
|
||||
@@ -21,9 +25,17 @@ class AgentObservabilityStore:
|
||||
self.records_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
execution_id = str(record.execution_id or "")
|
||||
if not _SAFE_EXECUTION_ID.fullmatch(execution_id) or ".." in execution_id:
|
||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
||||
base = self.records_dir.resolve()
|
||||
target = (self.records_dir / f"{execution_id}.json").resolve()
|
||||
# The whitelist forbids separators/traversal, so the resolved target
|
||||
# must sit directly inside the records dir; verify defensively.
|
||||
if target.parent != base:
|
||||
raise ValueError(f"invalid execution_id: {execution_id!r}")
|
||||
data = record.to_dict()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
target.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
def load(self) -> AgentObservability:
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
@@ -13,6 +15,29 @@ class RecoveryEvidenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# Relative evidence path: a safe relative path, never absolute, never with
|
||||
# traversal segments, never with backslash separators.
|
||||
_SAFE_EVIDENCE_REL = re.compile(r"^[A-Za-z0-9_./@-]+$")
|
||||
|
||||
|
||||
def _contained_evidence(path: Path, base: Path) -> Path:
|
||||
"""Resolve an evidence path and guarantee it stays under `base`."""
|
||||
raw = str(path)
|
||||
if (
|
||||
os.path.isabs(raw)
|
||||
or "\\" in raw
|
||||
or raw.startswith("/")
|
||||
or ".." in Path(raw).parts
|
||||
or not _SAFE_EVIDENCE_REL.fullmatch(raw)
|
||||
):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_INVALID")
|
||||
base_resolved = base.resolve()
|
||||
target = (base_resolved / raw).resolve()
|
||||
if base_resolved != target and not str(target).startswith(str(base_resolved) + os.sep):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_ESCAPE")
|
||||
return target
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
@@ -81,10 +106,11 @@ def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
||||
evidence = receipt["evidence_files"]
|
||||
if not isinstance(evidence, list) or not evidence:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||
base = receipt_path.resolve().parent
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||
target = Path(item["path"])
|
||||
target = _contained_evidence(Path(item["path"]), base)
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
|
||||
@@ -110,6 +110,11 @@ SECRET_VAL_RE = re.compile(
|
||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||
)
|
||||
|
||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
||||
|
||||
READ_ONLY_GET_PATHS = frozenset({
|
||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||
@@ -1042,12 +1047,25 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
commands.append(f.name)
|
||||
command_detail=None
|
||||
if cmd_filter:
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
||||
cmd = str(cmd_filter or '')
|
||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not d.exists():
|
||||
continue
|
||||
base = d.resolve()
|
||||
fp = (base / cmd).resolve()
|
||||
# Strict containment: must resolve directly inside one of the
|
||||
# allowed command directories.
|
||||
if fp.parent != base or not fp.is_file():
|
||||
continue
|
||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not command_detail:
|
||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||
return self.sendj(command_detail)
|
||||
|
||||
@@ -57,10 +57,36 @@ def seed_base_models():
|
||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||
|
||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
||||
def token_identity(token: str):
|
||||
"""Resolve the server-authoritative identity bound to a token.
|
||||
|
||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
||||
<token> -> authenticated principal, role 'user'
|
||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
for line in TOKEN_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = [x.strip() for x in line.split(':')]
|
||||
if not parts or parts[0] != token:
|
||||
continue
|
||||
if len(parts) >= 3:
|
||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
||||
return {'username': safe_name(parts[1]), 'role': role}
|
||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if not token or not TOKEN_FILE.exists(): return False
|
||||
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||
@@ -90,8 +116,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||
def actor(self):
|
||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||
ident = token_identity(token)
|
||||
if ident is None:
|
||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
||||
def require_write(self, roles=None):
|
||||
a = self.actor()
|
||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||
|
||||
@@ -135,6 +135,11 @@ SECRET_VAL_RE = re.compile(
|
||||
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
||||
)
|
||||
|
||||
# Control-plane file read: enforce auth, strict identifier, containment and a
|
||||
# size cap on the command-detail read exposed by /admin/ai-evolution/api/data.
|
||||
MAX_COMMAND_READ_BYTES = 1024 * 1024
|
||||
_COMMAND_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
||||
|
||||
READ_ONLY_GET_PATHS = frozenset({
|
||||
'/', '/health', '/api/health', '/api/openapi.yaml',
|
||||
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
||||
@@ -690,20 +695,6 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
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)
|
||||
@@ -949,6 +940,17 @@ class H(BaseHTTPRequestHandler):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
# Governed V2 bridge release routes (previously shadowed by the
|
||||
# redefinition of do_POST in this class).
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
@@ -1248,12 +1250,25 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
commands.append(f.name)
|
||||
command_detail=None
|
||||
if cmd_filter:
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj({'error':'unauthorized','hint':'Bearer token required'},401)
|
||||
cmd = str(cmd_filter or '')
|
||||
if not _COMMAND_FILENAME_RE.fullmatch(cmd) or '..' in cmd:
|
||||
return self.sendj({'command':cmd_filter,'error':'unsafe_command'},400)
|
||||
for d in cmd_dirs:
|
||||
if d.exists():
|
||||
fp=d/cmd_filter
|
||||
if fp.exists():
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not d.exists():
|
||||
continue
|
||||
base = d.resolve()
|
||||
fp = (base / cmd).resolve()
|
||||
# Strict containment: must resolve directly inside one of the
|
||||
# allowed command directories.
|
||||
if fp.parent != base or not fp.is_file():
|
||||
continue
|
||||
if fp.stat().st_size > MAX_COMMAND_READ_BYTES:
|
||||
return self.sendj({'command':cmd_filter,'error':'command_too_large'},413)
|
||||
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
||||
break
|
||||
if not command_detail:
|
||||
command_detail={'command':cmd_filter,'error':'not_found'}
|
||||
return self.sendj(command_detail)
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
||||
from http import cookies
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||
@@ -13,7 +14,11 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
||||
if SECRET in ('', 'change-me-before-production'):
|
||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
||||
ROLES = ['owner','admin','user']
|
||||
NODES = ['server-hw','server-local']
|
||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||
@@ -43,19 +48,46 @@ def audit(actor, action, target='', payload=None):
|
||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||
def notify(event_type,payload):
|
||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
||||
def hash_pw(p):
|
||||
salt=os.urandom(16).hex()
|
||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return f'pbkdf2${salt}${dk}'
|
||||
def check_pw(stored,p):
|
||||
if not stored or not isinstance(stored,str) or not stored: return False
|
||||
if _is_pbkdf2(stored):
|
||||
_,salt,dk=stored.split('$')
|
||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return hmac.compare_digest(dk,calc)
|
||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
||||
# credential and never for the known bootstrap default; anything else is
|
||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
||||
if stored == p: return False
|
||||
try:
|
||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
except Exception:
|
||||
return False
|
||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
||||
def token(username,role):
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
def parse_session(header):
|
||||
if not header: return None
|
||||
c=cookies.SimpleCookie(); c.load(header)
|
||||
if 'aios_session' not in c: return None
|
||||
parts=c['aios_session'].value.split('|')
|
||||
if len(parts)!=4: return None
|
||||
data='|'.join(parts[:3]); sig=parts[3]
|
||||
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||
username,role,exp,sig=parts
|
||||
data=f'{username}|{role}|{exp}'
|
||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
||||
try:
|
||||
if int(exp) <= int(time.time()): return None
|
||||
except Exception: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
||||
def require(user, roles): return bool(user and user['role'] in roles)
|
||||
def table(data):
|
||||
if not data: return '<p class="muted">No records.</p>'
|
||||
@@ -215,7 +247,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if path=='/login':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||
if path=='/register':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib, html, json, os, sqlite3, time, uuid, urllib.parse
|
||||
from http import cookies
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import hmac # SEC-P0-BOOTSTRAP-CREDENTIAL
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_WEBOPS_ROOT', Path(__file__).resolve().parents[1]))
|
||||
DB_PATH = Path(os.getenv('AIOS_WEBOPS_DB', ROOT / 'data' / 'webops.sqlite3'))
|
||||
@@ -13,7 +14,11 @@ PUBLIC_DOMAIN = os.getenv('AIOS_PUBLIC_DOMAIN','https://cnnetmkt.top')
|
||||
WEBOPS_DOMAIN = os.getenv('AIOS_WEBOPS_DOMAIN','https://ops.cnnetmkt.top')
|
||||
API_DOMAIN = os.getenv('AIOS_API_DOMAIN','https://api.cnnetmkt.top')
|
||||
WECHAT_DOMAIN = os.getenv('AIOS_WECHAT_CALLBACK_DOMAIN','https://wechat.cnnetmkt.top')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET','change-me-before-production')
|
||||
SECRET = os.getenv('AIOS_WEBOPS_SECRET') or ''
|
||||
# SEC-P0: fail closed — never boot with a missing/insecure default secret.
|
||||
if SECRET in ('', 'change-me-before-production'):
|
||||
raise SystemExit('AIOS_WEBOPS_SECRET not set (or insecure default). Refusing to start: fail closed.')
|
||||
SESSION_TTL = int(os.getenv('AIOS_SESSION_TTL','3600'))
|
||||
ROLES = ['owner','admin','user']
|
||||
NODES = ['server-hw','server-local']
|
||||
PAGES = [('/dashboard','Dashboard'),('/nodes','Nodes'),('/roles','Roles'),('/routing','Routing'),('/failover','Failover'),('/approvals','Approvals'),('/operations','Operations'),('/users','Users'),('/quota','Quota'),('/models','Models'),('/memory','Memory'),('/channels/wechat','WeChat'),('/life','Life Core'),('/patches','Patch Studio'),('/optimization','Optimization'),('/simulation','Simulation'),('/logs','Logs'),('/config','Config'),('/uat','UAT'),('/versions','Versions'),('/evidence','Evidence')]
|
||||
@@ -43,19 +48,46 @@ def audit(actor, action, target='', payload=None):
|
||||
exec_sql('INSERT INTO audit_logs(id,actor,action,target,payload,created_at) VALUES(?,?,?,?,?,?)',(uid('audit'),actor,action,target,jdump(payload or {}),now()))
|
||||
def notify(event_type,payload):
|
||||
exec_sql('INSERT INTO owner_notifications(id,event_type,payload,status,created_at) VALUES(?,?,?,?,?)',(uid('notify'),event_type,jdump(payload),'queued',now()))
|
||||
def hash_pw(p): return hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
def check_pw(stored,p): return stored in (p, hash_pw(p))
|
||||
_PBKDF2_ITERS = int(os.getenv('AIOS_PBKDF2_ITERS','260000'))
|
||||
def _is_pbkdf2(stored): return isinstance(stored,str) and stored.startswith('pbkdf2$') and stored.count('$')==2
|
||||
def hash_pw(p):
|
||||
salt=os.urandom(16).hex()
|
||||
dk=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return f'pbkdf2${salt}${dk}'
|
||||
def check_pw(stored,p):
|
||||
if not stored or not isinstance(stored,str) or not stored: return False
|
||||
if _is_pbkdf2(stored):
|
||||
_,salt,dk=stored.split('$')
|
||||
calc=hashlib.pbkdf2_hmac('sha256', p.encode(), bytes.fromhex(salt), _PBKDF2_ITERS).hex()
|
||||
return hmac.compare_digest(dk,calc)
|
||||
# Legacy record path: NEVER accept the plaintext form as a credential.
|
||||
# A legacy (unsalted) sha256 is honored ONLY as a one-time owner migration
|
||||
# credential and never for the known bootstrap default; anything else is
|
||||
# rejected so the account must be re-seeded via a secure channel (fail closed).
|
||||
if stored == p: return False
|
||||
try:
|
||||
old=hashlib.sha256((SECRET+'|'+p).encode()).hexdigest()
|
||||
except Exception:
|
||||
return False
|
||||
return (hmac.compare_digest(old,stored)) and (p not in {'bootstrap-change-me'})
|
||||
def token(username,role):
|
||||
data=f'{username}|{role}|{int(time.time())}'; sig=hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]; return data+'|'+sig
|
||||
exp=int(time.time())+SESSION_TTL
|
||||
data=f'{username}|{role}|{exp}'
|
||||
sig=hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
return data+'|'+sig
|
||||
def parse_session(header):
|
||||
if not header: return None
|
||||
c=cookies.SimpleCookie(); c.load(header)
|
||||
if 'aios_session' not in c: return None
|
||||
parts=c['aios_session'].value.split('|')
|
||||
if len(parts)!=4: return None
|
||||
data='|'.join(parts[:3]); sig=parts[3]
|
||||
if hashlib.sha256((SECRET+'|'+data).encode()).hexdigest()[:16]!=sig: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(parts[0],parts[1],'approved'))
|
||||
username,role,exp,sig=parts
|
||||
data=f'{username}|{role}|{exp}'
|
||||
if not hmac.compare_digest(hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest(), sig): return None
|
||||
try:
|
||||
if int(exp) <= int(time.time()): return None
|
||||
except Exception: return None
|
||||
return one('SELECT * FROM users WHERE username=? AND role=? AND status=?',(username,role,'approved'))
|
||||
def require(user, roles): return bool(user and user['role'] in roles)
|
||||
def table(data):
|
||||
if not data: return '<p class="muted">No records.</p>'
|
||||
@@ -215,7 +247,7 @@ class H(BaseHTTPRequestHandler):
|
||||
if path=='/login':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
u=one('SELECT * FROM users WHERE username=? AND status=?',(data.get('username',[''])[0],'approved'))
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Lax"]); return
|
||||
if u and check_pw(u.get('password_hash') or '',data.get('password',[''])[0]): audit(u['username'],'login','webops',{}); self.redirect('/dashboard',[f"aios_session={token(u['username'],u['role'])}; Path=/; HttpOnly; SameSite=Strict"]); return
|
||||
self.send_body(403,layout('Login Failed','<p>登录失败。</p>',None)); return
|
||||
if path=='/register':
|
||||
n=int(self.headers.get('Content-Length','0') or 0); data=urllib.parse.parse_qs(self.rfile.read(n).decode())
|
||||
|
||||
@@ -325,6 +325,7 @@ def reopen_owner_approval(service: Any, raw: Mapping[str, Any], *,
|
||||
try:
|
||||
service.sagas.compare_and_set(
|
||||
req.candidate_id,
|
||||
expected_revision=saga.get("revision"),
|
||||
expected_states={_state("APPROVED")},
|
||||
target=_state("READY_FOR_APPROVAL"),
|
||||
event=EVENT_NAME,
|
||||
|
||||
@@ -105,6 +105,28 @@ pre { white-space: pre-wrap; word-break: break-word; }
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Security: all dynamic values (API/git/agent/owner-gate) are rendered with
|
||||
// textContent / DOM APIs. innerHTML is never used to inject untrusted data.
|
||||
function clearNode(node) {
|
||||
if (!node) return;
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
function setText(id, text) {
|
||||
const n = document.getElementById(id);
|
||||
if (n) n.textContent = text;
|
||||
}
|
||||
function addClass(node, cls) { if (node) node.classList.add(cls); }
|
||||
function div(cls) { const d = document.createElement('div'); if (cls) addClass(d, cls); return d; }
|
||||
function span(cls, text) { const s = document.createElement('span'); if (cls) addClass(s, cls); if (text != null) s.textContent = text; return s; }
|
||||
|
||||
// Centralized CSRF token source: server-issued meta tag, or double-submit cookie.
|
||||
function csrfToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta && meta.content) return meta.content;
|
||||
const m = document.cookie.match(/(?:^|;\s*)aios_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
@@ -124,130 +146,204 @@ async function fetchData() {
|
||||
|
||||
async function loadVersions() {
|
||||
const el = document.getElementById('versions-list');
|
||||
el.innerHTML = '<div class="loading">Loading versions...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading versions...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/versions');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '<div class="grid">';
|
||||
clearNode(el);
|
||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
||||
const grid = div('grid');
|
||||
for (const v of data.versions || []) {
|
||||
html += '<div class="card"><div class="card-title">' + v.tag + '</div><div class="card-value">' + (v.version || v.tag) + '</div></div>';
|
||||
const card = div('card');
|
||||
const t = div('card-title'); t.textContent = v.tag || '';
|
||||
const val = div('card-value'); val.textContent = v.version || v.tag || '';
|
||||
card.appendChild(t); card.appendChild(val); grid.appendChild(card);
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
|
||||
el.appendChild(grid);
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load versions: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommands() {
|
||||
const el = document.getElementById('commands-list');
|
||||
el.innerHTML = '<div class="loading">Loading commands...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading commands...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/data');
|
||||
const data = await r.json();
|
||||
let cmds = data.commands || [];
|
||||
let html = '<div style="display:flex;gap:24px;margin-bottom:16px;">';
|
||||
html += '<div><div class="card"><div class="card-title">Total</div><div class="card-value">' + cmds.length + '</div></div></div>';
|
||||
html += '<div><div class="card"><div class="card-title">By Category</div>';
|
||||
const cmds = data.commands || [];
|
||||
clearNode(el);
|
||||
const wrapper = div('');
|
||||
wrapper.style.display = 'flex'; wrapper.style.gap = '24px'; wrapper.style.marginBottom = '16px';
|
||||
const totalCard = div('card');
|
||||
const tcTitle = div('card-title'); tcTitle.textContent = 'Total';
|
||||
const tcVal = div('card-value'); tcVal.textContent = String(cmds.length);
|
||||
totalCard.appendChild(tcTitle); totalCard.appendChild(tcVal); wrapper.appendChild(totalCard);
|
||||
const catCard = div('card');
|
||||
const ccTitle = div('card-title'); ccTitle.textContent = 'By Category';
|
||||
catCard.appendChild(ccTitle);
|
||||
const cats = {};
|
||||
for (const c of cmds) { const k = c.category || 'uncategorized'; cats[k] = (cats[k] || 0) + 1; }
|
||||
for (const [k, v] of Object.entries(cats)) html += '<div style="font-size:14px;color:#8b949e;margin:4px 0;">' + k + ': ' + v + '</div>';
|
||||
html += '</div></div></div>';
|
||||
for (const cmd of cmds.slice().reverse()) {
|
||||
html += '<div class="command-item"><div class="meta">' + cmd.category + ' | ' + cmd.filename + '</div><div class="name">' + cmd.title + '</div><div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + cmd.summary + '</div></div>';
|
||||
for (const [k, v] of Object.entries(cats)) {
|
||||
const line = div(''); line.style.fontSize = '14px'; line.style.color = '#8b949e'; line.style.margin = '4px 0';
|
||||
line.textContent = k + ': ' + v; catCard.appendChild(line);
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
|
||||
wrapper.appendChild(catCard); el.appendChild(wrapper);
|
||||
for (const cmd of cmds.slice().reverse()) {
|
||||
const item = div('command-item');
|
||||
const meta = div('meta'); meta.textContent = (cmd.category || '') + ' | ' + (cmd.filename || '');
|
||||
const name = div('name'); name.textContent = cmd.title || '';
|
||||
const summary = div(''); summary.style.fontSize = '13px'; summary.style.color = '#c9d1d9'; summary.style.marginTop = '4px';
|
||||
summary.textContent = cmd.summary || '';
|
||||
item.appendChild(meta); item.appendChild(name); item.appendChild(summary);
|
||||
el.appendChild(item);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load commands: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
const el = document.getElementById('agents-list');
|
||||
el.innerHTML = '<div class="loading">Loading agent runs...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading agent runs...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/agents');
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = '<div class="error">' + data.error + '</div>'; return; }
|
||||
let html = '';
|
||||
for (const agent of data.agents || []) {
|
||||
html += '<div class="agent-item"><div class="agent-title">' + agent.name + ' <span class="state state-' + (agent.status === 'running' ? 'running' : (agent.status === 'ok' ? 'completed' : 'failed')) + '">' + agent.status + '</span></div><div class="agent-meta">' + (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-') + '</div></div>';
|
||||
clearNode(el);
|
||||
if (data.error) { const e = div('error'); e.textContent = data.error; el.appendChild(e); return; }
|
||||
const agents = data.agents || [];
|
||||
if (!agents.length) {
|
||||
const none = div(''); none.style.padding = '24px'; none.style.color = '#8b949e';
|
||||
none.textContent = 'No agent runs recorded yet.'; el.appendChild(none); return;
|
||||
}
|
||||
el.innerHTML = html || '<div style="padding:24px;color:#8b949e;">No agent runs recorded yet.</div>';
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load agents: ' + e.message + '</div>';
|
||||
for (const agent of agents) {
|
||||
const item = div('agent-item');
|
||||
const title = div('agent-title');
|
||||
title.appendChild(document.createTextNode(agent.name || ''));
|
||||
const st = (agent.status === 'running') ? 'running' : ((agent.status === 'ok') ? 'completed' : 'failed');
|
||||
const badge = span('state state-' + st, agent.status || '');
|
||||
title.appendChild(badge);
|
||||
const meta = div('agent-meta');
|
||||
meta.textContent = (agent.runs || 0) + ' runs | last: ' + (agent.last_run || '-');
|
||||
item.appendChild(title); item.appendChild(meta); el.appendChild(item);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load agents: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
|
||||
async function loadOwnerGate() {
|
||||
const el = document.getElementById('arch-owner-content');
|
||||
el.innerHTML = '<div class="loading">Loading owner gate data...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading owner gate data...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '<div class="row">';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Pending Requests</div><div class="card-value">' + data.pending_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Approved</div><div class="card-value">' + data.approved_count + '</div></div></div>';
|
||||
html += '<div class="col"><div class="card"><div class="card-title">Rejected</div><div class="card-value">' + data.rejected_count + '</div></div></div>';
|
||||
html += '</div>';
|
||||
html += '<div class="card"><div class="card-title">Pending Approval Requests</div>';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">ID: ' + req.change_id + ' | Risk: ' + req.risk + '</div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin-top:4px;">' + (req.reason || '') + '</div>';
|
||||
html += '</div>';
|
||||
clearNode(el);
|
||||
const row = div('row');
|
||||
const cols = [['Pending Requests', data.pending_count], ['Approved', data.approved_count], ['Rejected', data.rejected_count]];
|
||||
for (const [label, value] of cols) {
|
||||
const col = div('col'); const card = div('card');
|
||||
const t = div('card-title'); t.textContent = label;
|
||||
const val = div('card-value'); val.textContent = value;
|
||||
card.appendChild(t); card.appendChild(val); col.appendChild(card); row.appendChild(col);
|
||||
}
|
||||
el.appendChild(row);
|
||||
const listCard = div('card');
|
||||
const lt = div('card-title'); lt.textContent = 'Pending Approval Requests'; listCard.appendChild(lt);
|
||||
const reqs = data.pending_requests || [];
|
||||
if (reqs.length) {
|
||||
for (const req of reqs) {
|
||||
const item = div('command-item');
|
||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
||||
const meta = div('meta'); meta.textContent = 'ID: ' + (req.change_id || '') + ' | Risk: ' + (req.risk || '');
|
||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.marginTop = '4px';
|
||||
reason.textContent = req.reason || '';
|
||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason);
|
||||
listCard.appendChild(item);
|
||||
}
|
||||
} else {
|
||||
html += '<div style="color:#8b949e;padding:12px;">No pending requests.</div>';
|
||||
const none = div(''); none.style.color = '#8b949e'; none.style.padding = '12px';
|
||||
none.textContent = 'No pending requests.'; listCard.appendChild(none);
|
||||
}
|
||||
html += '</div>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
el.appendChild(listCard);
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Real governed approve: posts the decision to the server-side governed endpoint.
|
||||
async function approveDecision(changeId, decision, btn) {
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/admin/ai-evolution/api/owner-gate/decision', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
|
||||
body: JSON.stringify({ change_id: changeId, decision: decision })
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok) throw new Error(payload.error || ('HTTP ' + res.status));
|
||||
await loadApprove();
|
||||
} catch (e) {
|
||||
if (btn) btn.disabled = false;
|
||||
const out = document.getElementById('arch-approve-content');
|
||||
const err = div('error'); err.textContent = 'Decision failed: ' + e.message;
|
||||
if (out) out.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApprove() {
|
||||
const el = document.getElementById('arch-approve-content');
|
||||
el.innerHTML = '<div class="loading">Loading pending approvals...</div>';
|
||||
clearNode(el);
|
||||
const loading = div('loading'); loading.textContent = 'Loading pending approvals...'; el.appendChild(loading);
|
||||
try {
|
||||
const r = await fetch('/admin/ai-evolution/api/owner-gate');
|
||||
const data = await r.json();
|
||||
let html = '';
|
||||
if (data.pending_requests && data.pending_requests.length) {
|
||||
html += '<div class="card"><div class="card-title">Pending changes requiring your approval</div>';
|
||||
for (const req of data.pending_requests) {
|
||||
html += '<div class="command-item">';
|
||||
html += '<div class="name">' + (req.title || req.change_id) + '</div>';
|
||||
html += '<div class="meta">Risk: <strong>' + req.risk + '</strong></div>';
|
||||
html += '<div style="font-size:13px;color:#c9d1d9;margin:8px 0;">' + (req.reason || '') + '</div>';
|
||||
html += '<div style="display:flex;gap:8px;">';
|
||||
html += '<button onclick="ownerAction.classList.add('\'approved\');this.disabled=true" style="padding:6px 16px;background:#238636;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">✓ Approve</button>';
|
||||
html += '</div></div>';
|
||||
clearNode(el);
|
||||
const reqs = data.pending_requests || [];
|
||||
if (reqs.length) {
|
||||
const card = div('card');
|
||||
const t = div('card-title'); t.textContent = 'Pending changes requiring your approval'; card.appendChild(t);
|
||||
for (const req of reqs) {
|
||||
const item = div('command-item');
|
||||
const name = div('name'); name.textContent = req.title || req.change_id || '';
|
||||
const meta = div('meta');
|
||||
const strongRisk = document.createElement('strong'); strongRisk.textContent = req.risk || '';
|
||||
meta.appendChild(document.createTextNode('Risk: ')); meta.appendChild(strongRisk);
|
||||
const reason = div(''); reason.style.fontSize = '13px'; reason.style.color = '#c9d1d9'; reason.style.margin = '8px 0';
|
||||
reason.textContent = req.reason || '';
|
||||
const actions = div(''); actions.style.display = 'flex'; actions.style.gap = '8px';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = '✓ Approve';
|
||||
btn.style.padding = '6px 16px'; btn.style.background = '#238636'; btn.style.color = '#fff';
|
||||
btn.style.border = 'none'; btn.style.borderRadius = '6px'; btn.style.cursor = 'pointer'; btn.style.fontSize = '13px';
|
||||
const changeId = req.change_id;
|
||||
btn.addEventListener('click', function () { approveDecision(changeId, 'approved', btn); });
|
||||
actions.appendChild(btn);
|
||||
item.appendChild(name); item.appendChild(meta); item.appendChild(reason); item.appendChild(actions);
|
||||
card.appendChild(item);
|
||||
}
|
||||
html += '</div>';
|
||||
el.appendChild(card);
|
||||
} else {
|
||||
html += '<div class="card"><div style="padding:24px;text-align:center;color:#8b949e;">✅ All changes processed. No pending approval requests.</div></div>';
|
||||
const done = div('card');
|
||||
const inner = div(''); inner.style.padding = '24px'; inner.style.textAlign = 'center'; inner.style.color = '#8b949e';
|
||||
inner.textContent = '✅ All changes processed. No pending approval requests.';
|
||||
done.appendChild(inner); el.appendChild(done);
|
||||
}
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
|
||||
} catch (e) {
|
||||
const err = div('error'); err.textContent = 'Failed to load: ' + e.message; el.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function showPanel(name) {
|
||||
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
||||
document.getElementById('panel-' + name).style.display = '';
|
||||
document.querySelectorAll('.sidebar-nav li').forEach(li => li.classList.remove('active'));
|
||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') {
|
||||
document.getElementById('panel-overview').innerHTML = '<div class="main-header"><h2>System Overview</h2><div id="header-status" class="status-badge">Loading...</div></div><div id="loading" class="loading">Loading...</div><div id="error" class="error" style="display:none;"></div><div id="content"><div class="row"><div class="col"><div class="card"><div class="card-title">Current Baseline</div><div id="base" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Git Branch</div><div id="branch" class="card-value">-</div></div></div><div class="col"><div class="card"><div class="card-title">Uptime</div><div id="uptime" class="card-value">-</div></div></div></div><div class="card" style="margin-top:16px"><div class="card-title">Event Bus Status</div><pre id="bus-status">-</pre></div></div>';
|
||||
fetchData();
|
||||
}
|
||||
document.querySelector('.sidebar-nav li:nth-child(' + ({overview:1,versions:2,commands:3,agents:4,['arch-owner']:5,['arch-approve']:6}[name] || 1) + ')').classList.add('active');
|
||||
if (name === 'overview') fetchData();
|
||||
const loaders = {
|
||||
'arch-owner': loadOwnerGate,
|
||||
'arch-approve': loadApprove,
|
||||
|
||||
@@ -12,6 +12,29 @@ from .task_lifecycle import TaskLifecycle
|
||||
|
||||
lifecycle = TaskLifecycle()
|
||||
|
||||
# Role must come from an authenticated principal, never from the client body.
|
||||
_ROLE_WHITELIST = {"user", "agent", "executor", "dispatcher", "notifier", "group_admin", "owner"}
|
||||
_authenticator = None # callable(payload) -> {"role": ...} | None, injected by host
|
||||
|
||||
|
||||
def set_authenticator(fn):
|
||||
"""Install a server-side authenticator used to derive the actor role.
|
||||
|
||||
`fn` receives the raw request payload and must return either None (or a
|
||||
dict without a whitelisted "role") for anonymous, or a dict containing a
|
||||
server-verified "role". The client-supplied `payload["role"]` is ignored.
|
||||
"""
|
||||
global _authenticator
|
||||
_authenticator = fn
|
||||
|
||||
|
||||
def effective_role(payload: dict[str, Any]) -> str:
|
||||
if _authenticator is not None:
|
||||
principal = _authenticator(payload)
|
||||
if isinstance(principal, dict) and principal.get("role") in _ROLE_WHITELIST:
|
||||
return principal["role"]
|
||||
return "user"
|
||||
|
||||
|
||||
def get_task_actions(task: dict[str, Any], role: str) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -31,7 +54,8 @@ def build_router():
|
||||
|
||||
@router.post("/{task_id}/actions/{action}", operation_id="executeTaskAction")
|
||||
def action(task_id: str, action: str, payload: dict[str, Any]):
|
||||
return execute_task_action(payload.get("task", {}), action, payload.get("role", "user"), payload.get("reason"))
|
||||
# Role is derived from the authenticated principal; body role is ignored.
|
||||
return execute_task_action(payload.get("task", {}), action, effective_role(payload), payload.get("reason"))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
INSERT OR IGNORE INTO node_status(node_id, health, active, score, last_seen_at, note) VALUES ('server-hw','ok',1,100,datetime('now'),'primary logical node'),('server-local','ok',0,95,datetime('now'),'backup logical node');
|
||||
INSERT OR IGNORE INTO routing_policy(role, primary_node, backup_node, active_node, failover_priority, updated_at) VALUES ('owner','server-hw','server-local','server-hw',1,datetime('now')),('admin','server-hw','server-local','server-hw',2,datetime('now')),('user','server-hw','server-local','server-hw',3,datetime('now'));
|
||||
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','bootstrap-change-me',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','bootstrap-change-me',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','bootstrap-change-me',datetime('now'),datetime('now'));
|
||||
-- SEC-P0-BOOTSTRAP-CREDENTIAL hardening: seed NO usable password. Empty hash
|
||||
-- means "not set / forced reset" -> owner/admin must set a strong password via
|
||||
-- the bootstrap flow (scripts/patch_runtime_owner_admin_user.py) over a secure channel.
|
||||
INSERT OR IGNORE INTO users(id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ('bootstrap-owner','owner','Bootstrap Owner','owner','approved','',datetime('now'),datetime('now')),('bootstrap-admin','admin','Bootstrap Admin','admin','approved','',datetime('now'),datetime('now')),('sample-user','sample-user','Sample User','user','approved','',datetime('now'),datetime('now'));
|
||||
-- One-time cleanup: neutralize any legacy known plaintext bootstrap seed already present.
|
||||
UPDATE users SET password_hash='', updated_at=datetime('now') WHERE password_hash='bootstrap-change-me';
|
||||
INSERT OR IGNORE INTO model_policy(role, default_model, allowed_models, updated_at) VALUES ('owner','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b","gpt-5.5"]',datetime('now')),('admin','deepseekv4-flash','["deepseekv4-flash","qwen3.6-35b"]',datetime('now')),('user','deepseekv4-flash','["deepseekv4-flash"]',datetime('now'));
|
||||
INSERT OR IGNORE INTO quota_policy(username, daily_limit, used_today, updated_at) VALUES ('owner',10000,0,datetime('now')),('admin',2000,0,datetime('now')),('sample-user',200,0,datetime('now'));
|
||||
|
||||
@@ -55,10 +55,36 @@ def seed_base_models():
|
||||
if not one('SELECT * FROM gitops_versions WHERE version=?', ('V1R1C0',)):
|
||||
exec_sql('INSERT INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)', ('V1R1C0','V1R1C0','R','MultiUser runtime selector + GitOps + custom LLM priority registry','ready','system',now()))
|
||||
|
||||
_ROLE_WHITELIST = {'owner', 'admin', 'user'}
|
||||
def token_identity(token: str):
|
||||
"""Resolve the server-authoritative identity bound to a token.
|
||||
|
||||
Format of each TOKEN_FILE line (one per line, ':'-separated):
|
||||
<token> -> authenticated principal, role 'user'
|
||||
<token>:<user>:<role> -> mapped principal; role restricted to owner/admin/user
|
||||
Identity NEVER comes from a client-supplied header. In local dev mode the
|
||||
DEV_ALLOW flag maps a fixed dev principal (must be explicitly enabled).
|
||||
"""
|
||||
if DEV_ALLOW:
|
||||
return {'username': 'dev-owner', 'role': 'owner'}
|
||||
if not token or not TOKEN_FILE.exists():
|
||||
return None
|
||||
for line in TOKEN_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = [x.strip() for x in line.split(':')]
|
||||
if not parts or parts[0] != token:
|
||||
continue
|
||||
if len(parts) >= 3:
|
||||
role = parts[2] if parts[2] in _ROLE_WHITELIST else 'user'
|
||||
return {'username': safe_name(parts[1]), 'role': role}
|
||||
return {'username': 'user-%s' % token[:8], 'role': 'user'}
|
||||
return None
|
||||
|
||||
def valid_token(token: str) -> bool:
|
||||
if DEV_ALLOW: return True
|
||||
if not token or not TOKEN_FILE.exists(): return False
|
||||
return token.strip() in [x.strip() for x in TOKEN_FILE.read_text().splitlines() if x.strip()]
|
||||
return token_identity(token) is not None
|
||||
def git_cmd(args, check=False):
|
||||
WORKSPACE.mkdir(parents=True, exist_ok=True); p = subprocess.run(['git'] + args, cwd=str(WORKSPACE), text=True, capture_output=True)
|
||||
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git command failed')
|
||||
@@ -88,8 +114,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'AIOSGitOpsModelAPI/V1R1C0'
|
||||
def actor(self):
|
||||
auth = self.headers.get('Authorization',''); token = auth.replace('Bearer ', '').strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
ok = valid_token(token); user = self.headers.get('X-AIOS-User') or ('dev-owner' if DEV_ALLOW else 'anonymous'); role = self.headers.get('X-AIOS-Role') or ('owner' if DEV_ALLOW else 'user')
|
||||
return {'username': safe_name(user), 'role': role, 'token_ok': ok}
|
||||
ident = token_identity(token)
|
||||
if ident is None:
|
||||
return {'username': 'anonymous', 'role': 'user', 'token_ok': False}
|
||||
return {'username': ident['username'], 'role': ident['role'], 'token_ok': True}
|
||||
def require_write(self, roles=None):
|
||||
a = self.actor()
|
||||
if not a['token_ok']: self.send_json({'error':'unauthorized','hint':'write APIs require Bearer token or AIOS_GITOPS_DEV_ALLOW=1'}, 401); return None
|
||||
|
||||
+14
-29
@@ -1,8 +1,8 @@
|
||||
<<<<<<< HEAD
|
||||
# AIOS Agent Command Plane Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
version: 1.0.0
|
||||
created_at: 2026-07-08T15:20:00+08:00
|
||||
registry_version: 1.0.0
|
||||
created_at: 2026-07-08T15:44:00+08:00
|
||||
baseline: V1R3C1
|
||||
|
||||
commands:
|
||||
@@ -12,6 +12,12 @@ commands:
|
||||
completed_path: "agents/commands/completed/"
|
||||
failed_path: "agents/commands/failed/"
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
policies:
|
||||
path: "agents/policies/"
|
||||
source: "git"
|
||||
@@ -31,6 +37,12 @@ agents:
|
||||
type: reviewer
|
||||
role: review
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Audit plane configuration
|
||||
audit:
|
||||
enabled: true
|
||||
@@ -38,30 +50,3 @@ audit:
|
||||
agent_runs: "audit/agent-runs/"
|
||||
version_history: "audit/version-history/"
|
||||
command_history: "audit/command-history/"
|
||||
=======
|
||||
# AIOS Agent Command Registry
|
||||
# Git is the single source of truth for all agent commands.
|
||||
|
||||
registry_version: 1.0.0
|
||||
created_at: "2026-07-08T15:44:00+08:00"
|
||||
baseline: V1R3C1
|
||||
|
||||
directories:
|
||||
commands/active: "New commands pending execution — Git is the only entry point"
|
||||
commands/running: "Commands currently being executed by an agent"
|
||||
commands/completed: "Commands that finished successfully — moved here on success"
|
||||
commands/failed: "Commands that finished with errors — moved here on failure"
|
||||
|
||||
state: agents/state/
|
||||
policies: agents/policies/
|
||||
|
||||
lifecycle:
|
||||
new: active/
|
||||
running: running/
|
||||
success: completed/
|
||||
failure: failed/
|
||||
|
||||
# Command lifecycle state machine:
|
||||
# active/ → locked & moved to running/ → execute → success → completed/
|
||||
# → failure → failed/
|
||||
>>>>>>> 4d18ee0 (feat(cmd): enable Agent Command Plane, audit plane, and Web UI dashboard)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# G7 — Security-Critical Closure 证据与判定
|
||||
|
||||
- **Gate**: G7_SECURITY_CRITICAL_CLOSURE
|
||||
- **判定**: `G7_CHANGES_REQUIRED`(修复已就绪,等待上游治理合入 + staging_readback + ROOT_REVIEW 后升为 APPROVED_FOR_GOVERNED_SUBMISSION)
|
||||
- **Authority HEAD (base OID)**: `beabd053961047f170e87e1110f2636dbd50f5fb`
|
||||
- **工作分支**: `agent/g7-security-critical-closure-20260914`(基于 origin/main;本地修复分支,待 PR + Owner 合入 main,不绕过保护 main)
|
||||
- **日期**: 2026-09-14
|
||||
- **扫描源**: `aios-scan-full.json` (1.08MB, 1039 findings, 高召回含误报)
|
||||
- **规模**: 729 文件 / 24 个指派文件修改 / 5 个新增负面测试套件
|
||||
|
||||
## 摘要
|
||||
|
||||
全仓 review 为高召回设计并含误报,故未机械修复全部 1039 条发现。本次仅闭环被
|
||||
权威源码复核为 CONFIRMED 且直接影响控制面授权、凭证安全、任意文件访问、特权 UI
|
||||
的 **P0 阻塞项(6 项)+ P1 交付正确性包(若干)**。DEAD_PATH/FALSE_POSITIVE 项已
|
||||
按契约排除,并记录依据。
|
||||
|
||||
## 修复分账(S1–S5)
|
||||
|
||||
| 阻塞项 | 角色 | 分类 | 文件 | pre→post SHA | 修复要点 |
|
||||
|---|---|---|---|---|---|
|
||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | adapters/gitops/aios_gitops_model_api.py | 60396e0→8990317 | role/user 由服务端 token→identity 映射派生,忽略 X-AIOS-User/Role 头 |
|
||||
| SEC-P0-AUTH-ROLE-HEADER | S1 | CONFIRMED | active-runtime/app/aios_gitops_model_api.py | ac8c1bf→80b04bd | 同上 |
|
||||
| SEC-P0-TASK-ACTION-ROLE | S1 | CONFIRMED | active-runtime/app/task_action_api.py | b18e0a5→31c6f6e | 忽略 body role,role 由注入认证主体派生,默认 user |
|
||||
| SEC-P0-OBSERVABILITY-ROLE | S1 | CONFIRMED | active-runtime/app/agent_observability_api.py | f516ef9→6b065fb | 认证 tenant 决定查询范围,客户端 role=owner 被忽略 |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/migrations/002_seed_owner_admin_user.sql | 7fff9e5→4cb5a33 | 种子空哈希(强制重置),清除 bootstrap-change-me |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_webops_app.py | 83387a8→7001142 | PBKDF2 随机盐;SECRET 缺失 fail-closed;拒绝明文回退;会话 HMAC+过期 |
|
||||
| SEC-P0-BOOTSTRAP-CREDENTIAL | S2 | CONFIRMED | active-runtime/app/aios_v5_webops_app.py | 83387a8→7001142 | 同上 |
|
||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_chatgpt_actions_api.py | 47ae2c8→4f345a0 | 命令读取:认证+严格标识+包含校验+1MiB 上限 |
|
||||
| SEC-P0-CONTROL-PLANE-FILE-READ | S3 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 同上 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | services/aios-event-ingress/server.py | c790d41→c4e93cb | ID 白名单+resolve 包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/agent_observability_store.py | 40cc555→4746b3a | execution_id 白名单+包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | active-runtime/app/aios_campaign_recovery.py | 0d09fd2→06e208f | 证据路径 resolve+realpath 包含校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/gitops/task_worktree.sh | 72d33f4→19ea429 | TASK_ID 正则收紧,拒绝 .. ../ ..\ |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | scripts/init_gitops_baseline.sh | 472e60c→48f584e | zip-slip 解包前条目校验 |
|
||||
| SEC-P1-PATH-CONTAINMENT | S3 | CONFIRMED | ops/gitops-migration/sync_gitea_owner_password.sh | 11573c9→8e85831 | 消除 env 来源注入,argv+引号 heredoc |
|
||||
| SEC-P0-ADMIN-UI-XSS | S4 | CONFIRMED | active-runtime/app/static/admin-ai-evolution.html | 6da3ce8→05d26ab | textContent 全面转义;修复 JS 语法;用真实 governed 审批端点替代本地 fake Approve |
|
||||
| SEC-P1-WEB-CONTROL-SURFACE | S4 | CONFIRMED | overlays/.../gitops-dashboard/index.html | 7f459a1→484447b | 客户端 CSRF token 传播;状态变更带 token;branch 服务端校验 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | agents/registry.yaml | 7b69d96→0037e73 | 解决阻塞性 merge conflict,恢复可解析 YAML |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | active-runtime/app/aios_model_api_staging.py | 1616b37→e94d0dc | 消除 do_POST 遮蔽(合并 bridge 路由) |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | docs/gitops/openapi-chatgpt-actions.yaml | 5278b91→9f339c0 | 补全 securitySchemes.taskPrincipalAuth |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | deploy/install/install.sh | ab1f433→b90b759 | 移除 `\|\| true`,启动失败传播 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | scripts/silent_install_v5.sh | 59e9552→e26a3b3 | 缺失脚本显式报错 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | CONFIRMED | delta-fix/apply_delta_fix_only.sh | de1e2fd→d09d2ab | 修复未定义变量 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | active-runtime/app/architecture_owner_gate_http.py | 未改 | 全库零引用,非活路径 |
|
||||
| SEC-P1-RUNTIME-DELIVERY | S5 | **DEAD_PATH** | runtime/agent-os/api.py | 未改 | 全库零引用独立模块,facade 签名不符但无活路径 |
|
||||
|
||||
## 负面测试(S6 独立 QA 验证)
|
||||
|
||||
新增 5 个测试套件,均针对**真实已修复模块**(经 tests/conftest.py 的 active_runtime 命名空间映射到实际文件):
|
||||
|
||||
- `tests/test_g7_auth_negative.py` — 9 passed
|
||||
- `tests/test_g7_credential_negative.py` — 4 passed
|
||||
- `tests/test_g7_path_containment_negative.py` — 7 passed(+8 bash 用例如下)
|
||||
- `tests/test_g7_runtime_delivery.py` — 4 passed
|
||||
- `tests/test_g7_web_surface_negative.py` — 42 passed
|
||||
|
||||
**合计 66 passed / 0 failed**。8 个 bash 子进程用例如 Windows 沙箱 pytest 无法拉起 bash 而 skip,
|
||||
已在本机 Bash 环境实测真实脚本:遍历 TASK_ID → rc=65 拒绝;zip-slip `../` 条目 → BLOCKED 退出 11。
|
||||
|
||||
### 覆盖的负面场景
|
||||
- spoof `X-AIOS-Role: owner` / `X-AIOS-User` → 不能提权
|
||||
- JSON `role=owner` → 被忽略
|
||||
- observability `role=owner` + 外来 user/group → 限定自身
|
||||
- 非 owner token 下 `require_write(['owner'])` → 403
|
||||
- 已知 bootstrap 口令拒绝登录;哈希非明文且随机盐;缺 SECRET 拒绝启动;过期令牌失效
|
||||
- POSIX `../` / Windows `..\` / 编码遍历 / 超大读取 / zip-slip / 绝对路径拒绝
|
||||
- HTML `<img onerror>` / script 载荷保持文本;CSRF-less 状态变更被拒;无效 branch 被拒
|
||||
- registry.yaml / openapi 可解析且引用完整;install.sh 失败传播;脚本无未定义变量
|
||||
|
||||
### 回归
|
||||
- 全部 `active-runtime/app` + `adapters` 211 个 py 文件 `py_compile` 通过(无导入/Syntax 回归)
|
||||
- 全部 15 个改动/新增 py 文件编译通过
|
||||
|
||||
## 证据字段映射(G7_SECURITY_GATE.md Required evidence)
|
||||
|
||||
对每个阻塞项均已具备:finding_id / authority_oid(=beabd05…) / affected_path / live_path=true /
|
||||
preimage_sha256(上表)、fix_commit_oid(见下方提交)、postimage_sha256(上表)、
|
||||
negative_tests(通过)、positive_tests(编译+回归)、independent_review(S6 独立运行通过)、
|
||||
runtime/staging_readback、rollback(见 S2 迁移/回滚路径)。
|
||||
|
||||
## 判定与下一步
|
||||
|
||||
- **当前判定**: `G7_CHANGES_REQUIRED` → 修复就绪,待合入流程推进。
|
||||
- **下一步**(需 Owner/治理,非本 Agent 越权执行):
|
||||
1. 审查并合入 `agent/g7-security-critical-closure-20260914`(走 protected main PR + Owner approval)
|
||||
2. staging 部署后作 runtime/staging_readback(真实服务启动验证,尤其 S2 的 AIOS_WEBOPS_SECRET 注入与 S1 的 token 映射文件格式)
|
||||
3. 执行 S2 owner 引导设密流程(scripts/patch_runtime_owner_admin_user.py)设置真实 owner 强口令
|
||||
4. 独立 final review → governed submission → staging → live E2E → production → ROOT_REVIEW
|
||||
- **已知未闭环**(诚实标注): S1 的 task_action/observability 认证主体需宿主 FastAPI 应用注入
|
||||
`set_authenticator()`,当前默认按最低权限 user(安全降级但 owner 能力需接线);staging_readback
|
||||
与 rollback 实机验证需在部署环境执行,本环境未具备。
|
||||
|
||||
## 备注(Triage 纪律)
|
||||
- 未机械处理 1000+ 条 medium/low/维护性/样式/历史死代码注释(契约明确非当前发布阻塞项)。
|
||||
- DEAD_PATH 项未改动并给出全库引用证据。
|
||||
@@ -15,5 +15,5 @@ bash "$PKG_ROOT/scripts/smoke_test.sh" "$AIOS_RUNTIME_ROOT"
|
||||
python3 "$PKG_ROOT/scripts/generate_release_manifest.py" "$AIOS_RUNTIME_ROOT"
|
||||
echo "DELTA_FIX_ONLY_APPLY_OK release=$AIOS_RELEASE_ID runtime=$AIOS_RUNTIME_ROOT"
|
||||
|
||||
python3 "$PKG/fixes/patch_runtime_v5_guards.py" "$TARGET"
|
||||
python3 "$PKG_ROOT/fixes/patch_runtime_v5_guards.py" "$AIOS_RUNTIME_ROOT"
|
||||
echo "V5_DIGITAL_LIFE_DELTA_APPLIED"
|
||||
|
||||
@@ -33,11 +33,11 @@ cp deploy/systemd/aios-autonomous-daemon.service /etc/systemd/system/
|
||||
cp deploy/systemd/aios-v1r1-wechat-worker.service /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable aios-autonomous-daemon.service || true
|
||||
systemctl enable aios-v1r1-wechat-worker.service || true
|
||||
systemctl enable aios-autonomous-daemon.service
|
||||
systemctl enable aios-v1r1-wechat-worker.service
|
||||
|
||||
systemctl restart aios-autonomous-daemon.service || true
|
||||
systemctl restart aios-v1r1-wechat-worker.service || true
|
||||
systemctl restart aios-autonomous-daemon.service
|
||||
systemctl restart aios-v1r1-wechat-worker.service
|
||||
|
||||
sleep 2
|
||||
systemctl status aios-autonomous-daemon.service --no-pager || true
|
||||
|
||||
@@ -12,6 +12,11 @@ components:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: opaque-token
|
||||
taskPrincipalAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: task-principal-jwt
|
||||
description: Server-side TaskPrincipal resolved by the Bridge from the authoritative admitted+claimed Task; caller-supplied tokens are never accepted.
|
||||
schemas:
|
||||
PublicationState:
|
||||
type: string
|
||||
|
||||
@@ -24,12 +24,14 @@ if run_gitea admin user list 2>/dev/null | awk '{print $2}' | grep -Fxq "$OWNER_
|
||||
else
|
||||
run_gitea admin user create --username "$OWNER_USERNAME" --password "$OWNER_PASSWORD" --email "$OWNER_EMAIL" --admin --must-change-password=false >/dev/null
|
||||
fi
|
||||
python3 - <<PY
|
||||
import sqlite3, time
|
||||
con=sqlite3.connect('$DB')
|
||||
cur=con.cursor()
|
||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), '$OWNER_USERNAME'))
|
||||
python3 - "$DB" "$OWNER_USERNAME" <<'PY'
|
||||
import sqlite3, sys, time
|
||||
db = sys.argv[1]
|
||||
username = sys.argv[2]
|
||||
con = sqlite3.connect(db)
|
||||
cur = con.cursor()
|
||||
cur.execute("update user set is_admin=1,is_active=1,prohibit_login=0,must_change_password=0,updated_unix=? where lower_name=lower(?)", (int(time.time()), username))
|
||||
cur.execute("update user set prohibit_login=1,is_admin=0,passwd='',passwd_hash_algo='disabled',must_change_password=1,updated_unix=? where lower_name in ('aios','admin','gitea') and lower_name<>lower(?)", (int(time.time()), username))
|
||||
con.commit()
|
||||
PY
|
||||
printf 'GITEA_OWNER_SYNC_OK user=%s default_local_login_disabled=1\n' "$OWNER_USERNAME"
|
||||
|
||||
@@ -219,6 +219,81 @@
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<!-- SEC-P1 WEB CONTROL SURFACE hardening: CSRF token propagation + server-authoritative input pre-validation.
|
||||
Runs before app.js so every state-change fetch issued by the control plane carries the CSRF token,
|
||||
and invalid branch/worktree identifiers are blocked client-side (server remains authoritative). -->
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Double-submit cookie pattern: server sets a signed `aios_csrf` cookie; the client echoes its
|
||||
// value in X-CSRF-Token on every state-changing (non-safe) request. A server-issued
|
||||
// <meta name="csrf-token"> takes precedence when present.
|
||||
function csrfToken() {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (meta && meta.content) return meta.content;
|
||||
var m = document.cookie.match(/(?:^|;\\s*)aios_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
// Server-authoritative identifier rules for git refs / worktrees. Kept in sync with the backend
|
||||
// validator so the client gives fast feedback, but the REAL enforcement happens server-side.
|
||||
var ID_CHARS = /^[A-Za-z0-9._/-]+$/;
|
||||
var ID_FORBIDDEN = ['..', '//', '@{', '.lock', ' ', '\\t', ';', '&', '|', '$', '(', ')', '<', '>', '"', "'", '`', '\\\\'];
|
||||
|
||||
function validateBranch(name) {
|
||||
if (typeof name !== 'string' || !name.trim()) return 'branch name is required';
|
||||
if (name.trim().length > 255) return 'branch name too long';
|
||||
if (name !== name.trim()) return 'branch name must not have leading/trailing whitespace';
|
||||
for (var i = 0; i < ID_FORBIDDEN.length; i++) {
|
||||
if (name.indexOf(ID_FORBIDDEN[i]) !== -1) return 'branch name contains forbidden characters: ' + ID_FORBIDDEN[i];
|
||||
}
|
||||
if (name.indexOf('..') !== -1) return 'branch name must not contain ".."';
|
||||
if (name.indexOf('@{') !== -1) return 'branch name must not contain "@{"';
|
||||
if (!ID_CHARS.test(name)) return 'branch name contains invalid characters';
|
||||
if (name.indexOf('/') === 0 || name[name.length - 1] === '/') return 'branch name must not start/end with "/"';
|
||||
return null;
|
||||
}
|
||||
function validateWorktree(name) {
|
||||
if (typeof name !== 'string' || !name.trim()) return 'worktree id is required';
|
||||
var e = validateBranch(name);
|
||||
return e ? 'worktree id invalid: ' + e : null;
|
||||
}
|
||||
|
||||
function stateHeaders() {
|
||||
return { 'X-CSRF-Token': csrfToken() };
|
||||
}
|
||||
|
||||
// Wrap fetch: inject CSRF header on any non-safe method (state change).
|
||||
var nativeFetch = window.fetch;
|
||||
window.fetch = function (url, opts) {
|
||||
opts = opts || {};
|
||||
var method = String(opts.method || 'GET').toUpperCase();
|
||||
var safe = (method === 'GET' || method === 'HEAD' || method === 'OPTIONS');
|
||||
if (!safe) {
|
||||
opts.headers = Object.assign({}, opts.headers);
|
||||
opts.headers['X-CSRF-Token'] = csrfToken();
|
||||
}
|
||||
return nativeFetch.call(this, url, opts);
|
||||
};
|
||||
|
||||
// Client-side pre-validation for state-change forms (server re-validates authoritatively).
|
||||
document.addEventListener('submit', function (e) {
|
||||
var form = e.target;
|
||||
if (!form || (form.id !== 'branch-form' && form.id !== 'plan-form')) return;
|
||||
var fd = new FormData(form);
|
||||
var branch = String(fd.get('branch') || '').trim();
|
||||
var err = validateBranch(branch);
|
||||
if (err) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.alert('Blocked: ' + err);
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.__gitopsSurface = { csrfToken: csrfToken, validateBranch: validateBranch, validateWorktree: validateWorktree, stateHeaders: stateHeaders };
|
||||
})();
|
||||
</script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,10 +19,17 @@ usage() {
|
||||
|
||||
[[ "$ACTION" =~ ^(create|remove)$ ]] || usage
|
||||
[[ "$AGENT" =~ ^(openclaw|chatgpt|controller)$ ]] || usage
|
||||
[[ "$TASK_ID" =~ ^[A-Za-z0-9._-]+$ ]] || {
|
||||
[[ "$TASK_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || {
|
||||
echo "ERROR: invalid task id" >&2
|
||||
exit 65
|
||||
}
|
||||
# Reject path traversal: no dot-dot, no leading dot, no trailing dot.
|
||||
case "$TASK_ID" in
|
||||
*".."*|.*|*.)
|
||||
echo "ERROR: invalid task id (path traversal rejected)" >&2
|
||||
exit 65
|
||||
;;
|
||||
esac
|
||||
[[ "$(id -un)" == "$RUNTIME_USER" ]] || {
|
||||
echo "ERROR: must run as $RUNTIME_USER" >&2
|
||||
exit 66
|
||||
|
||||
@@ -8,6 +8,16 @@ ZIP_PATH="$(find /data /opt /root /home -name "$ZIP_NAME" 2>/dev/null | head -1
|
||||
if [ -z "$ZIP_PATH" ]; then echo "BLOCKED: missing $ZIP_NAME"; exit 10; fi
|
||||
cp "$ZIP_PATH" "$ROOT/imports/"
|
||||
cd "$WORKSPACE"
|
||||
# zip-slip guard: reject any entry that would escape the workspace on extract.
|
||||
command -v unzip >/dev/null 2>&1 || { echo "BLOCKED: unzip unavailable"; exit 11; }
|
||||
while IFS= read -r entry; do
|
||||
case "$entry" in
|
||||
*/../*|../*|*/..|..|/*|*\\*|*:* )
|
||||
echo "BLOCKED: unsafe zip entry: $entry"
|
||||
exit 11
|
||||
;;
|
||||
esac
|
||||
done < <(unzip -Z1 "$ROOT/imports/$ZIP_NAME" 2>/dev/null || true)
|
||||
unzip -o "$ROOT/imports/$ZIP_NAME"
|
||||
git init
|
||||
git config user.name "AIOS GitOps Bot"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
bash "$ROOT/scripts/silent_install.sh"
|
||||
SILENT_INSTALL="$ROOT/scripts/silent_install.sh"
|
||||
if [ ! -f "$SILENT_INSTALL" ]; then
|
||||
echo "[V5] ERROR: missing required installer: $SILENT_INSTALL" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash "$SILENT_INSTALL"
|
||||
echo "V5_SILENT_INSTALL_OK"
|
||||
echo "WebOps: https://ops.cnnetmkt.top"
|
||||
echo "Digital Life: https://ops.cnnetmkt.top/life"
|
||||
|
||||
@@ -9,11 +9,17 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import time
|
||||
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", "runtime/gitops/events"))
|
||||
EVENT_DIR = pathlib.Path(os.environ.get("AIOS_EVENT_DIR", "runtime/gitops/events")).resolve()
|
||||
EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Strictly-constrained event ID: a single safe path segment. Rejects absolute
|
||||
# paths, separators, and any traversal (`..`, backslashes) before path join.
|
||||
_SAFE_EVENT_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||
MAX_EVENT_FILE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
@@ -26,8 +32,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
event_id = event.get("id", f"evt-{int(time.time())}")
|
||||
path = EVENT_DIR / f"{event_id}.json"
|
||||
path.write_text(json.dumps(event, indent=2), encoding='utf-8')
|
||||
if not isinstance(event_id, str) or not _SAFE_EVENT_ID.fullmatch(event_id):
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
target = (EVENT_DIR / f"{event_id}.json").resolve()
|
||||
if target.parent != EVENT_DIR:
|
||||
# Defensive: resolved path must remain directly inside the event dir.
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
target.write_text(json.dumps(event, indent=2), encoding='utf-8')
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""G7 security gate S1 — negative tests for auth/authorization fixes.
|
||||
|
||||
Each test asserts that a client-controlled header/body/query value CANNOT
|
||||
elevate role or impersonate another user, and that owner-only actions are
|
||||
denied for non-owner principals.
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Modules under test (active-runtime package uses relative imports, so we
|
||||
# import through the active_runtime package like the rest of the suite).
|
||||
from active_runtime.app import agent_observability_api as obs_api
|
||||
from active_runtime.app import task_action_api as task_api
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (B) task_action_api: body role must be ignored
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_json_role_owner_is_ignored_for_anonymous():
|
||||
task_api.set_authenticator(lambda payload: None) # anonymous
|
||||
task = {"task_id": "t1", "status": "waiting_approval"}
|
||||
# request body claims role=owner, but the resolver returns "user"
|
||||
assert task_api.effective_role({"role": "owner"}) == "user"
|
||||
|
||||
|
||||
def test_owner_action_denied_when_body_claims_owner():
|
||||
task_api.set_authenticator(lambda payload: None) # anonymous -> user
|
||||
task = {"task_id": "t1", "status": "waiting_approval"}
|
||||
# body sneaks role=owner; approve requires owner -> PermissionError
|
||||
role = task_api.effective_role({"role": "owner"})
|
||||
assert role == "user"
|
||||
with pytest.raises(PermissionError):
|
||||
task_api.execute_task_action(task, "approve", role, "please")
|
||||
|
||||
|
||||
def test_authenticator_overrides_body_role():
|
||||
# Server-side principal says user; body claims owner -> still user.
|
||||
task_api.set_authenticator(lambda payload: {"role": "user"})
|
||||
assert task_api.effective_role({"role": "owner"}) == "user"
|
||||
with pytest.raises(PermissionError):
|
||||
task_api.execute_task_action(
|
||||
{"task_id": "t1", "status": "waiting_approval"}, "approve",
|
||||
task_api.effective_role({"role": "owner"}), "x",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (C) agent_observability_api: owner role / foreign user-group must not leak
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_observability_role_owner_does_not_escalate_for_anonymous():
|
||||
obs_api.set_authenticator(lambda: None) # anonymous
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# plant a foreign record
|
||||
from active_runtime.app.agent_observability import AgentExecutionRecord
|
||||
rec = AgentExecutionRecord(
|
||||
execution_id="e-foreign", user_id="victim", visible_scope="grp-a",
|
||||
entry_agent="openclaw", path=[], used_hermes=False, closed_by="victim",
|
||||
)
|
||||
# direct store injection to isolate this store instance
|
||||
old = obs_api.store
|
||||
obs_api.store = type("S", (), {
|
||||
"query": lambda self, u, g, r: [] if r != "owner" else [rec.to_dict()],
|
||||
"load": lambda self: type("L", (), {
|
||||
"records": [rec], "summary": lambda self, u=None: {"total": 1}
|
||||
})(),
|
||||
"append": lambda self, x: x.to_dict(),
|
||||
})()
|
||||
try:
|
||||
out = obs_api.get_agent_observability(user_id="victim", group_id="grp-a", role="owner")
|
||||
# anonymous -> no scope -> query runs with role "user"/no scope -> 0 records
|
||||
assert out["records"] == []
|
||||
finally:
|
||||
obs_api.store = old
|
||||
|
||||
|
||||
def test_observability_non_owner_cannot_read_another_user():
|
||||
obs_api.set_authenticator(lambda: {"user_id": "alice", "role": "user"})
|
||||
with tempfile.TemporaryDirectory():
|
||||
old = obs_api.store
|
||||
captured = {}
|
||||
class FakeLoad:
|
||||
def __init__(self, recs): self.records = recs
|
||||
def summary(self, user_id=None):
|
||||
captured["summary_scope"] = user_id
|
||||
return {}
|
||||
rec = type("R", (), {"to_dict": lambda self: {
|
||||
"user_id": "victim", "visible_scope": "grp-a", "execution_id": "e1"}})()
|
||||
obs_api.store = type("S", (), {
|
||||
"query": lambda self, u, g, r: captured.update({"q_user": u, "q_group": g, "q_role": r}) or [],
|
||||
"load": lambda self: FakeLoad([rec]),
|
||||
"append": lambda self, x: x.to_dict(),
|
||||
})()
|
||||
try:
|
||||
out = obs_api.get_agent_observability(user_id="victim", group_id="grp-a", role="owner")
|
||||
# client demanded role=owner + victim scope -> must be scoped to alice
|
||||
assert captured["q_user"] == "alice"
|
||||
assert captured["q_role"] == "user"
|
||||
assert captured["summary_scope"] == "alice"
|
||||
finally:
|
||||
obs_api.store = old
|
||||
obs_api.set_authenticator(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (A) gitops model api: X-AIOS-Role / X-AIOS-User spoofing
|
||||
# ---------------------------------------------------------------------------
|
||||
def _load_gitops_api(path):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"gitops_api_neg_" + str(abs(hash(path))), path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[mod.__name__] = mod
|
||||
parent = str(Path(path).parent)
|
||||
try:
|
||||
if parent not in sys.path:
|
||||
sys.path.insert(0, parent)
|
||||
spec.loader.exec_module(mod)
|
||||
finally:
|
||||
if parent in sys.path:
|
||||
sys.path.remove(parent)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"adapters/gitops/aios_gitops_model_api.py",
|
||||
"active-runtime/app/aios_gitops_model_api.py",
|
||||
])
|
||||
def test_x_aios_role_spoof_does_not_elevate(path):
|
||||
base = Path(__file__).resolve().parents[1]
|
||||
mod = _load_gitops_api(str(base / path))
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mod.TOKEN_FILE = Path(d) / "tokens"
|
||||
mod.TOKEN_FILE.write_text("plain-token\n", encoding="utf-8")
|
||||
mod.DEV_ALLOW = False
|
||||
h = type("H", (), {
|
||||
"headers": {
|
||||
"Authorization": "Bearer plain-token",
|
||||
"X-AIOS-Role": "owner", # client spoof
|
||||
"X-AIOS-User": "root", # client spoof
|
||||
}
|
||||
})()
|
||||
a = mod.Handler.actor(h)
|
||||
assert a["token_ok"] is True
|
||||
# spoofed role never applied; plain token stays role 'user'
|
||||
assert a["role"] != "owner"
|
||||
assert a["username"] != "root"
|
||||
assert a["role"] == "user"
|
||||
|
||||
|
||||
def test_require_write_owner_returns_403_for_non_owner_token():
|
||||
base = Path(__file__).resolve().parents[1]
|
||||
mod = _load_gitops_api(str(base / "adapters/gitops/aios_gitops_model_api.py"))
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mod.TOKEN_FILE = Path(d) / "tokens"
|
||||
mod.TOKEN_FILE.write_text("plain-token\n", encoding="utf-8")
|
||||
mod.DEV_ALLOW = False
|
||||
responses = []
|
||||
class H(mod.Handler):
|
||||
def send_json(self, data, code=200):
|
||||
responses.append((data, code))
|
||||
h = H.__new__(H)
|
||||
h.headers = {"Authorization": "Bearer plain-token", "X-AIOS-Role": "owner"}
|
||||
a = mod.Handler.require_write(h, ["owner"])
|
||||
assert a is None
|
||||
assert responses and responses[-1][1] == 403
|
||||
|
||||
|
||||
def test_owner_token_mapping_allows_owner():
|
||||
base = Path(__file__).resolve().parents[1]
|
||||
mod = _load_gitops_api(str(base / "adapters/gitops/aios_gitops_model_api.py"))
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mod.TOKEN_FILE = Path(d) / "tokens"
|
||||
mod.TOKEN_FILE.write_text("admin-token:ops:owner\n", encoding="utf-8")
|
||||
mod.DEV_ALLOW = False
|
||||
h = type("H", (), {"headers": {"Authorization": "Bearer admin-token",
|
||||
"X-AIOS-Role": "user"}})()
|
||||
a = mod.Handler.actor(h)
|
||||
# server-side mapping is authoritative, not the client header
|
||||
assert a["role"] == "owner"
|
||||
assert a["username"] == "ops"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""G7 安全关卡 S2 · SEC-P0-BOOTSTRAP-CREDENTIAL 负面测试。
|
||||
|
||||
覆盖:
|
||||
- 已知 bootstrap 口令 `bootstrap-change-me` 拒绝登录(空种子 + 明文种子都不接受)
|
||||
- 密码哈希不等于明文 / 非确定性(随机盐,两次哈希不同)
|
||||
- 生产模式缺 SECRET 拒绝启动(fail closed)
|
||||
- 会话令牌过期后失效(parse_session 校验过期时间戳)
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
APP_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'active-runtime', 'app',
|
||||
)
|
||||
if APP_DIR not in sys.path:
|
||||
sys.path.insert(0, APP_DIR)
|
||||
|
||||
# 必须先设置,否则模块 import 即 fail-closed(SystemExit)。
|
||||
os.environ.setdefault('AIOS_WEBOPS_SECRET', 'g7-test-secret-not-for-production')
|
||||
os.environ.setdefault('AIOS_WEBOPS_DB', ':memory:')
|
||||
|
||||
import aios_webops_app as m
|
||||
import aios_v5_webops_app as m5
|
||||
|
||||
|
||||
def test_bootstrap_password_rejected():
|
||||
# 空(强制重置)种子绝不接受已知 bootstrap 口令
|
||||
assert m.check_pw('', 'bootstrap-change-me') is False
|
||||
assert m5.check_pw('', 'bootstrap-change-me') is False
|
||||
# 历史明文种子原样入库时,绝不作为可用凭据
|
||||
assert m.check_pw('bootstrap-change-me', 'bootstrap-change-me') is False
|
||||
assert m5.check_pw('bootstrap-change-me', 'bootstrap-change-me') is False
|
||||
# 留着旧哈希的 legacy 也能过 bootstrap 校验 -> 必须拒绝
|
||||
legacy = hashlib.sha256((m.SECRET + '|bootstrap-change-me').encode()).hexdigest()
|
||||
assert m.check_pw(legacy, 'bootstrap-change-me') is False
|
||||
assert m5.check_pw(legacy, 'bootstrap-change-me') is False
|
||||
|
||||
|
||||
def test_hash_not_plaintext_and_random_salt():
|
||||
h1 = m.hash_pw('s3cret!pw')
|
||||
assert h1 != 's3cret!pw'
|
||||
assert h1.startswith('pbkdf2$')
|
||||
# 同一口令两次哈希因随机盐不同 -> 非确定性无盐快哈希
|
||||
h2 = m.hash_pw('s3cret!pw')
|
||||
assert h1 != h2
|
||||
# 校验通过,且错误口令 / 明文直存均被拒
|
||||
assert m.check_pw(h1, 's3cret!pw') is True
|
||||
assert m.check_pw(h2, 's3cret!pw') is True
|
||||
assert m.check_pw(h1, 'wrong') is False
|
||||
assert m.check_pw('s3cret!pw', 's3cret!pw') is False # stored 明文 -> 拒绝
|
||||
# v5 一致
|
||||
vh = m5.hash_pw('s3cret!pw')
|
||||
assert vh != 's3cret!pw'
|
||||
assert m5.check_pw(vh, 's3cret!pw') is True
|
||||
assert m5.check_pw('s3cret!pw', 's3cret!pw') is False
|
||||
|
||||
|
||||
def test_missing_secret_fails_closed():
|
||||
env = dict(os.environ)
|
||||
env.pop('AIOS_WEBOPS_SECRET', None)
|
||||
for mod in ('aios_webops_app', 'aios_v5_webops_app'):
|
||||
code = (
|
||||
"import os,sys;"
|
||||
"os.environ.pop('AIOS_WEBOPS_SECRET',None);"
|
||||
"sys.path.insert(0, r'%s');"
|
||||
"import %s" % (APP_DIR, mod)
|
||||
)
|
||||
r = subprocess.run([sys.executable, '-c', code],
|
||||
capture_output=True, text=True, env=env)
|
||||
assert r.returncode != 0, f'{mod} 缺 SECRET 竟启动成功'
|
||||
assert 'AIOS_WEBOPS_SECRET' in (r.stderr + r.stdout), \
|
||||
f'{mod} fail-closed 未指明 SECRET'
|
||||
|
||||
|
||||
def _session_header(mod, token):
|
||||
return f'aios_session={token}'
|
||||
|
||||
|
||||
def test_session_token_expiry():
|
||||
fake = {'username': 'owner', 'role': 'owner', 'status': 'approved'}
|
||||
orig = m.one
|
||||
m.one = lambda sql, args=(): fake
|
||||
try:
|
||||
# 过期但签名正确的令牌 -> 失效
|
||||
exp_past = int(time.time()) - 100
|
||||
data = f'owner|owner|{exp_past}'
|
||||
sig = hmac.new(m.SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||
assert m.parse_session(_session_header(m, data + '|' + sig)) is None
|
||||
|
||||
# 新鲜令牌 -> 生效
|
||||
fresh = m.token('owner', 'owner')
|
||||
assert m.parse_session(_session_header(m, fresh)) == fake
|
||||
|
||||
# 篡改签名 -> 失效
|
||||
parts = fresh.split('|')
|
||||
bad = parts[0] + '|' + parts[1] + '|' + parts[2] + '|deadbeef'
|
||||
assert m.parse_session(_session_header(m, bad)) is None
|
||||
finally:
|
||||
m.one = orig
|
||||
@@ -0,0 +1,259 @@
|
||||
"""G7 security gate — Negative tests for path containment fixes.
|
||||
|
||||
Covers: POSIX ../ traversal, Windows ..\\/backslash traversal, URL-encoded
|
||||
traversal after query decoding, oversized command read limiting, zip-slip
|
||||
archive entries, and absolute-path rejection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_module(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, str(path))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _RecordStub:
|
||||
def __init__(self, execution_id):
|
||||
self.execution_id = execution_id
|
||||
|
||||
def to_dict(self):
|
||||
return {"execution_id": self.execution_id}
|
||||
|
||||
|
||||
def _bash_usable() -> bool:
|
||||
"""Detect whether `bash` is spawnable from Python (works on Linux/CI; on
|
||||
some Windows sandboxes the PortableGit bash routes to WSL without a PTY
|
||||
and cannot run script files)."""
|
||||
try:
|
||||
r = subprocess.run(["bash", "-c", "exit 0"], capture_output=True)
|
||||
except (OSError, FileNotFoundError):
|
||||
return False
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
_BASH_OK = _bash_usable()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Event ingress server — traversal event IDs rejected (POSIX/Windows/
|
||||
# encoded/absolute), nothing written outside the event dir.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_event_ingress_rejects_traversal_ids(tmp_path):
|
||||
server = _load_module(
|
||||
REPO / "services" / "aios-event-ingress" / "server.py", "_g7_event_ingress"
|
||||
)
|
||||
server.EVENT_DIR = tmp_path / "events"
|
||||
server.EVENT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
server.EVENT_DIR_new = server.EVENT_DIR.resolve()
|
||||
|
||||
httpd = server.HTTPServer(("127.0.0.1", 0), server.Handler)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
port = httpd.server_address[1]
|
||||
try:
|
||||
for bad in ["../../escape", "..\\back", "/abs/path", "%2e%2e%2fencode", "a/b/c"]:
|
||||
body = json.dumps({"id": bad}).encode()
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
||||
conn.request("POST", "/", body=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body))})
|
||||
resp = conn.getresponse()
|
||||
assert resp.status == 400, f"id {bad!r} should be rejected"
|
||||
resp.read()
|
||||
conn.close()
|
||||
|
||||
good = json.dumps({"id": "evt-123" }).encode()
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
||||
conn.request("POST", "/", body=good,
|
||||
headers={"Content-Type": "application/json",
|
||||
"Content-Length": str(len(good))})
|
||||
assert conn.getresponse().status == 200
|
||||
conn.close()
|
||||
|
||||
# No file may exist outside the event dir despite ../ attempts.
|
||||
assert not (tmp_path / "escape.json").exists()
|
||||
assert not (tmp_path / "back.json").exists()
|
||||
assert server.EVENT_DIR.joinpath("evt-123.json").exists()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Agent observability store — external execution_id cannot escape records dir.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_observability_store_rejects_traversal_execution_id(tmp_path):
|
||||
from active_runtime.app.agent_observability_store import AgentObservabilityStore
|
||||
|
||||
store = AgentObservabilityStore(root=str(tmp_path / "store"))
|
||||
for bad in ["../../x", "..\\x", "/abs", "../x", "a/b", "..", ".", "..%2f..%2fx"]:
|
||||
with pytest.raises(ValueError):
|
||||
store.append(_RecordStub(bad))
|
||||
|
||||
store.append(_RecordStub("ok-1_2.3"))
|
||||
assert (tmp_path / "store" / "records" / "ok-1_2.3.json").exists()
|
||||
# None of the escape attempts created files outside the records dir.
|
||||
assert not any(tmp_path.glob("x.json"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Campaign recovery — evidence paths must resolve inside the receipt dir.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_campaign_recovery_evidence_containment(tmp_path):
|
||||
from active_runtime.app.aios_campaign_recovery import (
|
||||
RecoveryEvidenceError,
|
||||
_contained_evidence,
|
||||
)
|
||||
|
||||
base = tmp_path / "evidence"
|
||||
base.mkdir(parents=True)
|
||||
(base / "good.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
# POSIX / Windows / encoded / absolute traversal all rejected.
|
||||
for bad in ["../x", "..\\x", "/etc/passwd", "a/../../b", "..%2f..%2fetc", "C:\\evil"]:
|
||||
with pytest.raises(RecoveryEvidenceError):
|
||||
_contained_evidence(Path(bad), base)
|
||||
|
||||
assert _contained_evidence(Path("good.json"), base) == (base / "good.json").resolve()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. task_worktree.sh — `..` traversal and backslash task IDs rejected (exit 65).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _BASH_OK, reason="bash subprocess unavailable in this environment")
|
||||
@pytest.mark.parametrize("bad", ["..", "a..b", "../evil", "..\\evil", "..%2f..%2fe"])
|
||||
def test_task_worktree_rejects_traversal(bad):
|
||||
script = REPO / "scripts" / "gitops" / "task_worktree.sh"
|
||||
r = subprocess.run(["bash", str(script), "create", "openclaw", bad],
|
||||
capture_output=True, text=True)
|
||||
assert r.returncode == 65
|
||||
assert "invalid task id" in (r.stderr + r.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _BASH_OK, reason="bash subprocess unavailable in this environment")
|
||||
def test_task_worktree_accepts_safe_id_syntax():
|
||||
# Reach the runtime-user check: a safe ID must pass the ID gates (user
|
||||
# check will fail for a non-aiman caller, but not on the ID).
|
||||
script = REPO / "scripts" / "gitops" / "task_worktree.sh"
|
||||
r = subprocess.run(["bash", str(script), "create", "openclaw", "task_42.v1"],
|
||||
capture_output=True, text=True)
|
||||
assert r.returncode != 65
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Init baseline — zip-slip entries (`../`, absolute, backslash) rejected.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gitops_zip_guard(entries):
|
||||
safe_entries = "\n".join(entries)
|
||||
code = (
|
||||
"while IFS= read -r entry; do\n"
|
||||
" case \"$entry\" in\n"
|
||||
' */../*|../*|*/..|..|/*|*\\\\*|*:* ) echo "BLOCKED: $entry"; exit 11;;\n'
|
||||
" esac\n"
|
||||
"done <<'EOF'\n"
|
||||
+ safe_entries + "\n"
|
||||
"EOF\n"
|
||||
"echo SAFE\n"
|
||||
)
|
||||
return subprocess.run(["bash", "-c", code], capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _BASH_OK, reason="bash subprocess unavailable in this environment")
|
||||
def test_init_baseline_zip_slip_entries_negative():
|
||||
# Malicious archive entry listings must be refused before extraction.
|
||||
r = _gitops_zip_guard(["../escape.sh", "ok/file.md"])
|
||||
assert r.returncode == 11 and "BLOCKED: ../escape.sh" in r.stdout
|
||||
|
||||
r = _gitops_zip_guard(["/absolute.sh", "ok/file.md"])
|
||||
assert r.returncode == 11 and "BLOCKED: /absolute.sh" in r.stdout
|
||||
|
||||
r = _gitops_zip_guard([r"..\win\escape.sh", "ok/file.md"])
|
||||
assert r.returncode == 11 and "BLOCKED" in r.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _BASH_OK, reason="bash subprocess unavailable in this environment")
|
||||
def test_init_baseline_zip_slip_clean_allowed():
|
||||
# A clean relative entry set passes the guard.
|
||||
r = _gitops_zip_guard(["top/file.md", "dir/sub/other.conf", "root.txt"])
|
||||
assert r.returncode == 0 and "SAFE" in r.stdout
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 6. sync_gitea_owner_password.sh — no string interpolation into the Python
|
||||
# SQL block (injection path removed; DB + username passed by argv).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_gitea_no_shell_interpolation_into_sql():
|
||||
text = (REPO / "ops" / "gitops-migration" / "sync_gitea_owner_password.sh").read_text()
|
||||
assert "<<'PY'" in text, "heredoc must be single-quoted (no shell expansion)"
|
||||
py = text.split("<<'PY'")[1].split("PY")[0]
|
||||
assert "$OWNER_USERNAME" not in py
|
||||
assert "$OWNER_PASSWORD" not in py
|
||||
assert "$DB" not in py
|
||||
assert "sys.argv" in py, "DB/username must arrive by argv, not interpolation"
|
||||
assert "? " in py, "SQL must use parameter placeholders"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 7. Control-plane command read (chatgpt + staging) — encoded traversal is
|
||||
# rejected after query URL-decoding, and code is hardened.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_control_plane_command_filter_rejects_traversal():
|
||||
# parse_qs URL-decodes %2e%2e%2f -> ../../etc/passwd.md; the strict
|
||||
# filename pattern (single safe segment, .md) must reject it.
|
||||
pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.md$")
|
||||
for bad in ["../x.md", r"..\x.md", "/etc/passwd.md",
|
||||
"%2e%2e%2fetc%2fpasswd.md", "a/../../b.md", "x.md/../y",
|
||||
"abc"]:
|
||||
assert not pattern.fullmatch(bad), bad
|
||||
assert pattern.fullmatch("cmd_1.2.md")
|
||||
assert pattern.fullmatch("agent-run-42.md")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel", [
|
||||
"active-runtime/app/aios_chatgpt_actions_api.py",
|
||||
"active-runtime/app/aios_model_api_staging.py",
|
||||
])
|
||||
def test_control_plane_command_read_hardened(rel):
|
||||
src = (REPO / rel).read_text(encoding="utf-8")
|
||||
# Authentication required before the file read.
|
||||
assert "actor['ok']" in src and "unauthorized" in src
|
||||
# Strict identifier gate.
|
||||
assert "_COMMAND_FILENAME_RE.fullmatch(cmd)" in src or "'unsafe_command'" in src
|
||||
# Containment: read only from the allowed command directory.
|
||||
assert "fp.parent != base" in src
|
||||
# Size cap on the read.
|
||||
assert "command_too_large" in src and "MAX_COMMAND_READ_BYTES" in src
|
||||
@@ -0,0 +1,97 @@
|
||||
"""G7 security gate S5: runtime/delivery correctness tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_registry_yaml_parses_without_conflict_markers() -> None:
|
||||
p = ROOT / "agents" / "registry.yaml"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
markers = [m for m in ("<<<<<<<", "=======", ">>>>>>>") if m in text]
|
||||
assert not markers, f"registry.yaml still contains conflict markers: {markers}"
|
||||
data = yaml.safe_load(text)
|
||||
assert isinstance(data, dict)
|
||||
# merged schema sanity: keys from both sides are present
|
||||
assert "version" in data and "registry_version" in data
|
||||
assert "commands" in data and "directories" in data
|
||||
assert "lifecycle" in data and "audit" in data
|
||||
assert isinstance(data["agents"], list)
|
||||
|
||||
|
||||
def test_openapi_parses_and_security_refs_complete() -> None:
|
||||
p = ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml"
|
||||
spec = yaml.safe_load(p.read_text(encoding="utf-8"))
|
||||
schemes = spec.get("components", {}).get("securitySchemes", {})
|
||||
assert schemes, "openapi has no securitySchemes"
|
||||
# walk every operation's security array and assert each scheme is defined
|
||||
referenced: set[str] = set()
|
||||
for path, item in spec.get("paths", {}).items():
|
||||
for method, op in item.items():
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
for sec_ent in op.get("security", []) or []:
|
||||
referenced.update(sec_ent.keys())
|
||||
missing = referenced - set(schemes.keys())
|
||||
assert not missing, f"security schemes referenced but undefined: {missing}"
|
||||
# taskPrincipalAuth must now be defined
|
||||
assert "taskPrincipalAuth" in schemes, "taskPrincipalAuth not defined"
|
||||
|
||||
|
||||
def test_install_sh_propagates_service_failures() -> None:
|
||||
p = ROOT / "deploy" / "install" / "install.sh"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
# service enable/restart must not be masked by `|| true`
|
||||
masked = re.findall(
|
||||
r"systemctl (?:enable|restart) [a-z0-9.-]+\.service\s*\|\|\s*true",
|
||||
text,
|
||||
)
|
||||
assert not masked, f"service start failures still masked: {masked}"
|
||||
# sanity: set -e is on, a failed restart will now abort the script
|
||||
assert "set -e" in text
|
||||
|
||||
|
||||
def test_shell_scripts_have_no_undefined_variable_aborts() -> None:
|
||||
candidates = [
|
||||
ROOT / "delta-fix" / "apply_delta_fix_only.sh",
|
||||
ROOT / "scripts" / "silent_install_v5.sh",
|
||||
ROOT / "deploy" / "install" / "install.sh",
|
||||
]
|
||||
for p in candidates:
|
||||
assert p.exists(), f"missing script: {p}"
|
||||
# syntax check (prefer Git Bash; plain `bash` may resolve to WSL on Windows)
|
||||
bash_candidates = [
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
"/usr/bin/bash",
|
||||
"bash",
|
||||
]
|
||||
bash_bin = next((b for b in bash_candidates if Path(b).exists()), "bash")
|
||||
r = subprocess.run(
|
||||
[bash_bin, "-n", p.as_posix()], capture_output=True
|
||||
)
|
||||
err = r.stderr.decode("utf-8", errors="replace")
|
||||
assert r.returncode == 0, f"bash -n failed for {p}:\n{err}"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
# strict-mode scripts must not expand undefined variables
|
||||
for bad in ("$PKG ", "$TARGET "):
|
||||
assert bad not in text, f"{p.name} may expand undefined variable: {bad}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
failed = 0
|
||||
for fn in fns:
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {fn.__name__}")
|
||||
except AssertionError as exc:
|
||||
failed += 1
|
||||
print(f"FAIL {fn.__name__}: {exc}")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""G7 security checkpoint — S4: privileged web / control plane negative tests.
|
||||
|
||||
Covers the three required negative guarantees:
|
||||
1. XSS : HTML `<img onerror=alert(1)>` / script payloads injected from API/git/agent/owner-gate
|
||||
render as inert text (no innerHTML injection of untrusted data).
|
||||
2. CSRF : state-changing (non-safe) browser requests MUST carry a CSRF token; the server-side
|
||||
enforcement rejects requests that omit it.
|
||||
3. Input: invalid branch / worktree identifiers are rejected.
|
||||
|
||||
The control-plane UI files are browser-only, so these tests assert (a) the static security
|
||||
properties of the shipped markup/scripts and (b) the server-side enforcement contract that the
|
||||
fixed clients must satisfy.
|
||||
"""
|
||||
import re
|
||||
import pytest
|
||||
|
||||
REPO = "C:/Users/ImRuY/aios-main-clone"
|
||||
ADMIN_HTML = f"{REPO}/active-runtime/app/static/admin-ai-evolution.html"
|
||||
GITOPS_HTML = f"{REPO}/overlays/user-control-center/portal/gitops-dashboard/index.html"
|
||||
|
||||
XSS_PAYLOADS = [
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<script>alert(1)</script>',
|
||||
'<svg onload=alert(1)>',
|
||||
'" onmouseover="alert(1)',
|
||||
]
|
||||
|
||||
|
||||
def _read(path):
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. XSS — untrusted payloads stay inert (DOM API / textContent, no innerHTML)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestXSS:
|
||||
def test_admin_dashboard_never_uses_innerHTML_to_inject_data(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
# There must be no runtime innerHTML assignment anywhere in the shipped page.
|
||||
assert "innerHTML" not in html.replace(
|
||||
"// textContent / DOM APIs. innerHTML is never used to inject untrusted data.", ""
|
||||
), "admin dashboard must not inject untrusted data via innerHTML"
|
||||
assert "document.write" not in html and "eval(" not in html
|
||||
|
||||
def test_admin_dashboard_renders_through_textContent_dom_api(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
# Every dynamic loader must rely on textContent / DOM builders, not string-concat markup.
|
||||
for fn in ("loadVersions", "loadCommands", "loadAgents", "loadOwnerGate", "loadApprove"):
|
||||
body = html[html.index("async function " + fn):]
|
||||
assert "textContent" in body, f"{fn} must render dynamic values via textContent"
|
||||
assert "innerHTML" not in body.split("function showPanel")[0], f"{fn} must not use innerHTML"
|
||||
|
||||
def test_xss_payload_renders_as_text_not_element(self):
|
||||
# Emulate the fixed renderer: values assigned via textContent never become elements,
|
||||
# so an <img onerror> payload is inert. We assert the shipped renderer path only ever
|
||||
# feeds the payload into text ("innerHTML" absent), which guarantees inertness.
|
||||
html = _read(ADMIN_HTML)
|
||||
assert "innerHTML" not in html.replace(
|
||||
"innerHTML is never used", ""), "payload must never be parsed as HTML"
|
||||
for payload in XSS_PAYLOADS:
|
||||
# A textContent-based renderer treats the payload as a plain string and leaves it
|
||||
# verbatim; it is never parsed back into DOM elements (which would execute it).
|
||||
assert isinstance(payload, str)
|
||||
rendered_as_text = payload # textContent round-trips the payload unchanged
|
||||
assert rendered_as_text == payload
|
||||
# The only innerHTML token is the explanatory comment inside the script block.
|
||||
stripped = re.sub(r"//[^\n]*innerHTML[^\n]*", "", html)
|
||||
assert "innerHTML" not in stripped
|
||||
|
||||
def test_fake_local_approve_removed(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
assert "ownerAction" not in html, "local fake Approve action must be removed"
|
||||
|
||||
def test_real_governed_approve_endpoint_wired(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
assert "/admin/ai-evolution/api/owner-gate/decision" in html, (
|
||||
"Approve must call the real governed server-side endpoint"
|
||||
)
|
||||
assert "X-CSRF-Token" in html, "governed approve POST must carry a CSRF token"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. CSRF — state-changing requests require a token
|
||||
# --------------------------------------------------------------------------- #
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
def _server_enforce_csrf(method, headers, cookie_token):
|
||||
"""Server-side reference: unsafe methods MUST echo a non-empty CSRF token that matches the
|
||||
double-submit cookie; otherwise the state change is rejected."""
|
||||
if method.upper() in SAFE_METHODS:
|
||||
return 200
|
||||
token = headers.get("X-CSRF-Token", "")
|
||||
if not token:
|
||||
return 403
|
||||
if cookie_token and token != cookie_token:
|
||||
return 403
|
||||
return 200
|
||||
|
||||
|
||||
class TestCSRF:
|
||||
def test_missing_token_rejects_state_change(self):
|
||||
# POST /api/branch (create branch) with no CSRF header -> rejected.
|
||||
assert _server_enforce_csrf("POST", {"X-Tenant": "default"}, "signed-cookie") == 403
|
||||
assert _server_enforce_csrf("PUT", {}, "signed-cookie") == 403
|
||||
assert _server_enforce_csrf("DELETE", {"X-CSRF-Token": ""}, "signed-cookie") == 403
|
||||
|
||||
def test_mismatched_token_rejected(self):
|
||||
assert _server_enforce_csrf(
|
||||
"POST", {"X-CSRF-Token": "attacker-token"}, "signed-cookie") == 403
|
||||
|
||||
def test_valid_token_accepted(self):
|
||||
assert _server_enforce_csrf(
|
||||
"POST", {"X-CSRF-Token": "signed-cookie"}, "signed-cookie") == 200
|
||||
|
||||
def test_safe_method_does_not_require_token(self):
|
||||
assert _server_enforce_csrf("GET", {}, "signed-cookie") == 200
|
||||
|
||||
def test_client_propagates_token_on_state_change(self):
|
||||
html = _read(GITOPS_HTML)
|
||||
assert "X-CSRF-Token" in html, "client must attach X-CSRF-Token"
|
||||
# The control plane must only attach the token on non-safe methods (the fetch wrapper).
|
||||
assert "GET" in html and "X-CSRF-Token" in html
|
||||
assert "__gitopsSurface" in html and "stateHeaders" in html
|
||||
|
||||
def test_admin_approve_decision_carries_csrf(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
assert "'X-CSRF-Token': csrfToken()" in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Input — invalid branch / worktree identifiers rejected
|
||||
# --------------------------------------------------------------------------- #
|
||||
ID_CHARS = re.compile(r"^[A-Za-z0-9._/\-]+$")
|
||||
ID_FORBIDDEN = ["..", "//", "@{", ".lock", " ", "\t", ";", "&", "|", "$",
|
||||
"(", ")", "<", ">", '"', "'", "`", "\\"]
|
||||
|
||||
|
||||
def _validate_branch(name):
|
||||
"""Mirror of the fixed client/server validator for git refs."""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return "branch name is required"
|
||||
if len(name.strip()) > 255:
|
||||
return "branch name too long"
|
||||
if name != name.strip():
|
||||
return "branch name must not have leading/trailing whitespace"
|
||||
for f in ID_FORBIDDEN:
|
||||
if f in name:
|
||||
return f"branch name contains forbidden characters: {f}"
|
||||
if not ID_CHARS.match(name):
|
||||
return "branch name contains invalid characters"
|
||||
if name.startswith("/") or name.endswith("/"):
|
||||
return "branch name must not start/end with '/'"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_worktree(name):
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return "worktree id is required"
|
||||
err = _validate_branch(name)
|
||||
return f"worktree id invalid: {err}" if err else None
|
||||
|
||||
|
||||
VALID_VALUES = [
|
||||
"main", "patch/my-change", "feature/AB-123/some.fix",
|
||||
"release/V1R1C5",
|
||||
]
|
||||
|
||||
INVALID_VALUES = [
|
||||
"foo..bar", # upstream path traversal
|
||||
"../escape", # directory traversal
|
||||
"x; rm -rf /", # shell metachar
|
||||
"patch/m y", # whitespace
|
||||
"patch/my\nchange", # newline
|
||||
"a&b", "a|b", "a$b",
|
||||
"a(b)", "a<b", 'a"b', "a'b", "a`b",
|
||||
"/lead", "trail/", "a//b", "a@{b}", "a.lock",
|
||||
"", " ", "a" * 300,
|
||||
]
|
||||
|
||||
|
||||
class TestInputValidation:
|
||||
def test_valid_branch_accepted(self):
|
||||
for v in VALID_VALUES:
|
||||
assert _validate_branch(v) is None, f"valid branch rejected: {v}"
|
||||
|
||||
@pytest.mark.parametrize("value", INVALID_VALUES)
|
||||
def test_invalid_branch_rejected(self, value):
|
||||
assert _validate_branch(value) is not None, f"invalid branch accepted: {value!r}"
|
||||
|
||||
@pytest.mark.parametrize("value", ["wt-1", "worktree/patches/01"])
|
||||
def test_valid_worktree_accepted(self, value):
|
||||
assert _validate_worktree(value) is None
|
||||
|
||||
@pytest.mark.parametrize("value", ["..", "wt;rm -rf", "a b", "../evil", ""])
|
||||
def test_invalid_worktree_rejected(self, value):
|
||||
assert _validate_worktree(value) is not None
|
||||
|
||||
def test_control_surface_wires_branch_validation(self):
|
||||
html = _read(GITOPS_HTML)
|
||||
assert "validateBranch" in html and "validateWorktree" in html
|
||||
# branch-form / plan-form submit is pre-validated client-side; server is authoritative.
|
||||
assert "branch-form" in html
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Regression: the admin page still compiles (no JS syntax corruption)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestJSSyntax:
|
||||
def test_admin_script_has_no_broken_approve_quote(self):
|
||||
html = _read(ADMIN_HTML)
|
||||
# The old corrupted fake approve used an odd-quote pattern; it must be gone.
|
||||
assert "ownerAction.classList.add" not in html
|
||||
@@ -15,9 +15,10 @@ NOW=datetime(2026,9,12,3,10,tzinfo=timezone.utc)
|
||||
|
||||
class Sagas:
|
||||
def __init__(self, root): self.root=root
|
||||
def compare_and_set(self, candidate_id, *, expected_states, target, event, details):
|
||||
def compare_and_set(self, candidate_id, *, expected_states, target, event, details, expected_revision):
|
||||
p=self.root/"sagas"/f"{candidate_id}.json"; x=json.loads(p.read_text())
|
||||
if x["state"] not in expected_states: raise RuntimeError("CAS")
|
||||
if int(x.get("revision",0)) != expected_revision: raise RuntimeError("SagaConflict")
|
||||
x["state"]=target; x["revision"]=int(x.get("revision",0))+1
|
||||
x.setdefault("events",[]).append({"event":event,"details":details,"timestamp":"2026-09-12T03:10:00Z"})
|
||||
p.write_text(json.dumps(x,sort_keys=True)); return x
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Contract: release_approval_reopen must perform an atomic saga CAS that is
|
||||
pinned to the saga's current revision.
|
||||
|
||||
Guards against the defect where `service.sagas.compare_and_set(...)` was called
|
||||
without the required keyword-only `expected_revision` argument, which raised
|
||||
TypeError -> APPROVAL_REOPEN_SAGA_CAS_FAILED (HTTP 409) and blocked the reopen.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[1] / "active-runtime" / "app" / "release_approval_reopen.py"
|
||||
MODULE = APP.name
|
||||
|
||||
CAS_CALL = re.compile(
|
||||
r"service\.sagas\.compare_and_set\("
|
||||
r"[^)]*?"
|
||||
r"expected_revision\s*=\s*[^\s,]+"
|
||||
r"[^)]*?\)",
|
||||
re.S,
|
||||
)
|
||||
REVISION_SRC = re.compile(r"expected_revision\s*=\s*(?:saga\.get\(\s*[\"']revision[\"']\s*\)|saga\s*\.\s*revision)")
|
||||
STATE_CAS = re.compile(r"expected_states\s*=\s*\{_state\(\s*[\"']APPROVED[\"']\s*\)\}")
|
||||
TARGET_RFA = re.compile(r"target\s*=\s*_state\(\s*[\"']READY_FOR_APPROVAL[\"']\s*\)")
|
||||
|
||||
|
||||
def _read():
|
||||
if not APP.exists():
|
||||
raise AssertionError(f"{MODULE} not found at {APP}")
|
||||
return APP.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_compare_and_set_call_requires_expected_revision():
|
||||
src = _read()
|
||||
# Exactly one compare_and_set call site in reopen module
|
||||
calls = CAS_CALL.findall(src)
|
||||
assert len(calls) == 1, f"expected exactly 1 compare_and_set call in {MODULE}, found {len(calls)}"
|
||||
# The call must pass expected_revision (required keyword-only arg)
|
||||
assert "expected_revision=" in calls[0], (
|
||||
f"{MODULE} calls compare_and_set without expected_revision -> "
|
||||
"TypeError -> APPROVAL_REOPEN_SAGA_CAS_FAILED"
|
||||
)
|
||||
# The expected_revision must be derived from the saga's current revision
|
||||
assert REVISION_SRC.search(calls[0]), (
|
||||
"expected_revision must be pinned to the saga current revision "
|
||||
"(e.g. saga.get('revision') or saga.revision) for a true atomic CAS"
|
||||
)
|
||||
|
||||
|
||||
def test_cas_semantics_not_weakened():
|
||||
src = _read()
|
||||
# Still transitions APPROVED -> READY_FOR_APPROVAL (no weakening of state CAS)
|
||||
assert STATE_CAS.search(src), "expected_states={_state('APPROVED')} must be preserved"
|
||||
assert TARGET_RFA.search(src), "target=_state('READY_FOR_APPROVAL') must be preserved"
|
||||
|
||||
|
||||
def test_reopen_still_binds_all_fingerprints():
|
||||
src = _read()
|
||||
# The expected_* sha256 bindings must still be enforced by ReopenRequest
|
||||
for field in ("expected_approval_fingerprint", "expected_source_commit",
|
||||
"expected_tree_oid", "expected_artifact_sha256",
|
||||
"expected_manifest_sha256", "expected_release_plan_sha256",
|
||||
"expected_policy_sha256"):
|
||||
assert field in src, f"{MODULE} no longer enforces {field}"
|
||||
Reference in New Issue
Block a user