Compare commits

...
Author SHA1 Message Date
owner d5f5c9b8b9 test(sec): add G7 negative test suites + closure evidence pack (S6)
- auth/credential/path_containment/web_surface/runtime_delivery negative tests (66 pass)
- G7 evidence: pre/post SHA, classification, migration/rollback, verdict
2026-09-14 21:34:05 +08:00
owner bdd7cb1f8a fix(delivery): resolve registry.yaml merge conflict, close OpenAPI security scheme, propagate install failures, fix undefined vars (G7 S5)
- agents/registry.yaml: resolve blocking <<< merge conflict -> single parsable schema
- openapi-chatgpt-actions: add securitySchemes.taskPrincipalAuth
- deploy/install/install.sh: remove || true masking so service failure aborts install
- silent_install_v5.sh + apply_delta_fix_only.sh: fix missing script / undefined vars
2026-09-14 21:33:58 +08:00
owner d8bdb1d385 fix(sec): admin UI XSS + real governed approval + dashboard CSRF/branch validation (G7 S4)
- admin-ai-evolution.html: textContent escaping, remove innerHTML inject,
  fix JS syntax, replace local fake Approve with POST /admin/ai-evolution/api/owner-gate/decision + CSRF token
- gitops-dashboard: client CSRF token propagation on state-changing requests, server-side branch/worktree validation
2026-09-14 21:33:50 +08:00
owner ef768192c3 fix(sec): path/traversal+zip-slip containment and control-plane file-read hardening (G7 S3)
- event-ingress server, observability store, campaign recovery: ID whitelist + resolve containment
- task_worktree.sh: tighten TASK_ID regex, reject ../ and ..\ traversal
- init_gitops_baseline.sh: validate zip entries before extract (zip-slip)
- sync_gitea_owner_password.sh: remove env-sourced shell injection
- chatgpt_actions + model_api_staging: authenticated command read w/ strict id + containment + size cap
2026-09-14 21:33:43 +08:00
owner c920390469 fix(sec): PBKDF2+random-salt password hashing, fail-closed secret, no plaintext fallback, session expiry (G7 S2)
- seed no usable bootstrap password (forced reset) + neutralize legacy known seed
- SECRET missing/insecure default -> refuse to start (fail closed)
- pbkdf2_hmac 260k w/ random salt; check_pw never accepts plaintext
- session token: HMAC full signature + expiry TTL + SameSite=Strict
- legacy unsalted hash honored only as one-time owner migration, rejects known default
2026-09-14 21:33:36 +08:00
owner 1f4066b86c fix(sec): derive role/user from authenticated token identity, ignore client-controlled role headers (G7 S1)
- aios_gitops_model_api (adapters+app): drop X-AIOS-Role/X-AIOS-User trust;
  role/user resolved from server-side token->identity mapping in TOKEN_FILE.
- task_action_api: ignore body role; role from injected authenticator (default user).
- agent_observability_api: tenant/principal decides scope; client role=owner ignored.
2026-09-14 21:33:29 +08:00
owner beabd05396 test(governed): align reopen fake CAS with expected_revision contract (V2R2C2 hot-fix)
autonomy-smoke-tests pr82 PASS evidence_sha256=c16c68f86ee0c4e8cfd0af3f76078be45441ee151395fbde5800145c08e189b5
autonomy-unit-tests pr82 PASS evidence_sha256=168bb88573b44876966c64ec2dc2b9e030fc7635ca0b18538678f1c492c97ea2
campaign-approval-gate pr82 PASS evidence_sha256=0616d6aa11496391686dcbd658123e51d8d057e1f02661af7ef8512554e404d8
git-diff-check pr82 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
py-compile pr82 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
reconciliation-contract pr82 PASS evidence_sha256=8250e3e736db14432c421c46f995c201540e2b9c50958bdeb3ef7b057c4fd577
release-state-machine pr82 PASS evidence_sha256=b6c51b401d65a9c69435f8b86cecab752d65ded7188228aab3de3eb010990b60
secret-scan pr82 PASS evidence_sha256=f8ed9052d5ba0fbe8e52f171ea180fa353a89b4049cd3e1ed881736d0622c752
signer-validator-contract pr82 PASS evidence_sha256=775b7aa27d0c17f48b3feff20c836f14b6ed8ebcb90ec7e0b76d2a13c579268a
aios.owner-final-merge-authorization-v2 real v2 contract tests passed
2026-09-12 15:29:18 +08:00
owner 5a4e952b39 fix(governed): pin saga CAS to current revision in approval reopen (V2R2C2)
autonomy-smoke-tests pr82 PASS evidence_sha256=c16c68f86ee0c4e8cfd0af3f76078be45441ee151395fbde5800145c08e189b5
autonomy-unit-tests pr82 FAIL evidence_sha256=44994ff4cfe08df298fe59f790f3f98df7aa29e4d239ecd185b3143818c540cd
campaign-approval-gate pr82 PASS evidence_sha256=0616d6aa11496391686dcbd658123e51d8d057e1f02661af7ef8512554e404d8
git-diff-check pr82 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
py-compile pr82 PASS evidence_sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
reconciliation-contract pr82 PASS evidence_sha256=a0d5c1849c776f73fa64b02300e10c193241c0f9460e6ad4e69d73ae720de90b
release-state-machine pr82 PASS evidence_sha256=1d3314562a9345a9f75b1adf32d61168822c00d316f7db85833230bba023e448
secret-scan pr82 PASS evidence_sha256=9ca1f810bf76589206704716c4f3701a6b6aca1066b1d0c24b91d5be008f52a2
signer-validator-contract pr82 PASS evidence_sha256=775b7aa27d0c17f48b3feff20c836f14b6ed8ebcb90ec7e0b76d2a13c579268a
2026-09-12 15:16:32 +08:00
31 changed files with 1654 additions and 172 deletions
+43 -2
View File
@@ -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:
+27 -1
View File
@@ -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 {
+21 -3
View File
@@ -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,10 +1047,23 @@ 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():
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:
+32 -4
View File
@@ -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
+32 -17
View File
@@ -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,10 +1250,23 @@ 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():
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:
+40 -8
View File
@@ -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())
+40 -8
View File
@@ -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,
+162 -66
View File
@@ -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;
el.appendChild(grid);
} catch (e) {
el.innerHTML = '<div class="error">Failed to load versions: ' + e.message + '</div>';
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);
}
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);
}
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="error">Failed to load commands: ' + e.message + '</div>';
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;
}
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);
}
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>';
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;
el.appendChild(listCard);
} catch (e) {
el.innerHTML = '<div class="error">Failed to load: ' + e.message + '</div>';
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>';
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,
+25 -1
View File
@@ -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'));
+32 -4
View File
@@ -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
View File
@@ -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 项未改动并给出全库引用证据。
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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
+5
View File
@@ -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')
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()), '$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'))
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>
+8 -1
View File
@@ -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
+10
View File
@@ -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"
+6 -1
View File
@@ -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"
+19 -3
View File
@@ -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()
+184
View File
@@ -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"
+104
View File
@@ -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
+259
View File
@@ -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
+97
View File
@@ -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)
+214
View File
@@ -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
+2 -1
View File
@@ -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}"