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
+219 -12
View File
@@ -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; "