diff --git a/docs/casan/CASAN_INSTALL_HYBRID.md b/docs/casan/CASAN_INSTALL_HYBRID.md index 471ea01..d3f9bf9 100644 --- a/docs/casan/CASAN_INSTALL_HYBRID.md +++ b/docs/casan/CASAN_INSTALL_HYBRID.md @@ -216,6 +216,14 @@ casan doctor --client codex casan doctor --client vscode-copilot ``` +Với Codex, `doctor` đọc trạng thái hiệu lực bằng API `hooks/list` của chính +`codex app-server`. Nếu toàn bộ CASAN hook của project đang `trusted`, kết quả +là `READY` và không yêu cầu Allow lại. Action `/hooks` chỉ xuất hiện khi hook +thực sự `untrusted`/`modified`, bị tắt, hoặc máy hiện tại không thể xác minh +trust state. Hash toàn file `.codex/hooks.json` trong `init-manifest.json` là +checksum ownership phục vụ uninstall an toàn; nó không phải per-hook +`trusted_hash` mà Codex dùng để quyết định trust. + ### Gỡ khỏi project ```bash diff --git a/packages/casan-devkit/casan-init.py b/packages/casan-devkit/casan-init.py index eae10c2..fe99126 100755 --- a/packages/casan-devkit/casan-init.py +++ b/packages/casan-devkit/casan-init.py @@ -32,12 +32,14 @@ from contextlib import contextmanager import hashlib import json import os +import queue import re import shutil import stat import subprocess import sys import tempfile +import threading import time PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$") @@ -1490,6 +1492,185 @@ def _commands_in_hooks(path): return values +def _casan_codex_hook_count(path): + doc = _load_json_or(path, {}) + count = 0 + for groups in (doc.get("hooks") or {}).values(): + if not isinstance(groups, list): + continue + for group in groups: + handlers = (group.get("hooks") or []) if isinstance(group, dict) else [] + for handler in handlers: + if not isinstance(handler, dict): + continue + commands = ( + str(handler.get("command", "")), + str(handler.get("commandWindows", "")), + ) + if any( + "casan-hook.py" in command and "--client codex" in command + for command in commands + ): + count += 1 + return count + + +def _codex_project_hook_trust(target, timeout=10): + """Read Codex's effective per-hook trust state via its app-server API.""" + hooks_path = os.path.realpath(os.path.join(target, ".codex", "hooks.json")) + expected = _casan_codex_hook_count(hooks_path) + codex = shutil.which("codex") + result = { + "verified": False, + "source": "codex_app_server", + "codex_cli": codex, + "expected_hooks": expected, + "discovered_hooks": 0, + "trusted_hooks": 0, + "untrusted_hooks": 0, + "modified_hooks": 0, + "disabled_hooks": 0, + "status": "unavailable", + "review_required": True, + "operational": False, + "reason": None, + } + if not codex: + result["reason"] = "codex_cli_not_found" + return result + if not expected: + result["reason"] = "casan_project_hooks_not_found" + return result + + messages = [ + { + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "casan_doctor", + "title": "CASAN Doctor", + "version": "1", + }, + }, + }, + {"method": "initialized", "params": {}}, + {"method": "hooks/list", "id": 2, "params": {"cwds": [target]}}, + ] + process = None + lines = queue.Queue() + + def read_stdout(stream): + try: + for line in stream: + lines.put(line) + finally: + lines.put(None) + + try: + process = subprocess.Popen( + [codex, "app-server", "--stdio"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + cwd=target, + ) + for message in messages: + process.stdin.write( + json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + process.stdin.flush() + threading.Thread( + target=read_stdout, args=(process.stdout,), daemon=True + ).start() + + deadline = time.monotonic() + timeout + response = None + while time.monotonic() < deadline: + remaining = max(0.01, deadline - time.monotonic()) + try: + line = lines.get(timeout=remaining) + except queue.Empty: + break + if line is None: + break + try: + message = json.loads(line) + except (TypeError, ValueError): + continue + if message.get("id") == 2: + response = message + break + if not response: + result["reason"] = "codex_hooks_list_timeout" + return result + if response.get("error"): + result["reason"] = "codex_hooks_list_error" + return result + + hooks = [] + for item in (response.get("result") or {}).get("data") or []: + for hook in item.get("hooks") or []: + source_path = hook.get("sourcePath") + command = str(hook.get("command") or "") + if ( + isinstance(source_path, str) + and os.path.normcase(os.path.realpath(source_path)) + == os.path.normcase(hooks_path) + and "casan-hook.py" in command + and "--client codex" in command + ): + hooks.append(hook) + + result["discovered_hooks"] = len(hooks) + if len(hooks) != expected: + result["reason"] = "codex_project_hooks_incomplete" + return result + + statuses = [str(hook.get("trustStatus") or "").lower() for hook in hooks] + result["trusted_hooks"] = sum( + status in ("trusted", "managed") for status in statuses + ) + result["untrusted_hooks"] = statuses.count("untrusted") + result["modified_hooks"] = statuses.count("modified") + result["disabled_hooks"] = sum( + hook.get("enabled") is False for hook in hooks + ) + result["verified"] = True + result["review_required"] = any( + status not in ("trusted", "managed") for status in statuses + ) + result["operational"] = ( + not result["review_required"] and not result["disabled_hooks"] + ) + if result["modified_hooks"]: + result["status"] = "modified" + elif result["untrusted_hooks"]: + result["status"] = "untrusted" + elif result["disabled_hooks"]: + result["status"] = "disabled" + elif result["operational"]: + result["status"] = "trusted" + else: + result["status"] = "unknown" + return result + except (OSError, ValueError, TypeError): + result["reason"] = "codex_hooks_list_unavailable" + return result + finally: + if process is not None and process.poll() is None: + try: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + except OSError: + pass + + def _smoke_bootstrap(target, client): bootstrap = os.path.join(target, ".casan", "casan-hook.py") event = "Begin" if client == "vscode-copilot" else "UserPromptSubmit" @@ -1600,13 +1781,17 @@ def cmd_doctor(args): item["vscode_extension_installed"] = ( "anthropic.claude-code" in installed_extensions if code else None) elif client == "codex": - commands = _commands_in_hooks( - os.path.join(target, ".codex", "hooks.json")) + hooks_path = os.path.join(target, ".codex", "hooks.json") + commands = _commands_in_hooks(hooks_path) item["hook_present"] = any( "casan-hook.py" in command and "--client codex" in command for command in commands) - item["trust_review_required"] = True - item["trust_action"] = "Open /hooks in Codex and trust the current project hook hash." + trust = _codex_project_hook_trust(target) + item["trust"] = trust + item["trust_review_required"] = trust["review_required"] + if trust["review_required"]: + item["trust_action"] = ( + "Open /hooks in Codex and trust the current project hooks.") item["vscode_extension_installed"] = ( "openai.chatgpt" in installed_extensions if code else None) elif client == "vscode-copilot": @@ -1628,6 +1813,8 @@ def cmd_doctor(args): else: item["smoke"] = {"ok": False, "reason": "configuration_missing"} client_ready = bool(item.get("hook_present") and item["smoke"].get("ok")) + if client == "codex" and item["trust"].get("verified"): + client_ready = client_ready and bool(item["trust"].get("operational")) if client == "vscode-copilot": client_ready = client_ready and bool(item.get("extension_installed")) item["ready"] = client_ready @@ -1635,14 +1822,34 @@ def cmd_doctor(args): checks["client_checks"][client] = item if "codex" in clients: - checks["warnings"].append( - "Codex project hooks do not run until their exact hash is reviewed and trusted via /hooks.") - checks["required_actions"].append({ - "code": "codex_hook_trust", - "message": ( - "Open /hooks in a local Codex client, review the exact hook hash, " - "and trust it before treating Codex as operational."), - }) + codex_check = checks["client_checks"].get("codex", {}) + trust = codex_check.get("trust", {}) + if trust.get("review_required"): + if trust.get("verified"): + warning = ( + "Codex reports one or more CASAN project hooks as " + "%s; their current hashes require review." % + trust.get("status", "untrusted")) + else: + warning = ( + "CASAN could not verify Codex project-hook trust state " + "(%s)." % (trust.get("reason") or "unavailable")) + checks["warnings"].append(warning) + checks["required_actions"].append({ + "code": "codex_hook_trust", + "message": ( + "Open /hooks in a local Codex client, review the current " + "project hooks, and trust any new or modified entries."), + }) + elif trust.get("disabled_hooks"): + checks["warnings"].append( + "Codex reports one or more CASAN project hooks as disabled.") + checks["required_actions"].append({ + "code": "codex_hook_enable", + "message": ( + "Open /hooks in a local Codex client and enable the disabled " + "CASAN project hooks."), + }) if "vscode-copilot" in clients: checks["warnings"].append( "Only prompts explicitly sent to @casan use the CASAN-owned Copilot route; " diff --git a/packages/casan-devkit/tests/casan-init-tests.py b/packages/casan-devkit/tests/casan-init-tests.py new file mode 100644 index 0000000..709b4c7 --- /dev/null +++ b/packages/casan-devkit/tests/casan-init-tests.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import contextlib +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[3] + + +def module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + value = importlib.util.module_from_spec(spec) + spec.loader.exec_module(value) + return value + + +INIT = module("casan_init_test", ROOT / "packages/casan-devkit/casan-init.py") + + +def codex_hooks(): + hooks = {} + for event in ("UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"): + hooks[event] = [{ + "hooks": [{ + "type": "command", + "command": ( + 'python3 ".casan/casan-hook.py" ' + f"--client codex --event {event}" + ), + "timeout": 15, + }], + }] + return {"hooks": hooks} + + +class FakeProcess: + def __init__(self, response): + self.stdin = io.StringIO() + self.stdout = io.StringIO(json.dumps(response) + "\n") + self.returncode = None + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def kill(self): + self.returncode = -9 + + +class CasanInitTests(unittest.TestCase): + def trust_response(self, target: str, statuses): + source = str(Path(target, ".codex", "hooks.json").resolve()) + hooks = [] + for index, (event, status) in enumerate(zip( + ("user_prompt_submit", "pre_tool_use", "post_tool_use", "stop"), + statuses, + )): + hooks.append({ + "key": f"{source}:{event}:0:0", + "sourcePath": source, + "command": ( + 'python3 ".casan/casan-hook.py" ' + f"--client codex --event {event}" + ), + "enabled": True, + "trustStatus": status, + "displayOrder": index, + }) + return { + "id": 2, + "result": { + "data": [{ + "cwd": target, + "hooks": hooks, + "warnings": [], + "errors": [], + }], + }, + } + + def test_codex_trust_uses_effective_app_server_state(self): + with tempfile.TemporaryDirectory() as target: + hooks_path = Path(target, ".codex", "hooks.json") + hooks_path.parent.mkdir() + hooks_path.write_text(json.dumps(codex_hooks()), encoding="utf-8") + response = self.trust_response(target, ["trusted"] * 4) + process = FakeProcess(response) + + with patch.object(INIT.shutil, "which", return_value="/bin/codex"), \ + patch.object(INIT.subprocess, "Popen", return_value=process): + result = INIT._codex_project_hook_trust(target) + + self.assertTrue(result["verified"]) + self.assertEqual(result["status"], "trusted") + self.assertEqual(result["trusted_hooks"], 4) + self.assertFalse(result["review_required"]) + self.assertTrue(result["operational"]) + requests = [ + json.loads(line) for line in process.stdin.getvalue().splitlines() + ] + self.assertEqual(requests[-1]["method"], "hooks/list") + self.assertEqual(requests[-1]["params"]["cwds"], [target]) + + def test_codex_trust_detects_modified_hook(self): + with tempfile.TemporaryDirectory() as target: + hooks_path = Path(target, ".codex", "hooks.json") + hooks_path.parent.mkdir() + hooks_path.write_text(json.dumps(codex_hooks()), encoding="utf-8") + response = self.trust_response( + target, ["trusted", "modified", "trusted", "trusted"]) + + with patch.object(INIT.shutil, "which", return_value="/bin/codex"), \ + patch.object( + INIT.subprocess, "Popen", + return_value=FakeProcess(response), + ): + result = INIT._codex_project_hook_trust(target) + + self.assertTrue(result["verified"]) + self.assertEqual(result["status"], "modified") + self.assertEqual(result["modified_hooks"], 1) + self.assertTrue(result["review_required"]) + self.assertFalse(result["operational"]) + + def test_doctor_does_not_warn_when_codex_hooks_are_trusted(self): + with tempfile.TemporaryDirectory() as target: + root = Path(target) + (root / ".casan").mkdir() + (root / ".codex").mkdir() + (root / ".casan/config.json").write_text(json.dumps({ + "project_id": "trusted-project", + "enforcement_mode": "enforce", + "integration_mode": "project_hook", + "clients": ["codex"], + }), encoding="utf-8") + (root / ".casan/version.lock").write_text( + json.dumps({"harness_hash": "sha256:harness"}), + encoding="utf-8", + ) + (root / ".casan/casan-hook.py").write_text( + "# test bootstrap\n", encoding="utf-8") + (root / ".codex/hooks.json").write_text( + json.dumps(codex_hooks()), encoding="utf-8") + trusted = { + "verified": True, + "review_required": False, + "operational": True, + "status": "trusted", + "disabled_hooks": 0, + } + args = argparse.Namespace( + target=target, harness=None, client=None, json=True) + + with patch.object( + INIT, "resolve_project_harness", return_value="/harness" + ), patch.object( + INIT, "compute_live", + return_value=("sha256:harness", "live"), + ), patch.object( + INIT, "_legacy_instruction_conflicts", return_value=[] + ), patch.object( + INIT, "_smoke_bootstrap", + return_value={"ok": True, "decision": "allow"}, + ), patch.object( + INIT, "_codex_project_hook_trust", return_value=trusted + ), patch.object( + INIT.shutil, "which", return_value=None + ), contextlib.redirect_stdout(io.StringIO()) as output: + returncode = INIT.cmd_doctor(args) + + result = json.loads(output.getvalue()) + self.assertEqual(returncode, 0) + self.assertEqual(result["status"], "ready") + self.assertEqual(result["operational_status"], "ready") + self.assertEqual(result["warnings"], []) + self.assertEqual(result["required_actions"], []) + self.assertFalse( + result["client_checks"]["codex"]["trust_review_required"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/casan-devkit/tests/hybrid-install-tests.sh b/packages/casan-devkit/tests/hybrid-install-tests.sh index 533aa86..9d48f04 100755 --- a/packages/casan-devkit/tests/hybrid-install-tests.sh +++ b/packages/casan-devkit/tests/hybrid-install-tests.sh @@ -24,6 +24,9 @@ trap cleanup EXIT CASAN="$CASAN_HOME/bin/casan" echo "===== ① global install =====" +python3 "$REPO_ROOT/packages/casan-devkit/tests/casan-init-tests.py" >/dev/null \ + && pass "casan init/doctor unit contracts pass" \ + || fail "casan init/doctor unit contracts failed" if sh "$REPO_ROOT/install.sh" >/dev/null 2>&1; then pass "install.sh completes"; else fail "install.sh failed"; fi [[ -x "$CASAN" ]] && pass "global launcher created" || fail "launcher missing" [[ -f "$CASAN_HOME/current/.harness-hash" ]] && pass "integrity hash recorded at install" || fail "no .harness-hash" diff --git a/packages/casan-harness/tests/phase-project-shell-tests.sh b/packages/casan-harness/tests/phase-project-shell-tests.sh index 0b8540b..ec29884 100644 --- a/packages/casan-harness/tests/phase-project-shell-tests.sh +++ b/packages/casan-harness/tests/phase-project-shell-tests.sh @@ -3,6 +3,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh" +python3 "$CASAN_APP_ROOT/packages/casan-devkit/tests/casan-init-tests.py" python3 "$CASAN_APP_ROOT/packages/casan-devkit/tests/project-scaffold-tests.py" bash "$CASAN_APP_ROOT/packages/casan-devkit/tests/adoption-install-tests.sh" python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \