From 8485822da65ec6b2f46470ba0370e0ebf8de8c4f Mon Sep 17 00:00:00 2001 From: bingshuo Date: Sun, 26 Jul 2026 23:13:12 +0800 Subject: [PATCH] feat: add fixed enterprise Agent gate Part 2/4 of verified local 18dfdfd: fail-closed navigation and intent gate, fixed Agent connector, loopback code-channel candidate scripts and tests. --- server-tools/enterprise-lighthouse/README.md | 28 ++- .../agent_gate_client.py | 127 +++++++++++ .../aw-hlcc-candidate-deploy | 75 +++++++ .../fixed_agent_connector.py | 91 ++++++++ .../enterprise-lighthouse/lighthouse.py | 201 ++++++++++++++++-- .../enterprise-lighthouse/test_lighthouse.py | 155 +++++++++++--- .../candidate-app.enterprise.ini | 4 +- .../install-candidate.sh | 5 +- .../hololake-code-channel/start-candidate.sh | 4 +- .../hololake-code-channel/stop-candidate.sh | 2 +- 10 files changed, 643 insertions(+), 49 deletions(-) create mode 100755 server-tools/enterprise-lighthouse/agent_gate_client.py create mode 100755 server-tools/enterprise-lighthouse/aw-hlcc-candidate-deploy create mode 100755 server-tools/enterprise-lighthouse/fixed_agent_connector.py diff --git a/server-tools/enterprise-lighthouse/README.md b/server-tools/enterprise-lighthouse/README.md index 1142985..53cfa85 100644 --- a/server-tools/enterprise-lighthouse/README.md +++ b/server-tools/enterprise-lighthouse/README.md @@ -4,6 +4,32 @@ 部署后只监听本机 `127.0.0.1:8031`。公众网站与后续 GLSV 页面通过 Nginx 以单独的受控路由接入;在邮件确认与节点连接器完成前,不开放节点激活或执行。 -人类管理员与人格体遵守同一条地图门禁:先 `GET /v1/navigation-map` 完整读取地图,再向 `/v1/navigation-map/ack` 签收当前哈希。任何域、动作或地图版本变化都会让旧签收失效;未签收时所有登记、预检和变更请求返回 `423`。管理员 token 不能绕过地图。 +人类管理员与人格体遵守同一条入口链: + +```text +GET /v2/global-navigation-map +→ POST /v1/navigation-map/ack +→ POST /v1/intent-state/restore +→ POST /v1/agents/unlock +→ 固定 Agent 连接器消费一次性 unlock +→ 验证和回执 +``` + +地图签收按当前主体和人类锚点隔离,不能共用;地图、主体、意图胶囊或动作任一变化都会让 +旧解锁失效。管理员 token 只能证明入口认证,不能绕过地图、意图状态和 Agent 解锁。 +HTTP 头中的主体编号必须使用注册表登记的 ASCII 传输别名,例如 `ICE-GL-INF`;显示层可 +继续呈现 `ICE-GL∞`,两者必须由编号注册表逐条映射,不能临时猜写。 + +root 可以在物理层停止或移除本服务,但绕过本门直接执行的动作属于 +`OUT_OF_SYSTEM · NO_GUANGHU_RECEIPT`,不得登记为光湖系统内部成功。 固定动作只有:`health_check`、`backup`、`deploy_release`、`restart_service`、`rollback`。部署、重启和回滚必须在预检中具备备份引用与回滚计划;预检通过也只是“可申请人类授权”,不会执行。 + +`fixed_agent_connector.py` 是灯塔之后的 root 所有本地边界。它先消费一个完全匹配的 +短期解锁,再把精确的 Agent/动作/目标三元组映射到代码中固化的可执行文件;调用者不能 +传入命令或可执行路径。首个注册连接器 `aw-hlcc-candidate-deploy` 只会安装并启动锁定到 +已审核提交的、仅监听回环地址的 HLCC 隔离候选实例。 + +`agent_gate_client.py` 负责按固定顺序读取地图、按当前主体签收、恢复外显意图胶囊、申请 +单 Agent/单动作解锁,并把一次性解锁交给固定连接器。管理员凭据只从进程环境读取,不 +写入参数、意图胶囊、仓库或回执。 diff --git a/server-tools/enterprise-lighthouse/agent_gate_client.py b/server-tools/enterprise-lighthouse/agent_gate_client.py new file mode 100755 index 0000000..a343281 --- /dev/null +++ b/server-tools/enterprise-lighthouse/agent_gate_client.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Read, acknowledge, restore intent, unlock one Agent, then call the fixed connector.""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + + +BASE_URL = "http://127.0.0.1:8031" +CONNECTOR = "/usr/local/sbin/guanghu-fixed-agent-connector" + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--intent-state", required=True) + parser.add_argument("--principal-id", required=True) + parser.add_argument("--subject-kind", required=True, choices=("human", "persona", "persona_system")) + parser.add_argument("--human-anchor", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--action", required=True) + parser.add_argument("--target-node-id", required=True) + parser.add_argument("--execute", action="store_true") + return parser.parse_args() + + +def request_json(path, token="", headers=None, payload=None): + request_headers = dict(headers or {}) + if token: + request_headers["X-Lighthouse-Admin-Token"] = token + data = None + method = "GET" + if payload is not None: + request_headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode() + method = "POST" + request = urllib.request.Request( + BASE_URL + path, + data=data, + method=method, + headers=request_headers, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return json.load(response) + except urllib.error.HTTPError as error: + print("Gate rejected %s with HTTP %d." % (path, error.code), file=sys.stderr) + raise SystemExit(4) + except (OSError, ValueError) as error: + print("Gate request failed: %s" % error, file=sys.stderr) + raise SystemExit(4) + + +def main(): + args = parse_args() + token = os.environ.get("LIGHTHOUSE_ADMIN_TOKEN", "") + if not token: + print("LIGHTHOUSE_ADMIN_TOKEN is required in the process environment.", file=sys.stderr) + raise SystemExit(3) + with open(args.intent_state, encoding="utf-8") as handle: + capsule = json.load(handle) + identity_headers = { + "X-Guanghu-Principal-Id": args.principal_id, + "X-Guanghu-Subject-Kind": args.subject_kind, + "X-Guanghu-Human-Anchor": args.human_anchor, + } + navigation = request_json("/v2/global-navigation-map") + acknowledged = request_json( + "/v1/navigation-map/ack", + token=token, + headers=identity_headers, + payload={"map_hash": navigation["map_hash"]}, + ) + if not acknowledged.get("ok"): + raise SystemExit(4) + restored = request_json( + "/v1/intent-state/restore", + token=token, + headers=identity_headers, + payload=capsule, + ) + if not restored.get("ok"): + raise SystemExit(4) + unlocked = request_json( + "/v1/agents/unlock", + token=token, + headers=identity_headers, + payload={ + "agent_id": args.agent_id, + "action": args.action, + "target_node_id": args.target_node_id, + }, + ) + unlock_id = unlocked.get("unlock_id", "") + if not unlock_id: + raise SystemExit(4) + if not args.execute: + print(unlock_id) + return + completed = subprocess.run( + [ + CONNECTOR, + "--unlock-id", + unlock_id, + "--principal-id", + args.principal_id, + "--subject-kind", + args.subject_kind, + "--human-anchor", + args.human_anchor, + "--agent-id", + args.agent_id, + "--action", + args.action, + "--target-node-id", + args.target_node_id, + ], + check=False, + ) + raise SystemExit(completed.returncode) + + +if __name__ == "__main__": + main() diff --git a/server-tools/enterprise-lighthouse/aw-hlcc-candidate-deploy b/server-tools/enterprise-lighthouse/aw-hlcc-candidate-deploy new file mode 100755 index 0000000..a814b04 --- /dev/null +++ b/server-tools/enterprise-lighthouse/aw-hlcc-candidate-deploy @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SOURCE_ROOT="/var/tmp/hlcc-deploy-src-20260726" +readonly SOURCE_COMMIT="b03619e359b9c5f5eeae73b15db6b255b9b243f7" +readonly TOOL_ROOT="/usr/local/libexec/guanghu/hlcc-candidate" +readonly PACKAGE_ROOT="/var/tmp/hlcc-offline-20260726" +readonly CONFIG_FILE="${TOOL_ROOT}/candidate-app.enterprise.ini" +readonly STATE_ROOT="/var/lib/guanghu-enterprise-code-channel" +readonly RECEIPT_ROOT="${STATE_ROOT}/receipts" +readonly RECEIPT_FILE="${RECEIPT_ROOT}/AW-GZ-001-HLCC-16.0.1.json" +readonly GUANGHU_GROUP="$(id -gn guanghu)" + +if [[ "$(id -u)" != "0" ]]; then + echo "This fixed connector must be invoked by root." >&2 + exit 2 +fi + +if [[ "$(git -C "${SOURCE_ROOT}" rev-parse HEAD)" != "${SOURCE_COMMIT}" ]]; then + echo "Pinned deployment source commit does not match." >&2 + exit 3 +fi + +if [[ ! -d "${PACKAGE_ROOT}" || ! -f "${CONFIG_FILE}" ]]; then + echo "Verified package staging or pinned candidate configuration is missing." >&2 + exit 3 +fi + +install -d -o guanghu -g "${GUANGHU_GROUP}" -m 0700 "${STATE_ROOT}" + +if ss -ltnH 'sport = :3340' | grep -q .; then + echo "Loopback candidate port 3340 is already in use." >&2 + exit 4 +fi + +chown -R root:"${GUANGHU_GROUP}" "${PACKAGE_ROOT}" +chmod 0750 "${PACKAGE_ROOT}" +find "${PACKAGE_ROOT}" -type f -exec chmod 0640 {} + + +rollback_candidate() { + runuser -u guanghu -- "${TOOL_ROOT}/stop-candidate.sh" || true +} +trap rollback_candidate ERR + +runuser -u guanghu -- "${TOOL_ROOT}/install-candidate.sh" \ + "${PACKAGE_ROOT}" \ + "${CONFIG_FILE}" +runuser -u guanghu -- "${TOOL_ROOT}/start-candidate.sh" + +install -d -o guanghu -g "${GUANGHU_GROUP}" -m 0700 "${RECEIPT_ROOT}" +runuser -u guanghu -- python3 - "${RECEIPT_FILE}" "${SOURCE_COMMIT}" <<'PY' +import json +import sys +from datetime import datetime, timezone + +receipt = { + "schema": "guanghu.agent-execution-receipt/v1", + "agent_id": "AW-HLCC-CANDIDATE-DEPLOYER", + "action": "deploy_release", + "target_node_id": "AW-GZ-001", + "release": "Forgejo 16.0.1", + "source_commit": sys.argv[2], + "listen": "127.0.0.1:3340", + "scope": "isolated-candidate", + "production_cutover": False, + "created_at": datetime.now(timezone.utc).isoformat(), +} +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(receipt, handle, ensure_ascii=False, indent=2) + handle.write("\n") +PY +chmod 0600 "${RECEIPT_FILE}" +trap - ERR + +echo "AW-HLCC-CANDIDATE-DEPLOYER completed the registered deployment action." diff --git a/server-tools/enterprise-lighthouse/fixed_agent_connector.py b/server-tools/enterprise-lighthouse/fixed_agent_connector.py new file mode 100755 index 0000000..cefe32f --- /dev/null +++ b/server-tools/enterprise-lighthouse/fixed_agent_connector.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Consume one lighthouse unlock and execute one compiled-in local connector.""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + + +LIGHTHOUSE_URL = "http://127.0.0.1:8031" +FIXED_CONNECTORS = { + ( + "AW-HLCC-CANDIDATE-DEPLOYER", + "deploy_release", + "AW-GZ-001", + ): "/usr/local/libexec/guanghu/aw-hlcc-candidate-deploy", +} + + +def fail(message, code=2): + print(message, file=sys.stderr) + raise SystemExit(code) + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--unlock-id", required=True) + parser.add_argument("--principal-id", required=True) + parser.add_argument("--subject-kind", required=True, choices=("human", "persona", "persona_system")) + parser.add_argument("--human-anchor", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--action", required=True) + parser.add_argument("--target-node-id", required=True) + return parser.parse_args() + + +def consume_unlock(args, token): + payload = json.dumps( + { + "unlock_id": args.unlock_id, + "agent_id": args.agent_id, + "action": args.action, + "target_node_id": args.target_node_id, + } + ).encode() + request = urllib.request.Request( + LIGHTHOUSE_URL + "/v1/agents/consume", + data=payload, + method="POST", + headers={ + "X-Lighthouse-Admin-Token": token, + "Content-Type": "application/json", + "X-Guanghu-Principal-Id": args.principal_id, + "X-Guanghu-Subject-Kind": args.subject_kind, + "X-Guanghu-Human-Anchor": args.human_anchor, + }, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + body = json.load(response) + except urllib.error.HTTPError as error: + fail("Lighthouse rejected the connector request with HTTP %d." % error.code, 4) + except (OSError, ValueError) as error: + fail("Lighthouse connector request failed: %s" % error, 4) + if not body.get("ok") or not body.get("consumed"): + fail("Lighthouse did not return a consumed Agent unlock.", 4) + + +def main(): + if os.geteuid() != 0: + fail("The fixed connector must be invoked by root.", 3) + args = parse_args() + key = (args.agent_id, args.action, args.target_node_id) + executable = FIXED_CONNECTORS.get(key) + if not executable: + fail("No fixed local connector is registered for this Agent/action/target.", 3) + token = os.environ.get("LIGHTHOUSE_ADMIN_TOKEN", "") + if not token: + fail("LIGHTHOUSE_ADMIN_TOKEN is required but must not be passed on the command line.", 3) + if not os.path.isfile(executable) or not os.access(executable, os.X_OK): + fail("The registered fixed local connector is unavailable.", 3) + consume_unlock(args, token) + completed = subprocess.run([executable], check=False) + raise SystemExit(completed.returncode) + + +if __name__ == "__main__": + main() diff --git a/server-tools/enterprise-lighthouse/lighthouse.py b/server-tools/enterprise-lighthouse/lighthouse.py index 92de053..b528ad2 100644 --- a/server-tools/enterprise-lighthouse/lighthouse.py +++ b/server-tools/enterprise-lighthouse/lighthouse.py @@ -19,7 +19,17 @@ DB = os.environ.get("LIGHTHOUSE_DB", "/var/lib/guanghu-enterprise-lighthouse/lig TOKEN = os.environ.get("LIGHTHOUSE_ADMIN_TOKEN", "") HOST = os.environ.get("LIGHTHOUSE_BIND", "127.0.0.1") PORT = int(os.environ.get("LIGHTHOUSE_PORT", "8031")) +NODE_MAP_PATH = os.environ.get("LIGHTHOUSE_NODE_MAP", "/etc/guanghu/navigation-maps/AW-GZ-001.json") +MAP_ACK_TTL = int(os.environ.get("LIGHTHOUSE_MAP_ACK_TTL", "900")) +INTENT_STATE_TTL = int(os.environ.get("LIGHTHOUSE_INTENT_STATE_TTL", "1800")) +AGENT_UNLOCK_TTL = int(os.environ.get("LIGHTHOUSE_AGENT_UNLOCK_TTL", "600")) FIXED_ACTIONS = {"health_check", "backup", "deploy_release", "restart_service", "rollback"} +REGISTERED_AGENTS = { + "AW-INSPECTOR": {"health_check"}, + "AW-HLCC-CANDIDATE-DEPLOYER": {"health_check", "backup", "deploy_release", "restart_service", "rollback"}, +} +SUBJECT_KINDS = {"human", "persona", "persona_system"} +FORBIDDEN_INTENT_KEYS = {"password", "token", "secret", "private_key", "authorization_code", "credential"} DOMAINS = { "DOMAIN-ZS": "零感域", "DOMAIN-MAIN": "光湖主域", "DOMAIN-SUB": "光湖分域", "DOMAIN-ZERO": "光湖零域", "DOMAIN-FIFTH": "第五域", @@ -57,6 +67,15 @@ def connection(): CREATE TABLE IF NOT EXISTS map_acks ( principal TEXT PRIMARY KEY, map_hash TEXT NOT NULL, acknowledged_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS intent_states ( + principal TEXT PRIMARY KEY, capsule_hash TEXT NOT NULL, capsule TEXT NOT NULL, + restored_at INTEGER NOT NULL, expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS agent_unlocks ( + id TEXT PRIMARY KEY, principal TEXT NOT NULL, agent_id TEXT NOT NULL, + action TEXT NOT NULL, target_node_id TEXT NOT NULL, map_hash TEXT NOT NULL, + capsule_hash TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL + ); """) for domain_id, name in DOMAINS.items(): state = "EXTERNAL_PRIVATE_FOUNDATION" if domain_id in EXTERNAL_FOUNDATION_DOMAINS else "PENDING_ENTRY_NODE" @@ -86,27 +105,123 @@ def require_admin(handler): return bool(TOKEN) and hmac.compare_digest(received, TOKEN) +def load_node_map(): + with open(NODE_MAP_PATH, encoding="utf-8") as handle: + node_map = json.load(handle) + if node_map.get("schema") != "guanghu.navigation-map/v1" or node_map.get("node_id") != "AW-GZ-001": + raise ValueError("AW-GZ-001 navigation map identity is invalid") + return node_map + + def navigation_map(): body = { - "schema": "guanghu.enterprise-navigation-map/v1", + "schema": "guanghu.enterprise-global-navigation-map/v2", + "world_entry": { + "system_id": "SYS-GLW-ZY-EXEC-0001", + "architecture": "GLS-0243", + "rule": "The public Zhuyuan system is an intelligent operations system, not a persona or AI instance.", + "physical_sovereignty_boundary": "Root may remove the system. Any operation that bypasses this gate is outside the Guanghu system and cannot claim a Guanghu receipt.", + }, "node_id": "AW-GZ-001", + "node_map": load_node_map(), "domains": [{"id": key, "name": DOMAINS[key]} for key in sorted(DOMAINS)], "fixed_actions": sorted(FIXED_ACTIONS), - "mandatory_order": ["read-navigation-map", "ack-current-map", "execute-registered-action"], - "forbidden": ["raw-shell", "unregistered-action", "secret-in-repository", "unmapped-human-operation"], + "registered_agents": {agent: sorted(actions) for agent, actions in sorted(REGISTERED_AGENTS.items())}, + "mandatory_order": [ + "read-global-navigation-map", + "ack-current-map-as-current-subject", + "restore-intent-state-capsule", + "unlock-one-registered-agent-for-one-action", + "execute-registered-action", + "write-verification-receipt", + ], + "forbidden": [ + "raw-shell", + "unregistered-action", + "secret-in-repository-or-intent-state", + "shared-map-ack-between-subjects", + "agent-unlock-without-current-intent-state", + "unmapped-human-or-persona-operation", + ], } encoded = json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() return body, hashlib.sha256(encoded).hexdigest() -def admin_principal(): - return hashlib.sha256(TOKEN.encode()).hexdigest() +def request_principal(handler): + principal_id = handler.headers.get("X-Guanghu-Principal-Id", "").strip() + subject_kind = handler.headers.get("X-Guanghu-Subject-Kind", "").strip() + human_anchor = handler.headers.get("X-Guanghu-Human-Anchor", "").strip() + if not principal_id or subject_kind not in SUBJECT_KINDS or not human_anchor: + return None + identity = f"{subject_kind}|{principal_id}|{human_anchor}".encode() + return { + "id": principal_id, + "subject_kind": subject_kind, + "human_anchor": human_anchor, + "digest": hmac.new(TOKEN.encode(), identity, hashlib.sha256).hexdigest(), + } -def has_current_map_ack(db): +def has_current_map_ack(db, principal_digest): _, current_hash = navigation_map() - row = db.execute("SELECT map_hash FROM map_acks WHERE principal=?", (admin_principal(),)).fetchone() - return bool(row and hmac.compare_digest(row["map_hash"], current_hash)) + row = db.execute("SELECT map_hash, acknowledged_at FROM map_acks WHERE principal=?", (principal_digest,)).fetchone() + return bool( + row + and hmac.compare_digest(row["map_hash"], current_hash) + and now() - row["acknowledged_at"] <= MAP_ACK_TTL + ) + + +def has_forbidden_intent_key(value): + if isinstance(value, dict): + return any(str(key).lower() in FORBIDDEN_INTENT_KEYS or has_forbidden_intent_key(item) for key, item in value.items()) + if isinstance(value, list): + return any(has_forbidden_intent_key(item) for item in value) + return False + + +def validate_intent_capsule(payload, principal): + required = { + "schema", "human_anchor", "persona_id", "task_intent", "identity_boundary", + "established_facts", "decisions", "rejected_routes", "authorization_state", + "current_checkpoint", "next_action", "completion_definition", "evidence", + } + if payload.get("schema") != "guanghu.intent-state-capsule/v1" or required.difference(payload): + raise ValueError("a complete guanghu.intent-state-capsule/v1 is required") + if payload["human_anchor"] != principal["human_anchor"]: + raise ValueError("intent-state human anchor does not match the current subject") + if has_forbidden_intent_key(payload): + raise ValueError("credentials and secrets are forbidden in intent state") + for field in ("established_facts", "decisions", "rejected_routes", "completion_definition", "evidence"): + if not isinstance(payload[field], list): + raise ValueError(f"{field} must be a list") + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return encoded, hashlib.sha256(encoded.encode()).hexdigest() + + +def current_intent_state(db, principal_digest): + return db.execute( + "SELECT capsule_hash, expires_at FROM intent_states WHERE principal=? AND expires_at>=?", + (principal_digest, now()), + ).fetchone() + + +def valid_agent_unlock(db, unlock_id, principal_digest, agent_id, action, target_node_id): + _, map_hash = navigation_map() + intent = current_intent_state(db, principal_digest) + if not intent: + return False + row = db.execute( + """SELECT * FROM agent_unlocks + WHERE id=? AND principal=? AND agent_id=? AND action=? AND target_node_id=? AND expires_at>=?""", + (unlock_id, principal_digest, agent_id, action, target_node_id, now()), + ).fetchone() + return bool( + row + and hmac.compare_digest(row["map_hash"], map_hash) + and hmac.compare_digest(row["capsule_hash"], intent["capsule_hash"]) + ) class Handler(BaseHTTPRequestHandler): @@ -128,12 +243,19 @@ class Handler(BaseHTTPRequestHandler): db = connection() try: if self.path == "/health": - return self.respond(200, {"ok": True, "service": "guanghu-enterprise-lighthouse", "mode": "registry-and-preflight-only", "execution": "disabled"}) + try: + _, map_hash = navigation_map() + except (OSError, ValueError, json.JSONDecodeError) as error: + return self.respond(503, {"ok": False, "service": "guanghu-enterprise-lighthouse", "error": f"navigation map unavailable: {error}"}) + return self.respond(200, {"ok": True, "service": "guanghu-enterprise-lighthouse", "mode": "map-intent-agent-gate", "map_hash": map_hash, "execution": "disabled"}) if self.path == "/v1/status": counts = {row["state"]: row["count"] for row in db.execute("SELECT state, COUNT(*) AS count FROM nodes GROUP BY state")} return self.respond(200, {"ok": True, "domains": [dict(row) for row in db.execute("SELECT id,name,state FROM domains ORDER BY id")], "node_counts": counts, "fixed_actions": sorted(FIXED_ACTIONS), "raw_shell": "rejected"}) - if self.path == "/v1/navigation-map": - body, map_hash = navigation_map() + if self.path in {"/v1/navigation-map", "/v2/global-navigation-map"}: + try: + body, map_hash = navigation_map() + except (OSError, ValueError, json.JSONDecodeError) as error: + return self.respond(503, {"ok": False, "error": f"navigation map unavailable: {error}"}) return self.respond(200, {"ok": True, "map_hash": map_hash, "navigation_map": body}) if self.path == "/v1/nodes": return self.respond(200, {"ok": True, "nodes": [dict(row) for row in db.execute("SELECT id,domain_id,state,display_name,allowed_actions,last_heartbeat,created_at FROM nodes ORDER BY created_at DESC")]}) @@ -150,6 +272,9 @@ class Handler(BaseHTTPRequestHandler): return self.respond(400, {"ok": False, "error": str(error)}) if "cmd" in payload or "shell" in payload or "command" in payload: return self.respond(400, {"ok": False, "error": "raw commands are never accepted by the lighthouse"}) + principal = request_principal(self) + if not principal: + return self.respond(400, {"ok": False, "error": "current human or persona identity and human anchor are required"}) db = connection() try: if self.path == "/v1/navigation-map/ack": @@ -157,11 +282,53 @@ class Handler(BaseHTTPRequestHandler): supplied = str(payload.get("map_hash", "")) if not hmac.compare_digest(supplied, current_hash): return self.respond(409, {"ok": False, "error": "navigation map changed; read the current map again"}) - db.execute("INSERT OR REPLACE INTO map_acks VALUES (?, ?, ?)", (admin_principal(), current_hash, now())) - audit(db, "navigation_map_acknowledged", {"map_hash": current_hash}) - return self.respond(200, {"ok": True, "map_hash": current_hash}) - if not has_current_map_ack(db): + db.execute("INSERT OR REPLACE INTO map_acks VALUES (?, ?, ?)", (principal["digest"], current_hash, now())) + audit(db, "navigation_map_acknowledged", {"map_hash": current_hash, "principal_id": principal["id"], "subject_kind": principal["subject_kind"]}) + return self.respond(200, {"ok": True, "map_hash": current_hash, "expires_in": MAP_ACK_TTL}) + if not has_current_map_ack(db, principal["digest"]): return self.respond(423, {"ok": False, "error": "current navigation map must be read and acknowledged before any operation", "required": ["GET /v1/navigation-map", "POST /v1/navigation-map/ack"]}) + if self.path == "/v1/intent-state/restore": + try: + encoded, capsule_hash = validate_intent_capsule(payload, principal) + except ValueError as error: + return self.respond(400, {"ok": False, "error": str(error)}) + restored_at = now() + db.execute( + "INSERT OR REPLACE INTO intent_states VALUES (?, ?, ?, ?, ?)", + (principal["digest"], capsule_hash, encoded, restored_at, restored_at + INTENT_STATE_TTL), + ) + audit(db, "intent_state_restored", {"principal_id": principal["id"], "capsule_hash": capsule_hash}) + return self.respond(200, {"ok": True, "capsule_hash": capsule_hash, "expires_in": INTENT_STATE_TTL}) + if self.path == "/v1/agents/unlock": + agent_id = str(payload.get("agent_id", "")) + action = str(payload.get("action", "")) + target_node_id = str(payload.get("target_node_id", "")) + intent = current_intent_state(db, principal["digest"]) + if not intent: + return self.respond(423, {"ok": False, "error": "current intent state must be restored before an Agent can unlock"}) + if agent_id not in REGISTERED_AGENTS or action not in REGISTERED_AGENTS[agent_id] or target_node_id != "AW-GZ-001": + return self.respond(403, {"ok": False, "error": "Agent, action, or target is not registered"}) + _, map_hash = navigation_map() + unlock_id = "UNLOCK-" + uuid.uuid4().hex.upper() + created_at = now() + db.execute( + "INSERT INTO agent_unlocks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (unlock_id, principal["digest"], agent_id, action, target_node_id, map_hash, intent["capsule_hash"], created_at, created_at + AGENT_UNLOCK_TTL), + ) + audit(db, "agent_unlocked", {"principal_id": principal["id"], "agent_id": agent_id, "action": action, "target_node_id": target_node_id, "unlock_id": unlock_id}) + return self.respond(200, {"ok": True, "unlock_id": unlock_id, "agent_id": agent_id, "action": action, "target_node_id": target_node_id, "expires_in": AGENT_UNLOCK_TTL}) + if self.path == "/v1/agents/consume": + agent_id = str(payload.get("agent_id", "")) + action = str(payload.get("action", "")) + target_node_id = str(payload.get("target_node_id", "")) + unlock_id = str(payload.get("unlock_id", "")) + if not valid_agent_unlock(db, unlock_id, principal["digest"], agent_id, action, target_node_id): + return self.respond(423, {"ok": False, "error": "a current matching Agent unlock is required"}) + db.execute("DELETE FROM agent_unlocks WHERE id=?", (unlock_id,)) + audit(db, "agent_unlock_consumed", {"principal_id": principal["id"], "agent_id": agent_id, "action": action, "target_node_id": target_node_id, "unlock_id": unlock_id}) + return self.respond(200, {"ok": True, "unlock_id": unlock_id, "consumed": True, "execution": "not performed by the lighthouse; pass this receipt to the fixed local connector"}) + if not current_intent_state(db, principal["digest"]): + return self.respond(423, {"ok": False, "error": "current intent state must be restored before any operation"}) if self.path == "/v1/intakes": required = ("human_name", "email", "server_ip", "domain_id") if any(not payload.get(field) for field in required) or payload["domain_id"] not in DOMAINS or not valid_email(payload["email"]): @@ -225,6 +392,10 @@ class Handler(BaseHTTPRequestHandler): action, node_id = payload.get("action"), payload.get("target_node_id") if action not in FIXED_ACTIONS or not node_id: return self.respond(400, {"ok": False, "error": "fixed action and target_node_id are required", "allowed_actions": sorted(FIXED_ACTIONS)}) + agent_id = str(payload.get("agent_id", "")) + unlock_id = str(payload.get("unlock_id", "")) + if not valid_agent_unlock(db, unlock_id, principal["digest"], agent_id, action, node_id): + return self.respond(423, {"ok": False, "decision": "REJECT", "reason": "a current matching Agent unlock is required"}) node = db.execute("SELECT * FROM nodes WHERE id=?", (node_id,)).fetchone() if not node: return self.respond(404, {"ok": False, "decision": "REJECT", "reason": "target node is not registered"}) diff --git a/server-tools/enterprise-lighthouse/test_lighthouse.py b/server-tools/enterprise-lighthouse/test_lighthouse.py index e71ce38..79a2f5d 100644 --- a/server-tools/enterprise-lighthouse/test_lighthouse.py +++ b/server-tools/enterprise-lighthouse/test_lighthouse.py @@ -5,42 +5,145 @@ import subprocess import sys import tempfile import time +import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).parent +NODE_MAP = ROOT.parents[1] / "deployment" / "navigation-maps" / "AW-GZ-001.json" +BASE = "http://127.0.0.1:48031" +HEADERS = { + "X-Lighthouse-Admin-Token": "test-token", + "X-Guanghu-Principal-Id": "ICE-GL-ZY001:TEST-INSTANCE", + "X-Guanghu-Subject-Kind": "persona", + "X-Guanghu-Human-Anchor": "ICE-GL-INF", +} + + +def post(path, payload, headers=HEADERS): + request = urllib.request.Request( + BASE + path, + data=json.dumps(payload).encode(), + method="POST", + headers=headers, + ) + return json.load(urllib.request.urlopen(request)) + + +def expect_error(code, path, payload, headers=HEADERS): + try: + post(path, payload, headers) + except urllib.error.HTTPError as error: + assert error.code == code, (error.code, json.load(error)) + return json.load(error) + raise AssertionError(f"{path} unexpectedly succeeded") + + with tempfile.TemporaryDirectory() as temp: - env = {**os.environ, "LIGHTHOUSE_DB": f"{temp}/lighthouse.db", "LIGHTHOUSE_ADMIN_TOKEN": "test-token", "LIGHTHOUSE_PORT": "48031"} - process = subprocess.Popen([sys.executable, "lighthouse.py"], cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + env = { + **os.environ, + "LIGHTHOUSE_DB": f"{temp}/lighthouse.db", + "LIGHTHOUSE_ADMIN_TOKEN": "test-token", + "LIGHTHOUSE_PORT": "48031", + "LIGHTHOUSE_NODE_MAP": str(NODE_MAP), + } + process = subprocess.Popen( + [sys.executable, "lighthouse.py"], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) try: for _ in range(30): try: - assert json.load(urllib.request.urlopen("http://127.0.0.1:48031/health", timeout=1))["execution"] == "disabled" + health = json.load(urllib.request.urlopen(BASE + "/health", timeout=1)) + assert health["execution"] == "disabled" + assert health["mode"] == "map-intent-agent-gate" break except OSError: time.sleep(.1) - else: raise AssertionError("server did not start") - locked = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=json.dumps({"human_name":"Test","email":"test@example.invalid","server_ip":"203.0.113.8","domain_id":"DOMAIN-ZS"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - try: urllib.request.urlopen(locked) - except urllib.error.HTTPError as error: assert error.code == 423 - else: raise AssertionError("mutation was allowed before navigation map acknowledgement") - nav = json.load(urllib.request.urlopen("http://127.0.0.1:48031/v1/navigation-map")) - ack = urllib.request.Request("http://127.0.0.1:48031/v1/navigation-map/ack", data=json.dumps({"map_hash":nav["map_hash"]}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - assert json.load(urllib.request.urlopen(ack))["ok"] is True - request = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=json.dumps({"human_name":"Test","email":"test@example.invalid","server_ip":"203.0.113.8","domain_id":"DOMAIN-ZS"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - assert json.load(urllib.request.urlopen(request))["state"] == "PENDING_REVIEW" - bootstrap = urllib.request.Request("http://127.0.0.1:48031/v1/nodes/bootstrap", data=json.dumps({"id":"NODE-TEST-001","domain_id":"DOMAIN-ZS","display_name":"Test entry","server_ip":"203.0.113.8"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - assert json.load(urllib.request.urlopen(bootstrap))["state"] == "CONNECTED_PENDING_CONNECTOR" - preflight = urllib.request.Request("http://127.0.0.1:48031/v1/preflight", data=json.dumps({"action":"health_check","target_node_id":"NODE-TEST-001"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - assert json.load(urllib.request.urlopen(preflight))["decision"] == "REJECT" - fifth = urllib.request.Request("http://127.0.0.1:48031/v1/nodes/bootstrap", data=json.dumps({"id":"NODE-FIFTH-001","domain_id":"DOMAIN-FIFTH","display_name":"Not allowed","server_ip":"203.0.113.8"}).encode(), method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - try: urllib.request.urlopen(fifth) - except urllib.error.HTTPError as error: assert error.code == 403 - else: raise AssertionError("private fifth domain was accepted by enterprise lighthouse") - bad = urllib.request.Request("http://127.0.0.1:48031/v1/intakes", data=b'{"cmd":"rm -rf /"}', method="POST", headers={"X-Lighthouse-Admin-Token":"test-token"}) - try: urllib.request.urlopen(bad) - except urllib.error.HTTPError as error: assert error.code == 400 - else: raise AssertionError("raw command was not rejected") + else: + raise AssertionError("server did not start") + + intake = { + "human_name": "Test", + "email": "test@example.invalid", + "server_ip": "203.0.113.8", + "domain_id": "DOMAIN-ZS", + } + expect_error(423, "/v1/intakes", intake) + + nav = json.load(urllib.request.urlopen(BASE + "/v2/global-navigation-map")) + assert nav["navigation_map"]["schema"] == "guanghu.enterprise-global-navigation-map/v2" + assert nav["navigation_map"]["node_map"]["node_id"] == "AW-GZ-001" + assert post("/v1/navigation-map/ack", {"map_hash": nav["map_hash"]})["ok"] is True + + expect_error(423, "/v1/intakes", intake) + capsule = { + "schema": "guanghu.intent-state-capsule/v1", + "human_anchor": "ICE-GL-INF", + "persona_id": "ICE-GL-ZY001", + "task_intent": "Test the enterprise navigation and Agent gate.", + "identity_boundary": "The public operations system is not a persona.", + "established_facts": ["AW map is current."], + "decisions": ["Use only a registered Agent."], + "rejected_routes": ["raw shell"], + "authorization_state": "test-only", + "current_checkpoint": "map acknowledged", + "next_action": "unlock inspector", + "completion_definition": ["preflight is gated"], + "evidence": ["deployment/navigation-maps/AW-GZ-001.json"], + } + restored = post("/v1/intent-state/restore", capsule) + assert restored["ok"] is True + assert json.load(urllib.request.urlopen(urllib.request.Request( + BASE + "/v1/intakes", + data=json.dumps(intake).encode(), + method="POST", + headers=HEADERS, + )))["state"] == "PENDING_REVIEW" + + bootstrap = post("/v1/nodes/bootstrap", { + "id": "AW-GZ-001", + "domain_id": "DOMAIN-ZS", + "display_name": "Enterprise root node", + "server_ip": "203.0.113.8", + }) + assert bootstrap["state"] == "CONNECTED_PENDING_CONNECTOR" + + preflight = {"action": "health_check", "target_node_id": "AW-GZ-001", "agent_id": "AW-INSPECTOR"} + expect_error(423, "/v1/preflight", preflight) + unlocked = post("/v1/agents/unlock", { + "agent_id": "AW-INSPECTOR", + "action": "health_check", + "target_node_id": "AW-GZ-001", + }) + preflight["unlock_id"] = unlocked["unlock_id"] + assert post("/v1/preflight", preflight)["decision"] == "REJECT" + assert post("/v1/agents/consume", { + **preflight, + "unlock_id": unlocked["unlock_id"], + })["consumed"] is True + expect_error(423, "/v1/preflight", preflight) + + expect_error(403, "/v1/nodes/bootstrap", { + "id": "NODE-FIFTH-001", + "domain_id": "DOMAIN-FIFTH", + "display_name": "Not allowed", + "server_ip": "203.0.113.8", + }) + expect_error(400, "/v1/intakes", {"cmd": "unsafe"}) + + other_headers = { + **HEADERS, + "X-Guanghu-Principal-Id": "ANOTHER-PERSONA:TEST", + } + expect_error(423, "/v1/intent-state/restore", capsule, other_headers) + assert post("/v1/navigation-map/ack", {"map_hash": nav["map_hash"]}, other_headers)["ok"] is True + expect_error(400, "/v1/intent-state/restore", {**capsule, "token": "forbidden"}, other_headers) finally: - process.terminate(); process.wait(timeout=5) + process.terminate() + process.wait(timeout=5) + print("enterprise lighthouse tests passed") diff --git a/server-tools/hololake-code-channel/candidate-app.enterprise.ini b/server-tools/hololake-code-channel/candidate-app.enterprise.ini index 35775b0..6f4d115 100644 --- a/server-tools/hololake-code-channel/candidate-app.enterprise.ini +++ b/server-tools/hololake-code-channel/candidate-app.enterprise.ini @@ -4,10 +4,10 @@ RUN_MODE = prod [database] DB_TYPE = sqlite3 -PATH = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/hlcc.db +PATH = /var/lib/guanghu-enterprise-code-channel/candidates/hlcc-v16.0.1/data/hlcc.db [repository] -ROOT = /var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1/data/repositories +ROOT = /var/lib/guanghu-enterprise-code-channel/candidates/hlcc-v16.0.1/data/repositories [server] DOMAIN = 127.0.0.1 diff --git a/server-tools/hololake-code-channel/install-candidate.sh b/server-tools/hololake-code-channel/install-candidate.sh index bae0dea..5e738b1 100755 --- a/server-tools/hololake-code-channel/install-candidate.sh +++ b/server-tools/hololake-code-channel/install-candidate.sh @@ -2,8 +2,9 @@ set -euo pipefail readonly HLCC_VERSION="16.0.1" -readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v${HLCC_VERSION}" -readonly HLCC_SOURCE_ARCHIVE="/var/lib/guanghu/code-channel/offline-source/hlcc-v${HLCC_VERSION}" +readonly HLCC_STATE_ROOT="/var/lib/guanghu-enterprise-code-channel" +readonly HLCC_ROOT="${HLCC_STATE_ROOT}/candidates/hlcc-v${HLCC_VERSION}" +readonly HLCC_SOURCE_ARCHIVE="${HLCC_STATE_ROOT}/offline-source/hlcc-v${HLCC_VERSION}" readonly HLCC_RELEASE_KEY="EB114F5E6C0DC2BCDD183550A4B61A2DC5923710" readonly HLCC_PACKAGE_SOURCE="${1:-}" readonly HLCC_CONFIG_SOURCE="${2:-}" diff --git a/server-tools/hololake-code-channel/start-candidate.sh b/server-tools/hololake-code-channel/start-candidate.sh index 90b7cde..22d738f 100755 --- a/server-tools/hololake-code-channel/start-candidate.sh +++ b/server-tools/hololake-code-channel/start-candidate.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1" +readonly HLCC_ROOT="/var/lib/guanghu-enterprise-code-channel/candidates/hlcc-v16.0.1" readonly HLCC_PID_FILE="${HLCC_ROOT}/hlcc.pid" readonly HLCC_LOG_FILE="${HLCC_ROOT}/logs/hlcc.log" @@ -39,6 +39,6 @@ if ! kill -0 "${candidate_pid}" 2>/dev/null; then fi curl --fail --silent --show-error \ - "http://127.0.0.1:3340/api/v1/version" + "http://127.0.0.1:3340/api/healthz" echo echo "HLCC isolated candidate is running on loopback only." diff --git a/server-tools/hololake-code-channel/stop-candidate.sh b/server-tools/hololake-code-channel/stop-candidate.sh index a007f14..9a15b93 100755 --- a/server-tools/hololake-code-channel/stop-candidate.sh +++ b/server-tools/hololake-code-channel/stop-candidate.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -readonly HLCC_ROOT="/var/lib/guanghu/code-channel/candidates/hlcc-v16.0.1" +readonly HLCC_ROOT="/var/lib/guanghu-enterprise-code-channel/candidates/hlcc-v16.0.1" readonly HLCC_PID_FILE="${HLCC_ROOT}/hlcc.pid" if [[ "$(id -un)" != "guanghu" ]]; then