fix Codex hook trust detection

This commit is contained in:
thanhnv
2026-07-24 17:29:22 +07:00
parent 353d87b6ae
commit ee5d1f7af5
5 changed files with 427 additions and 12 deletions
+8
View File
@@ -216,6 +216,14 @@ casan doctor --client codex
casan doctor --client vscode-copilot 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 ### Gỡ khỏi project
```bash ```bash
+215 -8
View File
@@ -32,12 +32,14 @@ from contextlib import contextmanager
import hashlib import hashlib
import json import json
import os import os
import queue
import re import re
import shutil import shutil
import stat import stat
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$") PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
@@ -1490,6 +1492,185 @@ def _commands_in_hooks(path):
return values 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): def _smoke_bootstrap(target, client):
bootstrap = os.path.join(target, ".casan", "casan-hook.py") bootstrap = os.path.join(target, ".casan", "casan-hook.py")
event = "Begin" if client == "vscode-copilot" else "UserPromptSubmit" event = "Begin" if client == "vscode-copilot" else "UserPromptSubmit"
@@ -1600,13 +1781,17 @@ def cmd_doctor(args):
item["vscode_extension_installed"] = ( item["vscode_extension_installed"] = (
"anthropic.claude-code" in installed_extensions if code else None) "anthropic.claude-code" in installed_extensions if code else None)
elif client == "codex": elif client == "codex":
commands = _commands_in_hooks( hooks_path = os.path.join(target, ".codex", "hooks.json")
os.path.join(target, ".codex", "hooks.json")) commands = _commands_in_hooks(hooks_path)
item["hook_present"] = any( item["hook_present"] = any(
"casan-hook.py" in command and "--client codex" in command "casan-hook.py" in command and "--client codex" in command
for command in commands) for command in commands)
item["trust_review_required"] = True trust = _codex_project_hook_trust(target)
item["trust_action"] = "Open /hooks in Codex and trust the current project hook hash." 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"] = ( item["vscode_extension_installed"] = (
"openai.chatgpt" in installed_extensions if code else None) "openai.chatgpt" in installed_extensions if code else None)
elif client == "vscode-copilot": elif client == "vscode-copilot":
@@ -1628,6 +1813,8 @@ def cmd_doctor(args):
else: else:
item["smoke"] = {"ok": False, "reason": "configuration_missing"} item["smoke"] = {"ok": False, "reason": "configuration_missing"}
client_ready = bool(item.get("hook_present") and item["smoke"].get("ok")) 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": if client == "vscode-copilot":
client_ready = client_ready and bool(item.get("extension_installed")) client_ready = client_ready and bool(item.get("extension_installed"))
item["ready"] = client_ready item["ready"] = client_ready
@@ -1635,13 +1822,33 @@ def cmd_doctor(args):
checks["client_checks"][client] = item checks["client_checks"][client] = item
if "codex" in clients: if "codex" in clients:
checks["warnings"].append( codex_check = checks["client_checks"].get("codex", {})
"Codex project hooks do not run until their exact hash is reviewed and trusted via /hooks.") 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({ checks["required_actions"].append({
"code": "codex_hook_trust", "code": "codex_hook_trust",
"message": ( "message": (
"Open /hooks in a local Codex client, review the exact hook hash, " "Open /hooks in a local Codex client, review the current "
"and trust it before treating Codex as operational."), "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: if "vscode-copilot" in clients:
checks["warnings"].append( checks["warnings"].append(
@@ -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()
@@ -24,6 +24,9 @@ trap cleanup EXIT
CASAN="$CASAN_HOME/bin/casan" CASAN="$CASAN_HOME/bin/casan"
echo "===== ① global install =====" 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 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" [[ -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" [[ -f "$CASAN_HOME/current/.harness-hash" ]] && pass "integrity hash recorded at install" || fail "no .harness-hash"
@@ -3,6 +3,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh" 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" python3 "$CASAN_APP_ROOT/packages/casan-devkit/tests/project-scaffold-tests.py"
bash "$CASAN_APP_ROOT/packages/casan-devkit/tests/adoption-install-tests.sh" bash "$CASAN_APP_ROOT/packages/casan-devkit/tests/adoption-install-tests.sh"
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \ python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \