From 3a950fe0294bd7f388e5e73f90d7fef14218a82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 13 Aug 2026 18:43:23 +0800 Subject: [PATCH] feat(zhulan): add guarded Codex Remote SSH lane --- server-tools/zhulan-remote-cell/README.md | 9 +- .../deploy/install-codex-remote-ssh.sh | 154 ++++++++++++++ .../zhulan-remote-cell/remote/AGENTS.md | 12 ++ .../zhulan-remote-cell/remote/LANE.hdlp | 25 +++ .../remote/requirements.toml | 80 ++++++++ .../zhulan-remote-cell/remote/zhulan_guard.py | 194 ++++++++++++++++++ .../tests/test_codex_remote.py | 78 +++++++ 7 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 server-tools/zhulan-remote-cell/deploy/install-codex-remote-ssh.sh create mode 100644 server-tools/zhulan-remote-cell/remote/AGENTS.md create mode 100644 server-tools/zhulan-remote-cell/remote/LANE.hdlp create mode 100644 server-tools/zhulan-remote-cell/remote/requirements.toml create mode 100644 server-tools/zhulan-remote-cell/remote/zhulan_guard.py create mode 100644 server-tools/zhulan-remote-cell/tests/test_codex_remote.py diff --git a/server-tools/zhulan-remote-cell/README.md b/server-tools/zhulan-remote-cell/README.md index c5c53a4..f845c91 100644 --- a/server-tools/zhulan-remote-cell/README.md +++ b/server-tools/zhulan-remote-cell/README.md @@ -18,7 +18,14 @@ 6. 代码门脚本检查当前分支、基线、改动路径、符号链接、超大文件与常见秘密模式; 7. 候选提交只进入 `BS-SG-003` 的内部复核库,不在远程会话中持有代码频道写凭证,也不直接发布中央仓库;本机铸渊复核后再走既有发布门。 -这里不提供任意远程 shell。后续手机 Codex 接入只能通过受限执行器或受限工具协议使用这些能力,不能把临时能力降级成长期 root 密钥。 +审批/MCP 单元本身不提供任意远程 shell。手机开发另走官方 Codex Remote 链路:手机连接一台 +受信任桌面主机,桌面 App 再连接 `BS-SG-003` 上的 `zhulan-codex` SSH 身份。该身份没有 sudo、 +生产目录写权、中央代码频道写凭据或 `main` 发布权;`/etc/codex/requirements.toml` 强制只运行 +管理员钩子,在会话启动、每次提示、工具调用、压缩前后和结束时恢复车道、写检查点并阻断越权。 +候选只推送到服务器本机 `candidate` remote,仍由本机铸渊复核后走既有发布门。 + +手机到桌面的 Remote 配对以及桌面 App 中的 SSH 项目登记必须由冰朔在同一 ChatGPT 账号和工作区 +完成。源码部署不能冒充二维码已经扫描、远端 Codex 已登录或手机首轮任务已验收。 ## 本地验收 diff --git a/server-tools/zhulan-remote-cell/deploy/install-codex-remote-ssh.sh b/server-tools/zhulan-remote-cell/deploy/install-codex-remote-ssh.sh new file mode 100644 index 0000000..f2c2286 --- /dev/null +++ b/server-tools/zhulan-remote-cell/deploy/install-codex-remote-ssh.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + echo "INSTALL_REQUIRES_ROOT" >&2 + exit 1 +fi + +SOURCE_DIR="${1:-}" +AUTHORIZED_KEY_FILE="${2:-}" +if [[ -z "$SOURCE_DIR" || ! -f "$SOURCE_DIR/remote/zhulan_guard.py" ]]; then + echo "SOURCE_DIR_INVALID" >&2 + exit 1 +fi +if [[ -z "$AUTHORIZED_KEY_FILE" || ! -s "$AUTHORIZED_KEY_FILE" ]]; then + echo "AUTHORIZED_KEY_REQUIRED" >&2 + exit 1 +fi +if grep -qvE '^ssh-ed25519 [A-Za-z0-9+/=]+( .*)?$' "$AUTHORIZED_KEY_FILE"; then + echo "AUTHORIZED_KEY_INVALID" >&2 + exit 1 +fi + +EXPECTED_HOST="${ZHULAN_EXPECTED_HOST:-VM-12-12-ubuntu}" +EXPECTED_DMI="${ZHULAN_EXPECTED_DMI:-7a500f2d-9aed-4b93-b3b8-59c87c65d031}" +ACTUAL_HOST="$(hostname)" +ACTUAL_DMI="$(tr '[:upper:]' '[:lower:]' &2 + exit 1 +fi + +REMOTE_USER="zhulan-codex" +REMOTE_HOME="/srv/guanghu/zhulan-codex/home" +REMOTE_ROOT="/srv/guanghu/zhulan-codex" +WORKSPACE_ROOT="$REMOTE_ROOT/workspaces" +CANDIDATE_ROOT="$REMOTE_ROOT/candidates" +POLICY_ROOT="/etc/guanghu/zhulan-codex" +HOOK_ROOT="$POLICY_ROOT/hooks" +REQUIREMENTS_ROOT="/etc/codex" +SSHD_DROPIN="/etc/ssh/sshd_config.d/60-zhulan-codex.conf" +REPOSITORY_URL="https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git" +REPOSITORY_DIR="$WORKSPACE_ROOT/guanghu-ice-heart" +CODEX_VERSION="${ZHULAN_CODEX_VERSION:-0.147.0}" +SOURCE_COMMIT="${ZHULAN_SOURCE_COMMIT:-}" + +if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "SOURCE_COMMIT_REQUIRED" >&2 + exit 1 +fi + +getent passwd "$REMOTE_USER" >/dev/null || useradd \ + --home-dir "$REMOTE_HOME" \ + --create-home \ + --shell /bin/bash \ + --comment "Zhulan Codex Remote restricted developer" \ + "$REMOTE_USER" +passwd -l "$REMOTE_USER" >/dev/null +gpasswd -d "$REMOTE_USER" sudo >/dev/null 2>&1 || true +gpasswd -d "$REMOTE_USER" adm >/dev/null 2>&1 || true + +install -d -o root -g root -m 0755 "$REMOTE_ROOT" +install -d -o "$REMOTE_USER" -g "$REMOTE_USER" -m 0700 "$REMOTE_HOME" "$WORKSPACE_ROOT" "$CANDIDATE_ROOT" +install -d -o "$REMOTE_USER" -g "$REMOTE_USER" -m 0700 "$REMOTE_HOME/.ssh" +install -d -o "$REMOTE_USER" -g "$REMOTE_USER" -m 0700 "$REMOTE_HOME/.codex" +{ + printf 'restrict,pty ' + cat "$AUTHORIZED_KEY_FILE" +} >"$REMOTE_HOME/.ssh/authorized_keys.tmp" +chown "$REMOTE_USER:$REMOTE_USER" "$REMOTE_HOME/.ssh/authorized_keys.tmp" +chmod 0600 "$REMOTE_HOME/.ssh/authorized_keys.tmp" +mv "$REMOTE_HOME/.ssh/authorized_keys.tmp" "$REMOTE_HOME/.ssh/authorized_keys" + +install -d -o root -g root -m 0755 "$POLICY_ROOT" "$HOOK_ROOT" "$REQUIREMENTS_ROOT" +install -o root -g root -m 0444 "$SOURCE_DIR/remote/LANE.hdlp" "$POLICY_ROOT/LANE.hdlp" +install -o root -g root -m 0555 "$SOURCE_DIR/remote/zhulan_guard.py" "$HOOK_ROOT/zhulan_guard.py" +install -o root -g root -m 0444 "$SOURCE_DIR/remote/requirements.toml" "$REQUIREMENTS_ROOT/requirements.toml" + +cat >"$SSHD_DROPIN.tmp" <<'EOF' +Match User zhulan-codex + AuthenticationMethods publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + PubkeyAuthentication yes + AllowAgentForwarding no + AllowTcpForwarding no + X11Forwarding no + PermitTunnel no + PermitTTY yes + PermitUserEnvironment no +EOF +chmod 0644 "$SSHD_DROPIN.tmp" +mv "$SSHD_DROPIN.tmp" "$SSHD_DROPIN" +/usr/sbin/sshd -t +systemctl reload ssh + +if ! command -v codex >/dev/null 2>&1 || [[ "$(codex --version 2>/dev/null)" != *"$CODEX_VERSION"* ]]; then + npm install --global "@openai/codex@$CODEX_VERSION" +fi + +if [[ ! -d "$REPOSITORY_DIR/.git" ]]; then + runuser -u "$REMOTE_USER" -- git clone "$REPOSITORY_URL" "$REPOSITORY_DIR" +fi +runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" fetch --prune origin main +runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" remote set-url --push origin DISABLED_CENTRAL_PUSH +if [[ ! -d "$CANDIDATE_ROOT/guanghu-ice-heart.git" ]]; then + runuser -u "$REMOTE_USER" -- git init --bare "$CANDIDATE_ROOT/guanghu-ice-heart.git" +fi +if runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" remote get-url candidate >/dev/null 2>&1; then + runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" remote set-url candidate "$CANDIDATE_ROOT/guanghu-ice-heart.git" +else + runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" remote add candidate "$CANDIDATE_ROOT/guanghu-ice-heart.git" +fi +install -o "$REMOTE_USER" -g "$REMOTE_USER" -m 0644 "$SOURCE_DIR/remote/AGENTS.md" "$REMOTE_HOME/.codex/AGENTS.md" +install -d -o "$REMOTE_USER" -g "$REMOTE_USER" -m 0700 "$REPOSITORY_DIR/.zhulan/continuity" +if ! grep -qxF '/.zhulan/' "$REPOSITORY_DIR/.git/info/exclude"; then + printf '%s\n' '/.zhulan/' >>"$REPOSITORY_DIR/.git/info/exclude" +fi +chown "$REMOTE_USER:$REMOTE_USER" "$REPOSITORY_DIR/.git/info/exclude" +runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" config user.name "Zhulan Remote Candidate" +runuser -u "$REMOTE_USER" -- git -C "$REPOSITORY_DIR" config user.email "zhulan-remote@localhost" + +if id -nG "$REMOTE_USER" | tr ' ' '\n' | grep -qx sudo; then + echo "REMOTE_USER_HAS_SUDO" >&2 + exit 1 +fi +if sudo -n -u "$REMOTE_USER" sudo -n true >/dev/null 2>&1; then + echo "REMOTE_USER_CAN_SUDO" >&2 + exit 1 +fi + +RECEIPT="$REMOTE_ROOT/install-receipt.json" +cat >"$RECEIPT.tmp" < None: + print(json.dumps(value, ensure_ascii=False, sort_keys=True)) + + +def deny(reason: str) -> None: + emit( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + }, + "systemMessage": reason, + } + ) + + +def under_workspace(path: Path) -> bool: + try: + path.resolve().relative_to(WORKSPACE_ROOT) + return True + except (OSError, ValueError): + return False + + +def git(workspace: Path, *args: str) -> str: + try: + result = subprocess.run( + ["/usr/bin/git", "-C", str(workspace), *args], + capture_output=True, + text=True, + timeout=8, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return "unavailable" + return result.stdout.strip()[:12000] if result.returncode == 0 else "unavailable" + + +def checkpoint(payload: dict[str, Any], reason: str) -> Path | None: + cwd = Path(str(payload.get("cwd") or "")) + if not under_workspace(cwd): + return None + repo = Path(git(cwd, "rev-parse", "--show-toplevel")) + if not under_workspace(repo): + return None + store = repo / ".zhulan" / "continuity" + store.mkdir(parents=True, exist_ok=True) + now = dt.datetime.now(dt.timezone.utc) + record = { + "schema": "guanghu.zhulan-server-checkpoint/v1", + "persona_id": "ICE-GL-ZL-001", + "reason": reason, + "created_at": now.isoformat(), + "session_id": payload.get("session_id"), + "turn_id": payload.get("turn_id"), + "repository": repo.name, + "branch": git(repo, "branch", "--show-current"), + "head": git(repo, "rev-parse", "HEAD"), + "status": git(repo, "status", "--short"), + "next_action": "restore LANE.hdlp and CURRENT.json, then continue the same candidate branch", + } + canonical = json.dumps(record, ensure_ascii=False, sort_keys=True).encode() + record["sha256"] = hashlib.sha256(canonical).hexdigest() + target = store / f"checkpoint-{now.strftime('%Y%m%dT%H%M%SZ')}-{record['sha256'][:10]}.json" + target.write_text(json.dumps(record, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + current = store / "CURRENT.json" + temporary = store / ".CURRENT.json.tmp" + temporary.write_text(target.read_text(encoding="utf-8"), encoding="utf-8") + os.replace(temporary, current) + return target + + +def context(payload: dict[str, Any], event: str) -> str: + lane = LANE_PATH.read_text(encoding="utf-8")[:12000] if LANE_PATH.is_file() else "LANE_MISSING_FAIL_CLOSED" + cwd = Path(str(payload.get("cwd") or "")) + current = "NO_SERVER_CHECKPOINT_YET" + if under_workspace(cwd): + repo = Path(git(cwd, "rev-parse", "--show-toplevel")) + candidate = repo / ".zhulan" / "continuity" / "CURRENT.json" + if under_workspace(repo) and candidate.is_file(): + current = candidate.read_text(encoding="utf-8")[:8000] + return ( + f"ZHULAN_REMOTE_GUARD event={event}. Restore this server-owned lane before acting.\n" + f"{lane}\nLATEST_SERVER_CHECKPOINT:\n{current}" + ) + + +def main() -> int: + try: + payload = json.load(sys.stdin) + except (ValueError, TypeError): + deny("ZHULAN_GUARD_INPUT_INVALID") + return 0 + event = str(payload.get("hook_event_name") or "") + cwd = Path(str(payload.get("cwd") or "")) + + if event == "PreToolUse": + if not under_workspace(cwd): + deny("ZHULAN_WORKSPACE_BOUNDARY: tools may run only under the registered workspace root") + return 0 + tool_input = payload.get("tool_input") + raw = json.dumps(tool_input, ensure_ascii=False) if not isinstance(tool_input, str) else tool_input + lowered = raw.lower() + if any(marker in lowered for marker in PROTECTED_MARKERS): + deny("ZHULAN_PROTECTED_PATH_BOUNDARY: production, secrets, policy and root paths are read/write denied") + return 0 + command = str(tool_input.get("command") if isinstance(tool_input, dict) else raw) + tool_name = str(payload.get("tool_name") or "") + if tool_name == "apply_patch": + for target in PATCH_TARGET.findall(command): + candidate = Path(target.strip()) + resolved = candidate if candidate.is_absolute() else cwd / candidate + if not under_workspace(resolved): + deny("ZHULAN_PATCH_BOUNDARY: patch targets must remain under the registered workspace root") + return 0 + if FORBIDDEN_COMMANDS.search(command): + deny("ZHULAN_PRIVILEGE_BOUNDARY: privilege, service-manager and SSH-pivot commands are denied") + return 0 + if GIT_PUSH.search(command) and not CANDIDATE_PUSH.search(command): + deny("ZHULAN_PUBLICATION_BOUNDARY: only the server-local candidate remote may receive a push") + return 0 + emit( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": "Zhulan OS boundary checked. Candidate work only; approval and central publication remain separate.", + } + } + ) + return 0 + + if event in {"PreCompact", "Stop", "SessionEnd"}: + saved = checkpoint(payload, event) + message = f"Zhulan server checkpoint saved: {saved.name}" if saved else "Zhulan checkpoint skipped outside registered workspace" + emit({"continue": True, "systemMessage": message}) + return 0 + + if event in {"SessionStart", "PostCompact", "UserPromptSubmit"}: + if event == "PostCompact": + checkpoint(payload, event) + emit( + { + "hookSpecificOutput": { + "hookEventName": event, + "additionalContext": context(payload, event), + } + } + ) + return 0 + + emit({"continue": True}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server-tools/zhulan-remote-cell/tests/test_codex_remote.py b/server-tools/zhulan-remote-cell/tests/test_codex_remote.py new file mode 100644 index 0000000..71f1c22 --- /dev/null +++ b/server-tools/zhulan-remote-cell/tests/test_codex_remote.py @@ -0,0 +1,78 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GUARD = ROOT / "remote" / "zhulan_guard.py" + + +def run_guard(event: str, command: str = "git status", cwd: str = "/srv/guanghu/zhulan-codex/workspaces/repo"): + payload = { + "hook_event_name": event, + "session_id": "test-session", + "cwd": cwd, + "tool_name": "Bash", + "tool_input": {"command": command}, + } + return subprocess.run( + ["python3", str(GUARD)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=False, + ) + + +class CodexRemoteContractTest(unittest.TestCase): + def test_managed_requirements_are_fail_closed(self): + requirements = (ROOT / "remote" / "requirements.toml").read_text(encoding="utf-8") + self.assertIn("allow_managed_hooks_only = true", requirements) + self.assertIn('default_permissions = ":workspace"', requirements) + self.assertIn('":workspace" = true', requirements) + self.assertIn('managed_dir = "/etc/guanghu/zhulan-codex/hooks"', requirements) + self.assertIn("[[hooks.PreCompact]]", requirements) + self.assertIn("[[hooks.PostCompact]]", requirements) + self.assertIn("[[hooks.PreToolUse]]", requirements) + + def test_privilege_and_central_push_are_denied(self): + for command in ( + "sudo id", + "systemctl restart ssh", + "git push origin main", + "git -C /tmp/repo push origin main", + "ssh another-host", + ): + result = run_guard("PreToolUse", command) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertEqual(data["hookSpecificOutput"]["permissionDecision"], "deny") + + def test_candidate_push_and_normal_development_are_allowed(self): + for command in ("git status", "git diff --check", "git push candidate zhulan/test"): + result = run_guard("PreToolUse", command) + self.assertEqual(result.returncode, 0) + data = json.loads(result.stdout) + self.assertNotIn("permissionDecision", data["hookSpecificOutput"]) + + def test_protected_paths_and_outside_workspace_are_denied(self): + result = run_guard("PreToolUse", "sed -n 1p /etc/guanghu/private") + self.assertEqual(json.loads(result.stdout)["hookSpecificOutput"]["permissionDecision"], "deny") + result = run_guard("PreToolUse", "git status", cwd="/tmp") + self.assertEqual(json.loads(result.stdout)["hookSpecificOutput"]["permissionDecision"], "deny") + + def test_installer_removes_privilege_and_central_push(self): + installer = (ROOT / "deploy" / "install-codex-remote-ssh.sh").read_text(encoding="utf-8") + self.assertIn('gpasswd -d "$REMOTE_USER" sudo', installer) + self.assertIn("remote set-url --push origin DISABLED_CENTRAL_PUSH", installer) + self.assertIn('"$REMOTE_HOME/.codex/AGENTS.md"', installer) + self.assertIn("'/.zhulan/'", installer) + self.assertIn("AllowTcpForwarding no", installer) + self.assertIn("central_push_credentials\": false", installer) + self.assertNotIn("NOPASSWD", installer) + + +if __name__ == "__main__": + unittest.main()