Add selectable CASAN IDE integrations

This commit is contained in:
thanhnv
2026-07-23 23:04:54 +07:00
parent ff4e9d5a53
commit ce708fafe5
28 changed files with 1658 additions and 407 deletions
+467 -44
View File
@@ -29,7 +29,10 @@ import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
@@ -39,6 +42,16 @@ PROJECT_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
LEVEL_NAME = {"1": "core", "2": "devkit", "3": "platform", "4": "enterprise",
"core": "core", "devkit": "devkit", "platform": "platform", "enterprise": "enterprise"}
LEVEL_NUM = {"core": 1, "devkit": 2, "platform": 3, "enterprise": 4}
SUPPORTED_CLIENTS = ("claude", "codex", "vscode-copilot")
VSCODE_EXTENSION_IDS = {
"claude": ("anthropic.claude-code",),
"codex": ("OpenAI.chatgpt",),
"vscode-copilot": (
"GitHub.copilot",
"GitHub.copilot-chat",
"fpt-casan.casan-governed-chat",
),
}
def now_iso():
@@ -187,10 +200,53 @@ def _has_marker_hook(groups, marker):
return False
def _remove_marker_hooks(doc, marker):
"""Remove only handlers owned by CASAN, preserving all user hook groups."""
changed = False
hooks = doc.get("hooks") if isinstance(doc, dict) else None
if not isinstance(hooks, dict):
return changed
for event in list(hooks):
groups = hooks.get(event)
if not isinstance(groups, list):
continue
kept_groups = []
for group in groups:
if not isinstance(group, dict):
kept_groups.append(group)
continue
handlers = group.get("hooks")
if not isinstance(handlers, list):
kept_groups.append(group)
continue
kept_handlers = []
for handler in handlers:
commands = ""
if isinstance(handler, dict):
commands = "%s %s" % (handler.get("command", ""),
handler.get("commandWindows", ""))
if marker in commands:
changed = True
else:
kept_handlers.append(handler)
if kept_handlers:
updated = dict(group)
updated["hooks"] = kept_handlers
kept_groups.append(updated)
elif handlers:
changed = True
if kept_groups:
hooks[event] = kept_groups
else:
hooks.pop(event, None)
return changed
def merge_json_hooks(target_file, template_file, marker, backups):
"""MERGE the template's hook groups into an existing hooks JSON without
clobbering the user's own hooks/agents/skills config. Idempotent by `marker`:
re-running never duplicates the CASAN hook. Returns created|merged|unchanged|None."""
clobbering the user's own hooks/agents/skills config. Existing CASAN-owned
handlers are replaced so upgrades cannot leave a stale command behind.
Returns created|merged|unchanged|None."""
tmpl = _load_json_or(template_file, None)
if not isinstance(tmpl, dict):
return None
@@ -198,24 +254,22 @@ def merge_json_hooks(target_file, template_file, marker, backups):
doc = _load_json_or(target_file, {}) if existed else {}
if not isinstance(doc, dict):
doc = {}
before = json.dumps(doc, ensure_ascii=False, sort_keys=True)
hooks = doc.get("hooks")
if not isinstance(hooks, dict):
hooks = {}
doc["hooks"] = hooks
changed = not existed
_remove_marker_hooks(doc, marker)
for event, groups in (tmpl.get("hooks") or {}).items():
cur = hooks.get(event)
if not isinstance(cur, list):
cur = []
hooks[event] = cur
if _has_marker_hook(cur, marker):
continue
cur.extend(groups if isinstance(groups, list) else [])
changed = True
for k, v in tmpl.items(): # carry template scalars (e.g. "version") if absent
if k != "hooks" and k not in doc:
doc[k] = v
changed = True
changed = (not existed) or before != json.dumps(doc, ensure_ascii=False, sort_keys=True)
if changed:
_backup_once(target_file, backups)
os.makedirs(os.path.dirname(target_file), exist_ok=True)
@@ -224,32 +278,176 @@ def merge_json_hooks(target_file, template_file, marker, backups):
return ("created" if not existed else "merged") if changed else "unchanged"
def merge_codex_config(target_file, mode, integration_mode, backups):
"""Append a CASAN [casan] section (and, only if absent, a [hooks] enabler) to
an existing .codex/config.toml without duplicating tables. stdlib-only; no TOML
writer needed. Idempotent by the '[casan]' marker."""
casan_block = (
"\n# CASAN Plan-20 (added by casan init)\n"
"[casan]\n"
'enforcement_mode = "%s"\n'
'integration_mode = "%s"\n' % (mode, integration_mode)
)
hooks_block = "\n[hooks]\nenabled = true\nproject_hooks = true\n"
def remove_json_hooks(target_file, marker, backups):
"""Disable one CASAN integration without touching non-CASAN hooks."""
if not os.path.exists(target_file):
os.makedirs(os.path.dirname(target_file), exist_ok=True)
with open(target_file, "w", encoding="utf-8") as fh:
fh.write("# CASAN Plan-20 Codex config (created by casan init)\n"
+ hooks_block + casan_block)
return "created"
with open(target_file, "r", encoding="utf-8", errors="replace") as fh:
cur = fh.read()
if "[casan]" in cur:
return "absent"
doc = _load_json_or(target_file, None)
if not isinstance(doc, dict) or not _remove_marker_hooks(doc, marker):
return "unchanged"
_backup_once(target_file, backups)
add = ("" if "[hooks]" in cur else hooks_block) + casan_block
with open(target_file, "w", encoding="utf-8") as fh:
fh.write(cur.rstrip() + "\n" + add)
return "merged"
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
return "removed"
def clean_legacy_codex_config(target_file, backups):
"""Remove only Plan-21's obsolete `[hooks]`/`[casan]` blocks.
Current Codex enables hooks by default and rejects/ignores the legacy custom
keys. The sentinel comment makes this migration precise; user-authored TOML
is otherwise preserved byte-for-byte.
"""
if not os.path.exists(target_file):
return "absent"
with open(target_file, "r", encoding="utf-8", errors="replace") as fh:
current = fh.read()
sentinel = "# CASAN Plan-20 (added by casan init)"
created_header = "# CASAN Plan-20 Codex config (created by casan init)"
if sentinel not in current and created_header not in current:
return "unchanged"
lines = current.splitlines()
kept = []
skipping = False
for line in lines:
stripped = line.strip()
if stripped == created_header:
continue
if stripped == sentinel:
skipping = True
continue
if skipping and stripped.startswith("[") and stripped != "[casan]":
skipping = False
if not skipping:
kept.append(line)
# Old files created by CASAN put [hooks] immediately before the sentinel.
cleaned = "\n".join(kept)
cleaned = re.sub(
r"(?ms)\n?\[hooks\]\nenabled = true\nproject_hooks = true\n(?=\s*$)",
"\n", cleaned)
cleaned = cleaned.strip() + ("\n" if cleaned.strip() else "")
_backup_once(target_file, backups)
if cleaned:
with open(target_file, "w", encoding="utf-8") as fh:
fh.write(cleaned)
else:
os.unlink(target_file)
return "cleaned"
def select_clients(values, interactive):
"""Normalize repeatable/comma-separated selections.
A plain interactive `casan init` presents the requested IDE menu. In
non-interactive automation, the historical Claude+Codex default is kept.
"""
if not values:
if interactive:
sys.stderr.write(
"\nEnable CASAN integrations (comma-separated numbers):\n"
" 1) Claude Code (CLI + official VS Code extension)\n"
" 2) Codex (CLI + official VS Code extension)\n"
" 3) GitHub Copilot in VS Code via explicit @casan route\n"
"Selection [1,2]: ")
sys.stderr.flush()
answer = sys.stdin.readline().strip() or "1,2"
values = [answer]
else:
values = ["claude,codex"]
aliases = {
"1": "claude",
"2": "codex",
"3": "vscode-copilot",
"copilot": "vscode-copilot",
"vscode": "vscode-copilot",
"github-copilot": "vscode-copilot",
}
selected = []
for value in values:
for raw in str(value).split(","):
item = aliases.get(raw.strip().lower(), raw.strip().lower())
if item == "all":
item_values = list(SUPPORTED_CLIENTS)
elif item in ("none", ""):
item_values = []
elif item in SUPPORTED_CLIENTS:
item_values = [item]
else:
raise ValueError("unknown --client %r (valid: %s, all, none)" %
(raw, ", ".join(SUPPORTED_CLIENTS)))
for normalized in item_values:
if normalized not in selected:
selected.append(normalized)
return selected
def merge_vscode_recommendations(target_file, clients, backups):
existed = os.path.exists(target_file)
doc = _load_json_or(target_file, {}) if existed else {}
if not isinstance(doc, dict):
doc = {}
before = json.dumps(doc, ensure_ascii=False, sort_keys=True)
recommendations = doc.get("recommendations")
if not isinstance(recommendations, list):
recommendations = []
managed = {item for values in VSCODE_EXTENSION_IDS.values() for item in values}
recommendations = [item for item in recommendations if item not in managed]
for client in clients:
for extension_id in VSCODE_EXTENSION_IDS.get(client, ()):
if extension_id not in recommendations:
recommendations.append(extension_id)
if recommendations:
doc["recommendations"] = recommendations
else:
doc.pop("recommendations", None)
changed = (not existed and bool(doc)) or before != json.dumps(
doc, ensure_ascii=False, sort_keys=True)
if changed:
_backup_once(target_file, backups)
os.makedirs(os.path.dirname(target_file), exist_ok=True)
with open(target_file, "w", encoding="utf-8") as fh:
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
return ("created" if not existed else "merged") if changed else "unchanged"
def build_vscode_vsix():
builder = os.path.join(devkit_root(), "package-vscode-extension.py")
output = os.path.join(devkit_root(), "dist", "casan-governed-chat.vsix")
if not os.path.isfile(builder):
return None, "builder_missing"
result = subprocess.run(
[sys.executable, builder, "--output", output],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
if result.returncode != 0 or not os.path.isfile(output):
return None, "build_failed:%s" % result.stderr.strip()
return output, "built"
def install_vscode_extension(mode):
"""Install the local CASAN VSIX when requested and `code` is available."""
if mode == "no":
return {"status": "skipped", "reason": "disabled"}
vsix, status = build_vscode_vsix()
if not vsix:
return {"status": "failed", "reason": status}
code = shutil.which("code")
if not code:
return {
"status": "needs_install",
"reason": "code_cli_not_found",
"vsix": vsix,
"action": "Install this VSIX from VS Code: Extensions: Install from VSIX...",
}
result = subprocess.run(
[code, "--install-extension", vsix, "--force"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
return {
"status": "installed" if result.returncode == 0 else "failed",
"extension": "fpt-casan.casan-governed-chat",
"vsix": vsix,
"command": code,
"detail": (result.stdout or result.stderr).strip(),
}
def cmd_init(args):
@@ -294,7 +492,11 @@ def cmd_init(args):
preview = (lvl == 3) # platform is a separate preview SERVICE; init applies the L2 base
apply_devkit = (lvl >= 2)
clients = ["claude", "codex"] if args.client == "all" else [args.client]
try:
clients = select_clients(args.client, sys.stdin.isatty() and not args.non_interactive)
except ValueError as error:
sys.stderr.write("casan init: %s\n" % error)
return 64
version = harness_version(harness)
hhash, hsource = compute_harness_hash(harness)
created = []
@@ -312,6 +514,13 @@ def cmd_init(args):
"enforcement_mode": args.mode,
"integration_mode": args.integration_mode,
"clients": clients,
"client_capabilities": {
"claude": "project_hook" if "claude" in clients else "disabled",
"codex": "project_hook_trust_required" if "codex" in clients else "disabled",
"vscode-copilot": "casan_owned_explicit_at_casan"
if "vscode-copilot" in clients else "disabled",
"vscode-native-copilot": "unsupported_global_interception",
},
"harness_version": version,
"adoption_model": "hybrid-global",
"target_level": lvl,
@@ -334,7 +543,8 @@ def cmd_init(args):
# ── .casan/agentic.env (Plan-20 flags) ──
env_lines = [
"# CASAN Plan-20 agentic bridge flags. Source before starting the client.",
"# CASAN Plan-20 flags (reference/export compatibility).",
"# .casan/casan-hook.py loads the authoritative values from config.json automatically.",
"CASAN_AGENTIC_BRIDGE_ENABLED=1",
"CASAN_AGENTIC_ENFORCEMENT_MODE=%s" % args.mode,
"CASAN_AGENTIC_INTEGRATION_MODE=%s" % args.integration_mode,
@@ -343,6 +553,22 @@ def cmd_init(args):
p = os.path.join(cfg_dir, "agentic.env")
_write(p, "\n".join(env_lines), backups); created_add(p)
# Stable project-local bootstrap. It loads the config above, resolves the
# global harness and verifies version.lock before dispatching an adapter.
bootstrap_source = os.path.join(devkit_root(), "templates", "project",
"casan-hook.py")
bootstrap_target = os.path.join(cfg_dir, "casan-hook.py")
if not os.path.isfile(bootstrap_source):
sys.stderr.write("casan init: missing project hook bootstrap template\n")
return 1
with open(bootstrap_source, "r", encoding="utf-8") as fh:
_write(bootstrap_target, fh.read(), backups)
try:
os.chmod(bootstrap_target, 0o755)
except OSError:
pass
created_add(bootstrap_target)
# ── .specify/ state root marker ──
specify = os.path.join(target, ".specify")
os.makedirs(os.path.join(specify, "state"), exist_ok=True)
@@ -356,25 +582,49 @@ def cmd_init(args):
# keeps everything; CASAN hooks are added idempotently.
ad = os.path.join(harness, "adapters")
merges = {}
claude_dst = os.path.join(target, ".claude", "settings.json")
if "claude" in clients:
dst = os.path.join(target, ".claude", "settings.json")
dst = claude_dst
remove_json_hooks(dst, "claude_hook.py", backups)
r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"),
"claude_hook.py", backups)
"casan-hook.py", backups)
if r and r != "unchanged":
created_add(dst)
merges[".claude/settings.json"] = r
else:
legacy = remove_json_hooks(claude_dst, "claude_hook.py", backups)
current = remove_json_hooks(claude_dst, "casan-hook.py", backups)
merges[".claude/settings.json"] = (
"removed" if "removed" in (legacy, current) else current)
codex_hooks_dst = os.path.join(target, ".codex", "hooks.json")
if "codex" in clients:
dsth = os.path.join(target, ".codex", "hooks.json")
dsth = codex_hooks_dst
remove_json_hooks(dsth, "codex_hook.py", backups)
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"),
"codex_hook.py", backups)
"casan-hook.py", backups)
if r and r != "unchanged":
created_add(dsth)
merges[".codex/hooks.json"] = r
dstc = os.path.join(target, ".codex", "config.toml")
r = merge_codex_config(dstc, args.mode, args.integration_mode, backups)
if r and r != "unchanged":
created_add(dstc)
merges[".codex/config.toml"] = r
else:
legacy = remove_json_hooks(codex_hooks_dst, "codex_hook.py", backups)
current = remove_json_hooks(codex_hooks_dst, "casan-hook.py", backups)
merges[".codex/hooks.json"] = (
"removed" if "removed" in (legacy, current) else current)
# Migrate obsolete Plan-21 TOML blocks. Current Codex discovers hooks.json
# directly and requires trust through /hooks; no custom [casan] keys.
merges[".codex/config.toml"] = clean_legacy_codex_config(
os.path.join(target, ".codex", "config.toml"), backups)
vscode_file = os.path.join(target, ".vscode", "extensions.json")
vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups)
if vscode_merge not in ("unchanged", "absent"):
created_add(vscode_file)
merges[".vscode/extensions.json"] = vscode_merge
vscode_install = (
install_vscode_extension(args.vscode_install)
if "vscode-copilot" in clients else
{"status": "skipped", "reason": "vscode-copilot_not_selected"}
)
# ── Level 2 (devkit) adoption extras: CI workflow + domain-pack scaffold.
# These are the real difference between L1 (gate/hooks only) and L2 (full
@@ -414,9 +664,10 @@ def cmd_init(args):
"clients": clients,
"created": created,
"hook_merges": merges,
"vscode_extension": vscode_install,
"level_extras": level_extras,
"note": ("harness NOT copied into repo (hybrid model); client hooks MERGED "
"(existing config preserved); run `casan verify-harness` to check the pin"),
"note": ("harness NOT copied into repo (hybrid model); selected client hooks "
"MERGED and unselected CASAN hooks removed; run `casan doctor`"),
}, ensure_ascii=False, indent=2))
if preview:
sys.stderr.write("casan init: NOTE — Level 3 (platform) is a PREVIEW SERVICE (Control "
@@ -489,6 +740,159 @@ def cmd_level(args):
return 0
def _commands_in_hooks(path):
doc = _load_json_or(path, {})
values = []
for groups in (doc.get("hooks") or {}).values():
if not isinstance(groups, list):
continue
for group in groups:
for handler in (group.get("hooks") or []) if isinstance(group, dict) else []:
if isinstance(handler, dict):
values.append(str(handler.get("command", "")))
values.append(str(handler.get("commandWindows", "")))
return values
def _smoke_bootstrap(target, client):
bootstrap = os.path.join(target, ".casan", "casan-hook.py")
event = "Begin" if client == "vscode-copilot" else "UserPromptSubmit"
payload = {
"cwd": target,
"project": target,
"session_id": "casan-doctor",
"turn_id": "casan-doctor-turn",
"prompt": "CASAN doctor smoke",
}
with tempfile.TemporaryDirectory(prefix="casan-doctor-") as tmp:
env = dict(os.environ)
env["CASAN_APP_ROOT"] = target
env["CASAN_STATE_ROOT"] = os.path.join(tmp, ".specify")
result = subprocess.run(
[sys.executable, bootstrap, "--client", client, "--event", event],
input=json.dumps(payload), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, cwd=target, env=env, check=False)
try:
response = json.loads(result.stdout.strip() or "{}")
except ValueError:
response = {}
return {
"ok": result.returncode == 0 and response.get("decision") != "block",
"returncode": result.returncode,
"decision": response.get("decision"),
"trace_id": response.get("trace_id"),
"reason": response.get("reason") or result.stderr.strip() or None,
}
def cmd_doctor(args):
target = os.path.abspath(args.target or os.getcwd())
config_path = os.path.join(target, ".casan", "config.json")
config = _load_json_or(config_path, {})
if not config:
sys.stderr.write("casan doctor: project is not initialized; run `casan init`.\n")
return 1
if args.client:
try:
clients = select_clients(args.client, False)
except ValueError as error:
sys.stderr.write("casan doctor: %s\n" % error)
return 64
else:
clients = config.get("clients") or []
harness = resolve_harness(args.harness)
lock = _load_json_or(os.path.join(target, ".casan", "version.lock"), {})
expected = lock.get("harness_hash")
actual, source = compute_live(harness) if harness else ("unavailable", "error")
integrity_ok = bool(expected and expected == actual)
bootstrap_ok = os.path.isfile(os.path.join(target, ".casan", "casan-hook.py"))
checks = {
"project": target,
"project_id": config.get("project_id"),
"enforcement_mode": config.get("enforcement_mode"),
"integration_mode": config.get("integration_mode"),
"clients": clients,
"harness_root": harness,
"integrity": {
"ok": integrity_ok,
"expected": expected,
"actual": actual,
"source": source,
},
"bootstrap": {"ok": bootstrap_ok},
"client_checks": {},
"warnings": [],
}
ready = integrity_ok and bootstrap_ok
code = shutil.which("code")
installed_extensions = set()
if code:
listed = subprocess.run(
[code, "--list-extensions"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, check=False)
if listed.returncode == 0:
installed_extensions = {
line.strip().lower() for line in listed.stdout.splitlines() if line.strip()}
for client in clients:
item = {"configured": True}
if client == "claude":
commands = _commands_in_hooks(
os.path.join(target, ".claude", "settings.json"))
item["hook_present"] = any(
"casan-hook.py" in command and "--client claude" in command
for command in commands)
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"))
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."
item["vscode_extension_installed"] = (
"openai.chatgpt" in installed_extensions if code else None)
elif client == "vscode-copilot":
item["hook_present"] = bootstrap_ok
item["route"] = "explicit_@casan"
item["native_copilot_interception"] = False
item["code_cli"] = code
item["extension_installed"] = (
"fpt-casan.casan-governed-chat" in installed_extensions
if code else False)
if not item["extension_installed"]:
item["install_action"] = (
"Re-run `casan init --client vscode-copilot "
"--vscode-install yes` with the VS Code `code` CLI on PATH.")
else:
item["hook_present"] = False
if bootstrap_ok and item.get("hook_present"):
item["smoke"] = _smoke_bootstrap(target, client)
else:
item["smoke"] = {"ok": False, "reason": "configuration_missing"}
client_ready = bool(item.get("hook_present") and item["smoke"].get("ok"))
if client == "vscode-copilot":
client_ready = client_ready and bool(item.get("extension_installed"))
item["ready"] = client_ready
ready = ready and client_ready
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.")
if "vscode-copilot" in clients:
checks["warnings"].append(
"Only prompts explicitly sent to @casan use the CASAN-owned Copilot route; "
"built-in Copilot chat is not globally intercepted.")
checks["status"] = "ready" if ready else "not_ready"
print(json.dumps(checks, ensure_ascii=False, indent=2))
return 0 if ready else 2
def main(argv=None):
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
sub = parser.add_subparsers(dest="cmd")
@@ -496,10 +900,21 @@ def main(argv=None):
pi = sub.add_parser("init", help="adopt CASAN into the current project (config only)")
pi.add_argument("--target", help="project root (default: cwd)")
pi.add_argument("--project", help="project id (^[a-z][a-z0-9-]{1,62}$; default: dir name)")
pi.add_argument("--client", choices=["claude", "codex", "all"], default="all")
pi.add_argument(
"--client", action="append",
help=("integration to enable; repeat or comma-separate: claude, codex, "
"vscode-copilot, all, none. Interactive init shows a menu."))
pi.add_argument(
"--non-interactive", action="store_true",
help="do not prompt; defaults to the backward-compatible claude,codex set")
pi.add_argument(
"--vscode-install", choices=["auto", "yes", "no"], default="auto",
help=("install the local CASAN @casan VSIX when vscode-copilot is selected "
"(default auto: install when `code` is available)"))
pi.add_argument("--level", default="devkit",
help="packaging level to adopt: 1|core, 2|devkit (default), 3|platform (preview), 4|enterprise (refused)")
pi.add_argument("--mode", choices=["observe", "enforce"], default="observe")
pi.add_argument("--mode", choices=["observe", "enforce"], default="enforce",
help="agentic policy mode (default: enforce; use observe for a telemetry-only pilot)")
pi.add_argument("--integration-mode", dest="integration_mode",
choices=["project_hook", "managed_hook", "casan_owned"], default="project_hook")
pi.add_argument("--force", action="store_true",
@@ -515,6 +930,12 @@ def main(argv=None):
pl.add_argument("--target", help="project root (default: cwd)")
pl.add_argument("--harness", help="override harness root")
pd = sub.add_parser("doctor", help="verify selected client integrations end-to-end")
pd.add_argument("--target", help="project root (default: cwd)")
pd.add_argument("--client", action="append",
help="client(s) to check; default: clients enabled in config")
pd.add_argument("--harness", help="override harness root")
args = parser.parse_args(argv)
if args.cmd == "init":
return cmd_init(args)
@@ -522,6 +943,8 @@ def main(argv=None):
return cmd_verify(args)
if args.cmd == "level":
return cmd_level(args)
if args.cmd == "doctor":
return cmd_doctor(args)
parser.print_help()
return 64
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Build the dependency-free CASAN VS Code extension as a deterministic VSIX."""
from __future__ import annotations
import argparse
import json
import os
import zipfile
ROOT = os.path.dirname(os.path.abspath(__file__))
HARNESS = os.environ.get(
"CASAN_HARNESS_ROOT",
os.path.abspath(os.path.join(ROOT, "..", "casan-harness")))
SOURCE = os.path.join(HARNESS, "adapters", "vscode", "extension")
CONTENT_TYPES = """<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="json" ContentType="application/json" />
<Default Extension="js" ContentType="application/javascript" />
<Default Extension="md" ContentType="text/markdown" />
<Default Extension="vsixmanifest" ContentType="text/xml" />
</Types>
"""
def manifest(package):
identity = "%s.%s" % (package["publisher"], package["name"])
return """<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011">
<Metadata>
<Identity Language="en-US" Id="{identity}" Version="{version}" Publisher="{publisher}" />
<DisplayName>{display}</DisplayName>
<Description xml:space="preserve">{description}</Description>
<Tags>casan,governance,github copilot,chat</Tags>
<Categories>Other</Categories>
<Properties>
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="{engine}" />
</Properties>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Code" Version="[1.98.0,)" />
</Installation>
<Dependencies />
<Assets>
<Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
</Assets>
</PackageManifest>
""".format(
identity=identity,
version=package["version"],
publisher=package["publisher"],
display=package["displayName"],
description=package["description"],
engine=package["engines"]["vscode"],
)
def build(output):
with open(os.path.join(SOURCE, "package.json"), "r", encoding="utf-8") as handle:
package = json.load(handle)
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
entries = ["package.json", "extension.js", "README.md"]
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
def write_entry(name, data):
info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16
archive.writestr(info, data)
write_entry("[Content_Types].xml", CONTENT_TYPES)
write_entry("extension.vsixmanifest", manifest(package))
for name in entries:
source = os.path.join(SOURCE, name)
with open(source, "rb") as handle:
write_entry("extension/" + name, handle.read())
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", default=os.path.join(ROOT, "dist",
"casan-governed-chat.vsix"))
args = parser.parse_args()
print(build(args.output))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -8,9 +8,9 @@ carries an H1→H7 trace and an H6 record.
- The hook command resolves the repo root via `$CLAUDE_PROJECT_DIR`, so **no
machine-specific path is committed**.
- It invokes `packages/casan-harness/adapters/claude-code/claude_hook.py`, a thin
renderer that calls the core bridge. The bridge **never runs a model** — Claude
remains the sole model executor (single-model invariant).
- It invokes `.casan/casan-hook.py`. That bootstrap reads project config,
resolves the pinned global harness, verifies its live hash, then dispatches
the thin Claude renderer. The bridge **never runs a model**.
- This is a **`project_hook`** integration: strong for a trusted project, but a
member who can edit `.claude/settings.json` can disable it. For organization
enforcement use a managed/pinned deployment (`managed_hook`) — see
@@ -19,18 +19,16 @@ carries an H1→H7 trace and an H6 record.
## Install (cross-platform)
```bash
packages/casan-devkit/install.sh --target <repo> --project <id>
# then enable the agentic client hooks:
cp packages/casan-devkit/templates/claude/settings.json <repo>/.claude/settings.json
cd <repo>
casan init --client claude --mode enforce
casan doctor --client claude
```
Windows: use `packages/casan-devkit/windows/install-agentic.ps1 -Client claude`.
## Feature flags (environment)
| Variable | Default | Meaning |
|---|---|---|
| `CASAN_AGENTIC_BRIDGE_ENABLED` | `1` | Master on/off. |
| `CASAN_AGENTIC_ENFORCEMENT_MODE` | `observe` | `observe` (telemetry-only, never certified) → `enforce` (gates + certification). |
| `CASAN_AGENTIC_ENFORCEMENT_MODE` | `enforce` via init | `observe` (telemetry-only) or `enforce`. |
| `CASAN_AGENTIC_INTEGRATION_MODE` | `project_hook` | `project_hook` / `managed_hook` / `casan_owned`. |
| `CASAN_AGENTIC_CLIENT_ALLOWLIST` | (unset) | Comma list; clients outside it are `observed_only`. |
@@ -1,12 +1,12 @@
{
"//": "CASAN Plan-20 Claude Code project hooks. Commit this as .claude/settings.json in the target repo (the devkit installer does this). Commands self-resolve the repo root via $CLAUDE_PROJECT_DIR — no machine-specific path is baked in. Secrets and absolute paths must NOT be added here.",
"//": "CASAN Plan-20 Claude Code project hooks. The project-local bootstrap resolves and integrity-checks the pinned global harness. Secrets and machine-specific paths must NOT be added here.",
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event UserPromptSubmit",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.casan/casan-hook.py\" --client claude --event UserPromptSubmit",
"timeout": 15
}
]
@@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event PreToolUse",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.casan/casan-hook.py\" --client claude --event PreToolUse",
"timeout": 15
}
]
@@ -30,7 +30,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event PostToolUse",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.casan/casan-hook.py\" --client claude --event PostToolUse",
"timeout": 15
}
]
@@ -41,7 +41,7 @@
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/packages/casan-harness/adapters/claude-code/claude_hook.py\" --event Stop",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.casan/casan-hook.py\" --client claude --event Stop",
"timeout": 15
}
]
@@ -1,9 +1,9 @@
# CASAN Codex adoption template (Plan-20)
Drop `hooks.json` → `.codex/hooks.json` and merge `config.toml` into
`.codex/config.toml` in the target repo (the installer does this). It wires the
Codex lifecycle hooks to the CASAN agentic bridge via the thin renderer
`packages/casan-harness/adapters/codex/codex_hook.py`.
`casan init --client codex` merges `hooks.json` into `.codex/hooks.json`. Each
handler calls `.casan/casan-hook.py`, which resolves and verifies the pinned
global harness before dispatching the Codex adapter. No custom `[casan]` TOML
keys are required.
Important Codex-specific facts (Spike-20 §4.2):
@@ -21,6 +21,6 @@ Important Codex-specific facts (Spike-20 §4.2):
disable them and set `CASAN_AGENTIC_INTEGRATION_MODE=managed_hook` through the
managed environment (not the committed config).
The command contract is stable (stdin JSON → exit 0 allow / exit 2 block); the
exact Codex payload key names are pinned during the Wave-3 payload spike, which
is why `codex_hook.py` reads several field aliases defensively.
The command shape follows current Codex hooks: nested command handlers,
`timeout` in seconds, `continue`/`stopReason` for turn events, and exit 2 for a
denied `PreToolUse`. The adapter reads documented fields plus defensive aliases.
@@ -1,18 +1,11 @@
# CASAN Plan-20 Codex config fragment (.codex/config.toml).
# Merge these keys into the target repo's .codex/config.toml. This enables the
# project hooks in hooks.template.json after Codex trust review.
# Optional CASAN Plan-20 Codex config fragment (.codex/config.toml).
# Hooks are enabled by default in current Codex; this explicit feature flag is
# useful only when an organization wants the project intent visible in TOML.
#
# For ENTERPRISE enforcement, the managed policy path pins hooks so a member
# cannot disable them (Spike-20 §4.2, Plan-20 Wave 3.3). In that deployment set
# CASAN_AGENTIC_INTEGRATION_MODE=managed_hook via managed environment/MDM, not
# in this committed file.
[hooks]
enabled = true
# project-local hooks load only after the user accepts the trust prompt.
project_hooks = true
[casan]
# Bridge feature flags — safe defaults (observe first, then enforce per Plan-20 §9).
enforcement_mode = "observe" # observe | enforce
integration_mode = "project_hook"
[features]
hooks = true
@@ -1,18 +1,59 @@
{
"//": "CASAN Plan-20 Codex project hooks. Commit as .codex/hooks.json in the target repo. Codex loads project-local hooks ONLY after a trust review — run `casan doctor --client codex` to confirm the trust/onboarding state (Spike-20 §4.2). The exact key names are pinned during the Wave-3 Codex payload spike; the command contract (stdin JSON -> exit 0 allow / exit 2 block) is stable. No secrets or absolute paths here.",
"version": 1,
"description": "CASAN Plan-20 lifecycle hooks. Review with /hooks; the project bootstrap resolves and verifies the pinned global harness.",
"hooks": {
"UserPromptSubmit": [
{ "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "UserPromptSubmit"], "timeout_ms": 15000 }
{
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event UserPromptSubmit",
"timeout": 15,
"statusMessage": "CASAN admission"
}
]
}
],
"PreToolUse": [
{ "matcher": "*", "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "PreToolUse"], "timeout_ms": 15000 }
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PreToolUse",
"timeout": 15,
"statusMessage": "CASAN policy gate"
}
]
}
],
"PostToolUse": [
{ "matcher": "*", "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "PostToolUse"], "timeout_ms": 15000 }
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event PostToolUse",
"timeout": 15,
"statusMessage": "CASAN evidence"
}
]
}
],
"Stop": [
{ "command": ["python3", "packages/casan-harness/adapters/codex/codex_hook.py", "--event", "Stop"], "timeout_ms": 15000 }
{
"hooks": [
{
"type": "command",
"command": "python3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop",
"commandWindows": "py -3 \"$(git rev-parse --show-toplevel)/.casan/casan-hook.py\" --client codex --event Stop",
"timeout": 15,
"statusMessage": "CASAN finalize"
}
]
}
]
}
}
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Project-local bootstrap for CASAN's globally installed agentic adapters.
This file is intentionally small and stdlib-only. It is the stable command
target committed by `casan init`; the policy implementation remains in the
versioned global CASAN installation. On every hook invocation it:
1. locates the project and loads `.casan/config.json`;
2. applies the project's enforcement/integration settings to the process;
3. resolves the pinned global harness and verifies its live integrity hash;
4. dispatches stdin/stdout to the selected client adapter.
The bootstrap never calls a model.
"""
from __future__ import print_function
import importlib.util
import json
import os
import runpy
import sys
ADAPTERS = {
"claude": ("claude-code", "claude_hook.py"),
"codex": ("codex", "codex_hook.py"),
"vscode-copilot": ("vscode", "vscode_hook.py"),
}
def load_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return {} if default is None else default
def find_project_root(start):
current = os.path.abspath(start or os.getcwd())
while current and current != os.path.dirname(current):
if os.path.isfile(os.path.join(current, ".casan", "config.json")):
return current
current = os.path.dirname(current)
return None
def harness_candidates(lock):
values = []
explicit = os.environ.get("CASAN_HARNESS_ROOT")
if explicit:
values.append(explicit)
install = os.environ.get("CASAN_INSTALL_ROOT")
if install:
values.append(os.path.join(install, "packages", "casan-harness"))
home = os.environ.get("CASAN_HOME")
if home:
values.append(os.path.join(home, "current", "packages", "casan-harness"))
values.append(os.path.join(os.path.expanduser("~"), ".casan", "current",
"packages", "casan-harness"))
local = os.environ.get("LOCALAPPDATA")
if local:
values.append(os.path.join(local, "casan", "current",
"packages", "casan-harness"))
recorded = lock.get("install_root")
if recorded:
values.append(os.path.join(recorded, "packages", "casan-harness"))
return values
def resolve_harness(lock):
for candidate in harness_candidates(lock):
root = os.path.abspath(os.path.expanduser(candidate))
if os.path.isfile(os.path.join(root, "scripts", "python",
"agentic_bridge.py")):
return root
return None
def live_hash(harness):
module_path = os.path.join(harness, "scripts", "python", "harness_hash.py")
spec = importlib.util.spec_from_file_location("casan_harness_hash", module_path)
if spec is None or spec.loader is None:
raise RuntimeError("harness_hash module is unavailable")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.compute(harness)
def emit_failure(client, event, reason, enforce):
"""Render a fail-closed response in the native client contract."""
if client == "claude":
if event == "PreToolUse":
payload = {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "CASAN bootstrap: " + reason,
}}
elif event == "UserPromptSubmit" and enforce:
payload = {"decision": "block", "reason": "CASAN bootstrap: " + reason}
elif event == "UserPromptSubmit":
payload = {"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "[CASAN observed_only] " + reason,
}}
else:
payload = {}
print(json.dumps(payload, ensure_ascii=False))
return 0
if client == "codex":
deny = event == "PreToolUse" or (event == "UserPromptSubmit" and enforce)
if event == "PreToolUse":
payload = {"systemMessage": "CASAN bootstrap denied tool: " + reason}
code = 2
elif deny:
payload = {
"continue": False,
"stopReason": "CASAN bootstrap: " + reason,
"systemMessage": "CASAN admission unavailable",
}
code = 0
else:
payload = {
"continue": True,
"systemMessage": "[CASAN observed_only] " + reason,
}
code = 0
print(json.dumps(payload, ensure_ascii=False))
return code
print(json.dumps({
"decision": "block",
"reason": "CASAN bootstrap: " + reason,
"certification_strength": "observed_only",
}, ensure_ascii=False))
return 2
def main(argv=None):
args = list(argv if argv is not None else sys.argv[1:])
client = None
event = None
index = 0
while index < len(args):
if args[index] == "--client" and index + 1 < len(args):
client = args[index + 1]
index += 2
elif args[index] == "--event" and index + 1 < len(args):
event = args[index + 1]
index += 2
else:
index += 1
if client not in ADAPTERS or not event:
print(json.dumps({"decision": "block",
"reason": "invalid CASAN hook arguments"}))
return 64
root = find_project_root(os.environ.get("CASAN_APP_ROOT") or os.getcwd())
if not root:
return emit_failure(client, event, "project is not initialized", True)
config = load_json(os.path.join(root, ".casan", "config.json"))
mode = str(config.get("enforcement_mode") or "observe")
enforce = mode == "enforce"
enabled = config.get("clients") or []
if client not in enabled:
return emit_failure(client, event, "client is not enabled for this project",
enforce)
os.environ["CASAN_APP_ROOT"] = root
os.environ["CASAN_AGENTIC_BRIDGE_ENABLED"] = "1"
os.environ["CASAN_AGENTIC_ENFORCEMENT_MODE"] = mode
os.environ["CASAN_AGENTIC_INTEGRATION_MODE"] = str(
config.get("integration_mode") or "project_hook")
os.environ["CASAN_AGENTIC_CLIENT_ALLOWLIST"] = ",".join(
{"claude": "claude-code", "codex": "codex",
"vscode-copilot": "vscode"}.get(item, item) for item in enabled)
lock = load_json(os.path.join(root, ".casan", "version.lock"))
harness = resolve_harness(lock)
if not harness:
return emit_failure(client, event, "pinned global harness not found",
enforce)
expected = lock.get("harness_hash")
try:
actual = live_hash(harness)
except Exception as exc: # noqa: BLE001 - boundary must render native failure
return emit_failure(client, event, "integrity check failed: %s" % exc,
enforce)
if not expected or str(expected).startswith("unavailable") or actual != expected:
return emit_failure(client, event, "HARNESS_INTEGRITY_DRIFT", enforce)
os.environ["CASAN_HARNESS_ROOT"] = harness
adapter_dir, adapter_name = ADAPTERS[client]
adapter = os.path.realpath(os.path.join(harness, "adapters", adapter_dir,
adapter_name))
allowed_root = os.path.realpath(os.path.join(harness, "adapters")) + os.sep
if not adapter.startswith(allowed_root) or not os.path.isfile(adapter):
return emit_failure(client, event, "adapter is unavailable", enforce)
old_argv = sys.argv
try:
sys.argv = [adapter, "--event", event]
runpy.run_path(adapter, run_name="__main__")
finally:
sys.argv = old_argv
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -28,15 +28,45 @@ if sh "$REPO_ROOT/install.sh" >/dev/null 2>&1; then pass "install.sh completes";
[[ -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"
"$CASAN" version >/dev/null 2>&1 && pass "casan version works via launcher" || fail "casan version failed"
python3 - "$CASAN_HOME/current/packages/casan-devkit/casan-init.py" <<'PY' \
&& pass "client selector accepts menu numbers, aliases, repeats, and all" \
|| fail "client selector normalization failed"
import importlib.util,sys
spec=importlib.util.spec_from_file_location("casan_init",sys.argv[1])
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
assert m.select_clients(["1,3"],False)==["claude","vscode-copilot"]
assert m.select_clients(["codex","copilot"],False)==["codex","vscode-copilot"]
assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"]
PY
echo "===== ② casan init (config only, no harness copy) ====="
PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json"
( cd "$PROJ" && "$CASAN" init --project my-app --mode enforce >/dev/null 2>&1 ) \
&& pass "casan init completes" || fail "casan init failed"
for f in .casan/config.json .casan/version.lock .casan/agentic.env .claude/settings.json .codex/hooks.json .specify/.gitignore; do
for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do
[[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f"
done
if [[ -d "$PROJ/packages/casan-harness" ]]; then fail "harness was copied into the repo (should not be)"; else pass "harness NOT copied into repo (hybrid model)"; fi
if grep -R -q 'packages/casan-harness/adapters' "$PROJ/.claude/settings.json" "$PROJ/.codex/hooks.json"; then fail "generated hooks still target a repo-local harness"; else pass "generated hooks target the stable project bootstrap"; fi
echo "===== ②b generated hooks execute through the GLOBAL harness ====="
CLAUDE_BEGIN=$(printf '%s' '{"session_id":"hybrid-claude","prompt_id":"t1","prompt":"add a safe helper","cwd":"'"$PROJ"'"}' |
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event UserPromptSubmit)
echo "$CLAUDE_BEGIN" | grep -q '"additionalContext"' && pass "Claude generated hook opens admission" || fail "Claude generated hook failed ($CLAUDE_BEGIN)"
CLAUDE_PRE=$(printf '%s' '{"session_id":"hybrid-claude","tool_name":"Bash","tool_input":{"command":"printf ok"},"cwd":"'"$PROJ"'"}' |
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event PreToolUse)
echo "$CLAUDE_PRE" | grep -q '"permissionDecision": "allow"' && pass "Claude generated hook gates an admitted tool" || fail "Claude pre-tool failed ($CLAUDE_PRE)"
CODEX_BEGIN=$(printf '%s' '{"session_id":"hybrid-codex","turn_id":"t2","prompt":"review this project","cwd":"'"$PROJ"'"}' |
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client codex --event UserPromptSubmit)
echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook opens admission" || fail "Codex generated hook failed ($CODEX_BEGIN)"
python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hook JSON matches the current nested command schema" || fail "Codex hook JSON schema is stale"
import json,sys
d=json.load(open(sys.argv[1]))
handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]]
assert handlers and all(h.get("type") == "command" for h in handlers)
assert all(isinstance(h.get("command"), str) for h in handlers)
assert all(isinstance(h.get("timeout"), int) and "timeout_ms" not in h for h in handlers)
PY
echo "===== ③ version.lock pins the installed harness ====="
LOCK_HASH="$(python3 -c 'import json;print(json.load(open("'"$PROJ"'/.casan/version.lock"))["harness_hash"])')"
@@ -45,10 +75,17 @@ REC_HASH="$(cat "$CASAN_HOME/current/.harness-hash")"
echo "===== ④ verify-harness: ok before tamper, drift after ====="
( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) && pass "verify-harness OK on a clean install" || fail "verify-harness reported drift on clean install"
cp "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh" "$WORK/security-check.clean"
echo "# tampered $(date)" >> "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh"
VRC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>"$WORK/vh.err" ) || VRC=$?
[[ "$VRC" -eq 3 ]] && pass "verify-harness detects tamper (rc=3)" || fail "tamper not detected (rc=$VRC)"
grep -q "HARNESS_INTEGRITY_DRIFT" "$WORK/vh.err" && pass "drift message emitted" || fail "no drift message"
cp "$WORK/security-check.clean" "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh"
cp "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py" "$WORK/codex-hook.clean"
echo "# adapter tamper" >> "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py"
ARC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) || ARC=$?
[[ "$ARC" -eq 3 ]] && pass "integrity pin includes client adapters" || fail "adapter tamper was not detected"
cp "$WORK/codex-hook.clean" "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py"
echo "===== ⑤ agentic bridge runs against the PROJECT state via GLOBAL harness ====="
BR="$CASAN_HOME/current/packages/casan-harness/scripts/python/agentic_bridge.py"
@@ -63,6 +100,8 @@ PROJ2="$WORK/proj2/Some_App"; mkdir -p "$PROJ2"
( cd "$PROJ2" && "$CASAN" init >/dev/null 2>&1 ) && pass "init works with a defaulted project id" || fail "init default id failed"
PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["project_id"])' 2>/dev/null)
[[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)"
MODE=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["enforcement_mode"])' 2>/dev/null)
[[ "$MODE" == "enforce" ]] && pass "production init defaults to enforce mode" || fail "default mode is not enforce ($MODE)"
echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) ====="
EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex"
@@ -81,14 +120,17 @@ PY
}
[ -f "$EXP/.claude/agents/reviewer.md" ] && [ -f "$EXP/.claude/skills/deploy.md" ] && pass "existing agents/skills preserved" || fail "agents/skills lost"
py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "existing Claude hook preserved (not clobbered)" || fail "existing hook clobbered"
py_check "$EXP/.claude/settings.json" "claude_hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged"
py_check "$EXP/.claude/settings.json" "casan-hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged"
[ "$(python3 -c 'import json;print(json.load(open("'"$EXP"'/.claude/settings.json")).get("model"))')" = "claude-opus-4-8" ] && pass "unrelated settings key (model) preserved" || fail "model key lost"
grep -q '\[mytool\]' "$EXP/.codex/config.toml" && pass "existing codex [mytool] preserved" || fail "mytool lost"
[ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]"
# idempotent
( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 )
N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "claude_hook.py" in h["command"]))')
N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "casan-hook.py" in h["command"]))')
[ "$N" = "1" ] && pass "re-running init is idempotent (no duplicate CASAN hook)" || fail "init duplicated CASAN hook (n=$N)"
( cd "$EXP" && "$CASAN" init --project existing-app --client claude >/dev/null 2>&1 )
if py_check "$EXP/.codex/hooks.json" "casan-hook.py"; then fail "re-selecting Claude left the CASAN Codex hook enabled"; else pass "re-running init synchronizes disabled clients"; fi
py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "client re-selection still preserves user hooks" || fail "client re-selection removed user hooks"
echo "===== ⑧ level-aware install + init (packaging/levels.json) ====="
# core install: no devkit, casan init unavailable
@@ -129,6 +171,46 @@ GRC=0; ( cd "$HUB" && "$DKC" init --project hub >/dev/null 2>"$WORK/hub.err" ) |
[ ! -f "$HUB/.claude/settings.json" ] && pass "no hooks written into the refused hub" || fail "hooks written into source hub"
( cd "$HUB" && "$DKC" init --project hub --force >/dev/null 2>&1 ) && pass "--force overrides the source-hub guard" || fail "--force did not override guard"
echo "===== ⑩ VS Code/Copilot @casan packaging + install + doctor ====="
FAKE_BIN="$WORK/fake-bin"; mkdir -p "$FAKE_BIN"
cat > "$FAKE_BIN/code" <<'EOF'
#!/usr/bin/env bash
if [[ "${1:-}" == "--list-extensions" ]]; then
printf '%s\n' 'fpt-casan.casan-governed-chat' 'GitHub.copilot' 'GitHub.copilot-chat'
exit 0
fi
printf '%s\n' "$*" >> "$CASAN_FAKE_CODE_LOG"
exit 0
EOF
chmod +x "$FAKE_BIN/code"
VSP="$WORK/vscode-project"; mkdir -p "$VSP"
export CASAN_FAKE_CODE_LOG="$WORK/code.log"
( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" init --project vscode-project --client vscode-copilot --mode enforce --vscode-install yes >/dev/null 2>&1 ) \
&& pass "init enables the selected VS Code/Copilot integration" || fail "VS Code/Copilot init failed"
grep -q -- '--install-extension .*casan-governed-chat.vsix --force' "$CASAN_FAKE_CODE_LOG" \
&& pass "init installs the packaged CASAN VSIX through code CLI" || fail "VSIX install was not invoked"
VSIX1="$WORK/one.vsix"; VSIX2="$WORK/two.vsix"
CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX1" >/dev/null
CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX2" >/dev/null
cmp -s "$VSIX1" "$VSIX2" && pass "VSIX packaging is deterministic" || fail "VSIX package bytes drift between builds"
python3 - "$VSIX1" <<'PY' && pass "VSIX contains the required production extension assets" || fail "VSIX structure is invalid"
import zipfile,sys
with zipfile.ZipFile(sys.argv[1]) as z:
names=set(z.namelist())
assert {"[Content_Types].xml","extension.vsixmanifest","extension/package.json","extension/extension.js"} <= names
PY
python3 - "$VSP/.vscode/extensions.json" <<'PY' && pass "VS Code recommendations include Copilot + CASAN" || fail "VS Code recommendations incomplete"
import json,sys
r=set(json.load(open(sys.argv[1]))["recommendations"])
assert {"GitHub.copilot","GitHub.copilot-chat","fpt-casan.casan-governed-chat"} <= r
PY
CASAN_HOME="$DK_HOME" node "$REPO_ROOT/packages/casan-devkit/tests/vscode-extension-tests.js" \
"$VSP" "$REPO_ROOT/packages/casan-harness/adapters/vscode/extension/extension.js" \
&& pass "VS Code @casan handler completes the real governed lifecycle" \
|| fail "VS Code @casan handler contract failed"
( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" doctor --client vscode-copilot >/dev/null 2>&1 ) \
&& pass "doctor proves the VS Code/Copilot adapter and installed extension" || fail "VS Code doctor failed"
echo ""
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -0,0 +1,106 @@
'use strict';
// Dependency-free contract test for the packaged VS Code @casan participant.
// It mocks only the stable VS Code host surface and runs the real project
// bootstrap + global harness adapters in a child process.
const assert = require('assert');
const Module = require('module');
const path = require('path');
const project = path.resolve(process.argv[2]);
const extensionPath = path.resolve(process.argv[3]);
let registeredHandler;
const streamed = [];
class CancellationError extends Error {}
class CancellationTokenSource {
constructor() {
this.token = {
isCancellationRequested: false,
onCancellationRequested: () => ({ dispose() {} })
};
}
dispose() {}
}
const vscodeMock = {
version: '1.98.0-test',
workspace: {
workspaceFolders: [{ uri: { fsPath: project } }],
isTrusted: true,
getConfiguration() {
return {
get(key, fallback) {
if (key === 'pythonPath') return process.env.PYTHON || 'python3';
if (key === 'hookTimeoutMs') return 30000;
return fallback;
}
};
}
},
chat: {
createChatParticipant(_id, handler) {
registeredHandler = handler;
return { dispose() {} };
}
},
LanguageModelChatMessage: {
User(value) { return { role: 'user', value }; }
},
CancellationError,
CancellationTokenSource
};
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'vscode') return vscodeMock;
return originalLoad.call(this, request, parent, isMain);
};
async function main() {
try {
const extension = require(extensionPath);
const subscriptions = [];
extension.activate({ subscriptions });
assert.strictEqual(typeof registeredHandler, 'function');
assert.strictEqual(subscriptions.length, 1);
const token = {
isCancellationRequested: false,
onCancellationRequested: () => ({ dispose() {} })
};
const request = {
prompt: 'Explain the CASAN integration without changing files.',
model: {
id: 'copilot-test-model',
async sendRequest(messages) {
assert.strictEqual(messages.length, 2);
return {
text: (async function* responseText() {
yield 'Governed ';
yield 'response';
})()
};
}
}
};
const stream = {
progress(value) { streamed.push(`progress:${value}`); },
markdown(value) { streamed.push(value); }
};
const result = await registeredHandler(request, {}, stream, token);
assert.strictEqual(streamed.filter(value => !value.startsWith('progress:')).join(''),
'Governed response');
assert.strictEqual(result.metadata.certified, true);
assert.strictEqual(result.metadata.certificationStrength, 'casan_owned');
assert.ok(result.metadata.traceId);
} finally {
Module._load = originalLoad;
}
}
main().catch(error => {
console.error(error.stack || error);
process.exit(1);
});
+35 -188
View File
@@ -1,204 +1,51 @@
#requires -Version 5.1
<#
.SYNOPSIS
CASAN Plan-20 agentic-client installer for Windows PowerShell.
Compatibility wrapper for the Plan-21 global `casan init` workflow.
.DESCRIPTION
Installs, verifies (doctor) or removes the CASAN agentic-client integration
(Claude Code and/or Codex project hooks) in a target repository — from a
clean clone, with NO manual file copying. Every created file is recorded in a
manifest so uninstall never touches a user's own config.
The hooks call the CASAN agentic bridge, which never runs a model (Claude/
Codex remains the sole model executor). See:
docs/casan/CASAN_AGENTIC_CLIENTS_WINDOWS.md
docs/casan/CASAN_AGENTIC_CLIENT_SECURITY.md
.PARAMETER Action
install | doctor | uninstall (default: install)
.PARAMETER Client
claude | codex | all (default: all)
.PARAMETER Target
Target repository root. Default: the repo this script lives in.
.PARAMETER Mode
observe | enforce (default: observe) — sets the bridge
enforcement mode written into the target's .casan/agentic.env.
.EXAMPLE
pwsh packages/casan-devkit/windows/install-agentic.ps1 -Client claude -Mode enforce
.EXAMPLE
pwsh packages/casan-devkit/windows/install-agentic.ps1 -Action doctor -Client all
New deployments should run `install.ps1` once and then `casan init` in each
project. This wrapper remains for existing automation, but delegates all
merge, pin, bootstrap, doctor, and VS Code behavior to the canonical CLI.
#>
[CmdletBinding()]
param(
[ValidateSet('install', 'doctor', 'uninstall')] [string]$Action = 'install',
[ValidateSet('claude', 'codex', 'all')] [string]$Client = 'all',
[string]$Target,
[ValidateSet('observe', 'enforce')] [string]$Mode = 'observe'
[ValidateSet('install', 'doctor', 'uninstall')] [string]$Action = 'install',
[ValidateSet('claude', 'codex', 'vscode-copilot', 'all')] [string]$Client = 'all',
[string]$Target = (Get-Location).Path,
[ValidateSet('observe', 'enforce')] [string]$Mode = 'enforce'
)
$ErrorActionPreference = 'Stop'
# ── Resolve roots ────────────────────────────────────────────────────────────
# This script lives at <repo>/packages/casan-devkit/windows/install-agentic.ps1
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = (Resolve-Path (Join-Path $ScriptDir '..\..\..')).Path
if (-not $Target) { $Target = $RepoRoot }
function Resolve-Casan {
$command = Get-Command casan -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$home = if ($env:CASAN_HOME) { $env:CASAN_HOME } else { Join-Path $env:LOCALAPPDATA 'casan' }
$fallback = Join-Path $home 'bin\casan.cmd'
if (Test-Path $fallback) { return $fallback }
throw 'CASAN global launcher not found. Run install.ps1 first, then restart the shell.'
}
$casan = Resolve-Casan
$Target = (Resolve-Path $Target).Path
$HarnessRel = 'packages\casan-harness'
$Bridge = Join-Path $Target "$HarnessRel\scripts\python\agentic_bridge.py"
$ClaudeTpl = Join-Path $Target "$HarnessRel\adapters\claude-code\settings.template.json"
$CodexHooks = Join-Path $Target "$HarnessRel\adapters\codex\hooks.template.json"
$CodexConf = Join-Path $Target "$HarnessRel\adapters\codex\config.template.toml"
$ManifestDir = Join-Path $Target '.casan'
$Manifest = Join-Path $ManifestDir 'agentic-install-manifest.json'
function Resolve-Python {
foreach ($cand in @('python3', 'python', 'py')) {
$cmd = Get-Command $cand -ErrorAction SilentlyContinue
if ($cmd) {
try { & $cmd.Source --version *> $null; if ($LASTEXITCODE -eq 0) { return $cmd.Source } } catch {}
}
}
throw 'No working Python 3 interpreter found on PATH (need python3/python/py).'
}
function Write-Info($m) { Write-Host "[casan] $m" }
function Write-Ok($m) { Write-Host "[casan] OK $m" -ForegroundColor Green }
function Write-Warn2($m) { Write-Host "[casan] !! $m" -ForegroundColor Yellow }
function Write-Err($m) { Write-Host "[casan] ERR $m" -ForegroundColor Red }
function Load-Manifest {
if (Test-Path $Manifest) {
try { return Get-Content -Raw -Path $Manifest | ConvertFrom-Json } catch {}
}
return [PSCustomObject]@{ created = @(); clients = @() }
}
function Save-Manifest($m) {
if (-not (Test-Path $ManifestDir)) { New-Item -ItemType Directory -Force -Path $ManifestDir | Out-Null }
$m | ConvertTo-Json -Depth 6 | Set-Content -Path $Manifest -Encoding UTF8
}
# Copy a template into place. Never overwrite a user's existing config silently:
# back it up first and record both in the manifest so uninstall can restore it.
function Install-File($src, $dst, $manifest) {
$dstDir = Split-Path -Parent $dst
if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Force -Path $dstDir | Out-Null }
if (Test-Path $dst) {
$backup = "$dst.casan-bak"
if (-not (Test-Path $backup)) {
Copy-Item -Path $dst -Destination $backup -Force
Write-Warn2 "existing $([System.IO.Path]::GetFileName($dst)) backed up to $([System.IO.Path]::GetFileName($backup))"
}
}
Copy-Item -Path $src -Destination $dst -Force
$entry = @{ path = $dst; from = $src }
$manifest.created = @($manifest.created + $dst | Select-Object -Unique)
Write-Ok "installed $dst"
}
function Do-Install {
if (-not (Test-Path $Bridge)) { throw "Bridge not found at $Bridge — is this a CASAN repo?" }
$py = Resolve-Python
$manifest = Load-Manifest
$clients = @()
if ($Client -in @('claude', 'all')) {
Install-File $ClaudeTpl (Join-Path $Target '.claude\settings.json') $manifest
$clients += 'claude'
}
if ($Client -in @('codex', 'all')) {
Install-File $CodexHooks (Join-Path $Target '.codex\hooks.json') $manifest
Install-File $CodexConf (Join-Path $Target '.codex\config.toml') $manifest
$clients += 'codex'
Write-Warn2 'Codex loads project hooks only AFTER you accept its trust prompt — open the repo in Codex once to complete onboarding.'
}
# Bridge feature flags for the target (sourced by the user's shell/session).
$envFile = Join-Path $Target '.casan\agentic.env'
@(
"# CASAN Plan-20 agentic bridge flags (Windows). Source before starting the client.",
"CASAN_AGENTIC_BRIDGE_ENABLED=1",
"CASAN_AGENTIC_ENFORCEMENT_MODE=$Mode",
"CASAN_AGENTIC_INTEGRATION_MODE=project_hook"
) | Set-Content -Path $envFile -Encoding UTF8
$manifest.created = @($manifest.created + $envFile | Select-Object -Unique)
Write-Ok "wrote flags -> $envFile (mode=$Mode)"
$manifest.clients = @($clients | Select-Object -Unique)
Save-Manifest $manifest
Write-Info 'Running doctor to verify...'
Do-Doctor
Write-Info "Install complete. Set CASAN_AGENTIC_ENFORCEMENT_MODE=enforce when ready to certify turns."
}
function Do-Doctor {
$py = Resolve-Python
Write-Info "python : $py"
Write-Info "target : $Target"
Write-Info "bridge : $Bridge"
if (-not (Test-Path $Bridge)) { Write-Err 'bridge missing'; exit 1 }
# Core bridge self-diagnostics.
& $py $Bridge doctor
if ($LASTEXITCODE -ne 0) { Write-Err 'bridge doctor reported a problem'; }
$ok = $true
if ($Client -in @('claude', 'all')) {
if (Test-Path (Join-Path $Target '.claude\settings.json')) { Write-Ok '.claude\settings.json present' }
else { Write-Warn2 '.claude\settings.json missing (run install)'; $ok = $false }
}
if ($Client -in @('codex', 'all')) {
if (Test-Path (Join-Path $Target '.codex\hooks.json')) { Write-Ok '.codex\hooks.json present (Codex trust review still required)' }
else { Write-Warn2 '.codex\hooks.json missing (run install)'; $ok = $false }
}
# A real begin->finalize smoke turn against an isolated state dir.
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("casan-agentic-" + [guid]::NewGuid().ToString('N'))
$env:CASAN_STATE_ROOT = (Join-Path $tmp '.specify')
try {
$begin = '{"op":"begin","client":"claude-code","project":"' + ($Target -replace '\\','/') + '","session":"doctor","prompt":"doctor smoke","integration_mode":"project_hook"}'
$resp = $begin | & $py $Bridge run | ConvertFrom-Json
if ($resp.decision -eq 'allow') { Write-Ok "smoke begin admitted (strength=$($resp.certification_strength))" }
else { Write-Warn2 "smoke begin decision=$($resp.decision)"; }
} finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
Remove-Item Env:\CASAN_STATE_ROOT -ErrorAction SilentlyContinue
}
if ($ok) { Write-Ok 'doctor passed' } else { Write-Warn2 'doctor found missing config' }
}
function Do-Uninstall {
$manifest = Load-Manifest
if (-not $manifest.created -or $manifest.created.Count -eq 0) {
Write-Warn2 'no install manifest found — nothing to remove'
return
}
foreach ($f in $manifest.created) {
if (Test-Path $f) {
Remove-Item -Force $f
Write-Ok "removed $f"
}
$backup = "$f.casan-bak"
if (Test-Path $backup) {
Move-Item -Force $backup $f
Write-Ok "restored user's original $f from backup"
}
}
Remove-Item -Force $Manifest -ErrorAction SilentlyContinue
Write-Info 'Uninstall complete — your own (non-CASAN) config was preserved.'
}
switch ($Action) {
'install' { Do-Install }
'doctor' { Do-Doctor }
'uninstall' { Do-Uninstall }
'install' {
& $casan init --target $Target --client $Client --mode $Mode --non-interactive --vscode-install auto
exit $LASTEXITCODE
}
'doctor' {
& $casan doctor --target $Target --client $Client
exit $LASTEXITCODE
}
'uninstall' {
# Safe compatibility behavior: remove only CASAN handlers/recommendations.
# Runtime evidence and pin/config are retained for audit/re-adoption.
& $casan init --target $Target --client none --level 1 --non-interactive --vscode-install no
if ($LASTEXITCODE -eq 0) {
Write-Host '[casan] integrations disabled; audit state and .casan pin retained.'
}
exit $LASTEXITCODE
}
}