feat: harden CASAN production install lifecycle

This commit is contained in:
thanhnv
2026-07-24 11:11:44 +07:00
parent c7671ab198
commit 114340c719
48 changed files with 1036 additions and 165 deletions
@@ -112,7 +112,7 @@ const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
const PATCH_EXECUTOR_CLI = join(HARNESS_BIN, 'goal-patch-executor.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'config', 'project-registry.json');
const PROJECT_SCAFFOLDER = join(APP_ROOT, 'packages', 'casan-devkit', 'project-scaffold.py');
function parseJson<T>(value: string): T | null {
@@ -8,7 +8,7 @@ import { GoalsService } from '../src/goals/goals.service.js';
const admin = { actor: 'goal-admin', role: 'org-admin', project: 'default', tenant: 'goal-test' };
const root = resolve(import.meta.dirname, '../../../..');
const registry = resolve(root, 'packages/casan-harness/level5/project-registry.json');
const registry = resolve(root, 'packages/casan-harness/config/project-registry.json');
test('goal project selector exposes only active allowlisted registry entries', () => {
const result = new GoalsService().projects(admin);
+363 -5
View File
@@ -56,6 +56,141 @@ VSCODE_EXTENSION_IDS = {
}
PROMPT_MARKER_START = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
PROMPT_MARKER_END = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->"
CLIENT_LABELS = {
"claude": "Claude Code",
"codex": "Codex",
"vscode-copilot": "VS Code / @casan",
}
def _color(code, text):
if (not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None or
os.environ.get("TERM") == "dumb"):
return text
return "\033[%sm%s\033[0m" % (code, text)
def _mark(ok):
return _color("32" if ok else "31", "✓" if ok else "✗")
def _warn_mark():
return _color("33", "!")
def _heading(text):
print(_color("1;36", text))
def _details(rows):
width = max((len(label) for label, _value in rows), default=0)
for label, value in rows:
print(" %-*s %s" % (width, label, value))
def _client_names(clients):
return ", ".join(CLIENT_LABELS.get(client, client) for client in clients) or "None"
def _emit_json_or_human(args, payload, renderer):
if getattr(args, "json", False):
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
renderer(payload)
def _render_init(result):
_heading("%s CASAN initialized" % _mark(True))
_details([
("Project", result["project_id"]),
("Location", result["target"]),
("Level", "%s (%s)" % (
result["target_level_name"].capitalize(), result["target_level"])),
("Mode", result["enforcement_mode"]),
("Clients", _client_names(result["clients"])),
("Files", "%d created or updated" % len(result["created"])),
("Harness", result["harness_version"]),
])
print()
print(_color("1", "Integrations"))
for path, status in result["hook_merges"].items():
ok = status not in (None, "absent")
print(" %s %-26s %s" % (_mark(ok), path, status or "unavailable"))
extension = result["vscode_extension"]
if extension.get("status") not in ("skipped",):
ok = extension.get("status") == "installed"
print(" %s %-26s %s" % (
_mark(ok), "VS Code extension", extension.get("status")))
print()
print(_color("1", "Next steps"))
print(" 1. Run `casan doctor`")
if "codex" in result["clients"]:
print(" 2. In Codex, open `/hooks` and trust this project's hook.")
def _render_verify(result):
ok = result["status"] == "ok"
_heading("%s Harness integrity %s" % (
_mark(ok), "verified" if ok else "check failed"))
_details([
("Version", str(result.get("harness_version_now") or "unknown")),
("Location", str(result.get("harness_root") or "unavailable")),
("Expected", str(result.get("expected") or "unavailable")),
("Actual", str(result.get("actual") or "unavailable")),
])
def _render_level(result):
_heading("CASAN packaging level")
_details([
("Installed", str(result.get("installed_level") or "unknown")),
("Project", ("%s (%s)" % (
result.get("project_target_level_name"),
result.get("project_target_level")))
if result.get("project_target_level") else "not initialized"),
("Status", str(result.get("project_level_status") or "unknown")),
])
print()
for level, description in result["levels"].items():
print(" %-14s %s" % (level, description))
def _render_doctor(result):
ready = result["status"] == "ready"
_heading("CASAN Doctor")
print(" %s Harness integrity" % _mark(result["integrity"]["ok"]))
print(" %s Project bootstrap" % _mark(result["bootstrap"]["ok"]))
for client, item in result["client_checks"].items():
label = CLIENT_LABELS.get(client, client)
print(" %s %s" % (_mark(item.get("ready", False)), label))
reason = item.get("smoke", {}).get("reason")
if reason and not item.get("ready"):
print(" %s" % reason)
if result["warnings"]:
print()
print(_color("1;33", "Warnings"))
for warning in result["warnings"]:
print(" %s %s" % (_warn_mark(), warning))
print()
status = _color("1;32", "READY") if ready else _color("1;31", "NOT READY")
print("Status: %s" % status)
def _render_uninstall(result):
_heading("%s CASAN removed from project" % _mark(True))
_details([
("Project", result.get("project_id") or "unknown"),
("Location", result["target"]),
("Hooks", "%d integration files cleaned" % len(result["hook_changes"])),
("Files", "%d CASAN-owned files removed" % len(result["removed"])),
("Evidence", "removed" if result["purged"] else "retained"),
("VS Code extension", result["vscode_extension"]["status"]),
])
if result["retained"]:
print()
print(_color("1", "Retained for safety"))
for path in result["retained"]:
print(" - %s" % path)
@contextmanager
@@ -225,6 +360,8 @@ def _load_json_or(path, default):
def _backup_once(path, backups):
if backups is None:
return
bak = path + ".casan-bak"
if os.path.exists(path) and not os.path.exists(bak):
with open(path, "r", encoding="utf-8", errors="replace") as fh:
@@ -670,10 +807,22 @@ def cmd_init(args):
hhash, hsource = compute_harness_hash(harness)
created = []
backups = []
previous_manifest = _load_json_or(
os.path.join(target, ".casan", "init-manifest.json"), {})
owned_files = {
str(path).replace("\\", "/")
for path in previous_manifest.get("owned_files", [])
if isinstance(path, str)
}
def created_add(p):
created.append(os.path.relpath(p, target))
def mark_owned_if_absent(p):
if not os.path.exists(p):
owned_files.add(
os.path.relpath(p, target).replace(os.sep, "/"))
# ── .casan/config.json ──
cfg_dir = os.path.join(target, ".casan")
cfg = {
@@ -696,6 +845,7 @@ def cmd_init(args):
"target_level_name": lvl_name,
}
p = os.path.join(cfg_dir, "config.json")
mark_owned_if_absent(p)
_write(p, json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
# ── .casan/version.lock (pin) ──
@@ -708,6 +858,7 @@ def cmd_init(args):
"recorded_at": now_iso(),
}
p = os.path.join(cfg_dir, "version.lock")
mark_owned_if_absent(p)
_write(p, json.dumps(lock, ensure_ascii=False, indent=2) + "\n", backups); created_add(p)
# ── .casan/agentic.env (Plan-20 flags) ──
@@ -720,6 +871,7 @@ def cmd_init(args):
"",
]
p = os.path.join(cfg_dir, "agentic.env")
mark_owned_if_absent(p)
_write(p, "\n".join(env_lines), backups); created_add(p)
# Stable project-local bootstrap. It loads the config above, resolves the
@@ -730,6 +882,7 @@ def cmd_init(args):
if not os.path.isfile(bootstrap_source):
sys.stderr.write("casan init: missing project hook bootstrap template\n")
return 1
mark_owned_if_absent(bootstrap_target)
with open(bootstrap_source, "r", encoding="utf-8") as fh:
_write(bootstrap_target, fh.read(), backups)
try:
@@ -746,6 +899,7 @@ def cmd_init(args):
os.makedirs(os.path.join(specify, "logs"), exist_ok=True)
gi = os.path.join(specify, ".gitignore")
if not os.path.exists(gi):
mark_owned_if_absent(gi)
_write(gi, "# CASAN runtime state — do not commit\nlogs/\nstate/\n", backups); created_add(gi)
# ── Plan-20 client hooks — MERGED into any existing config, never clobbered.
@@ -756,6 +910,7 @@ def cmd_init(args):
claude_dst = os.path.join(target, ".claude", "settings.json")
if "claude" in clients:
dst = claude_dst
mark_owned_if_absent(dst)
remove_json_hooks(dst, "claude_hook.py", backups)
r = merge_json_hooks(dst, os.path.join(ad, "claude-code", "settings.template.json"),
"casan-hook.py", backups)
@@ -770,6 +925,7 @@ def cmd_init(args):
codex_hooks_dst = os.path.join(target, ".codex", "hooks.json")
if "codex" in clients:
dsth = codex_hooks_dst
mark_owned_if_absent(dsth)
remove_json_hooks(dsth, "codex_hook.py", backups)
r = merge_json_hooks(dsth, os.path.join(ad, "codex", "hooks.template.json"),
"casan-hook.py", backups)
@@ -787,6 +943,7 @@ def cmd_init(args):
os.path.join(target, ".codex", "config.toml"), backups)
vscode_file = os.path.join(target, ".vscode", "extensions.json")
mark_owned_if_absent(vscode_file)
vscode_merge = merge_vscode_recommendations(vscode_file, clients, backups)
if vscode_merge not in ("unchanged", "absent"):
created_add(vscode_file)
@@ -812,24 +969,31 @@ def cmd_init(args):
ci_src = os.path.join(dk, "templates", "gitea-workflow", "ci.yml")
ci_dst = os.path.join(target, ".gitea", "workflows", "casan-ci.yml")
if _copy_if_absent(ci_src, ci_dst, None, target, created):
owned_files.add(os.path.relpath(
ci_dst, target).replace(os.sep, "/"))
level_extras.append(".gitea/workflows/casan-ci.yml")
dom_src = os.path.join(dk, "templates", "domain-pack")
dom_dst = os.path.join(target, "apps", project, "domain")
created_before_domain = len(created)
n = _copy_tree_missing(dom_src, dom_dst, target, created)
if n:
owned_files.update(
path.replace(os.sep, "/")
for path in created[created_before_domain:])
level_extras.append("apps/%s/domain (%d files)" % (project, n))
# ── manifest (so uninstall/verify know what init created) ──
manifest = {
"created": created,
"backups": [os.path.relpath(b, target) for b in backups],
"owned_files": sorted(owned_files),
"project_id": project,
"at": now_iso(),
}
p = os.path.join(cfg_dir, "init-manifest.json")
_write(p, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", backups)
print(json.dumps({
result = {
"status": "initialized",
"project_id": project,
"target": target,
@@ -846,7 +1010,8 @@ def cmd_init(args):
"level_extras": level_extras,
"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))
}
_emit_json_or_human(args, result, _render_init)
if legacy_migration.get("manual_review"):
sys.stderr.write(
"casan init: WARNING — legacy CASAN prose outside managed markers "
@@ -885,7 +1050,7 @@ def cmd_verify(args):
"harness_version_now": harness_version(harness),
"harness_root": harness,
}
print(json.dumps(result, ensure_ascii=False, indent=2))
_emit_json_or_human(args, result, _render_verify)
if not ok:
sys.stderr.write("HARNESS_INTEGRITY_DRIFT — the resolved harness does not match the "
"project pin. The global harness changed or was tampered.\n")
@@ -919,7 +1084,7 @@ def cmd_level(args):
"4 enterprise": "future — not shipped",
},
}
print(json.dumps(out, ensure_ascii=False, indent=2))
_emit_json_or_human(args, out, _render_level)
return 0
@@ -1091,10 +1256,180 @@ def cmd_doctor(args):
"with Plan-20 hooks and needs manual review: %s." %
", ".join(legacy_conflicts))
checks["status"] = "ready" if ready else "not_ready"
print(json.dumps(checks, ensure_ascii=False, indent=2))
_emit_json_or_human(args, checks, _render_doctor)
return 0 if ready else 2
def _clean_integration_json(path, metadata_key, metadata_prefix, remove_if_empty):
"""Remove CASAN template metadata and delete a CASAN-created empty file."""
if not os.path.isfile(path):
return False
doc = _load_json_or(path, None)
if not isinstance(doc, dict):
return False
metadata = doc.get(metadata_key)
if isinstance(metadata, str) and metadata.startswith(metadata_prefix):
doc.pop(metadata_key, None)
hooks = doc.get("hooks")
if isinstance(hooks, dict) and not hooks:
doc.pop("hooks", None)
recommendations = doc.get("recommendations")
if isinstance(recommendations, list) and not recommendations:
doc.pop("recommendations", None)
with owner_writable(path):
if not doc and remove_if_empty:
os.unlink(path)
else:
with open(path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(doc, ensure_ascii=False, indent=2) + "\n")
return True
def _remove_owned_file(path, removed, target):
if not os.path.isfile(path) and not os.path.islink(path):
return
with owner_writable(path):
os.unlink(path)
removed.append(os.path.relpath(path, target).replace(os.sep, "/"))
def _prune_empty_directory(path):
try:
os.rmdir(path)
except OSError:
pass
def _uninstall_vscode_extension(enabled):
if not enabled:
return {
"status": "retained_shared",
"reason": "use --remove-vscode-extension to remove it from this machine",
}
code = shutil.which("code")
if not code:
return {"status": "not_removed", "reason": "code_cli_not_found"}
result = subprocess.run(
[code, "--uninstall-extension", "fpt-casan.casan-governed-chat"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
return {
"status": "uninstalled" if result.returncode == 0 else "failed",
"command": code,
"detail": (result.stdout or result.stderr).strip(),
}
def cmd_uninstall(args):
"""Remove one project's CASAN adoption without clobbering user config."""
target = os.path.abspath(args.target or os.getcwd())
config_path = os.path.join(target, ".casan", "config.json")
manifest_path = os.path.join(target, ".casan", "init-manifest.json")
config = _load_json_or(config_path, {})
manifest = _load_json_or(manifest_path, {})
specify_gitignore = os.path.join(target, ".specify", ".gitignore")
try:
with open(specify_gitignore, "r", encoding="utf-8") as fh:
has_casan_evidence = fh.read().startswith(
"# CASAN runtime state — do not commit")
except OSError:
has_casan_evidence = False
if (not config and not manifest and
not (args.purge and has_casan_evidence)):
sys.stderr.write(
"casan uninstall: project is not initialized; nothing to remove.\n")
return 1
created = {
str(path).replace("\\", "/")
for path in manifest.get("created", [])
if isinstance(path, str)
}
owned_files = {
str(path).replace("\\", "/")
for path in manifest.get("owned_files", [])
if isinstance(path, str)
}
hook_changes = {}
integration_files = (
(".claude/settings.json", "claude_hook.py", "casan-hook.py"),
(".codex/hooks.json", "codex_hook.py", "casan-hook.py"),
)
for relative, legacy_marker, current_marker in integration_files:
path = os.path.join(target, *relative.split("/"))
legacy = remove_json_hooks(path, legacy_marker, None)
current = remove_json_hooks(path, current_marker, None)
status = "removed" if "removed" in (legacy, current) else current
hook_changes[relative] = status
codex_config = os.path.join(target, ".codex", "config.toml")
hook_changes[".codex/config.toml"] = clean_legacy_codex_config(
codex_config, None)
vscode_path = os.path.join(target, ".vscode", "extensions.json")
hook_changes[".vscode/extensions.json"] = merge_vscode_recommendations(
vscode_path, [], None)
_clean_integration_json(
os.path.join(target, ".claude", "settings.json"),
"//", "CASAN Plan-20", ".claude/settings.json" in owned_files)
_clean_integration_json(
os.path.join(target, ".codex", "hooks.json"),
"description", "CASAN Plan-20", ".codex/hooks.json" in owned_files)
_clean_integration_json(
vscode_path, "_casan_unused", "",
".vscode/extensions.json" in owned_files)
removed = []
for relative in (
".casan/config.json",
".casan/version.lock",
".casan/agentic.env",
".casan/casan-hook.py",
".casan/init-manifest.json"):
_remove_owned_file(
os.path.join(target, *relative.split("/")), removed, target)
if args.purge:
for relative in (".specify/logs", ".specify/state"):
path = os.path.join(target, *relative.split("/"))
if os.path.isdir(path):
with owner_writable(path):
shutil.rmtree(path)
removed.append(relative)
if has_casan_evidence:
_remove_owned_file(specify_gitignore, removed, target)
for relative in (
".casan", ".claude", ".codex", ".vscode", ".specify"):
_prune_empty_directory(os.path.join(target, relative))
retained = []
if not args.purge and os.path.isdir(os.path.join(target, ".specify")):
retained.append(".specify/ runtime evidence (use --purge to remove)")
for item in manifest.get("created", []):
normalized = str(item).replace("\\", "/")
if (normalized.startswith(".gitea/") or
normalized.startswith("apps/")):
retained.append(normalized + " (may contain project changes)")
backups = manifest.get("backups", [])
if backups:
retained.append("%d .casan-bak backup(s)" % len(backups))
result = {
"status": "uninstalled",
"project_id": config.get("project_id") or manifest.get("project_id"),
"target": target,
"hook_changes": hook_changes,
"removed": removed,
"purged": bool(args.purge),
"vscode_extension": _uninstall_vscode_extension(
args.remove_vscode_extension),
"retained": retained,
}
_emit_json_or_human(args, result, _render_uninstall)
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog="casan-init", description="CASAN hybrid adoption")
sub = parser.add_subparsers(dest="cmd")
@@ -1122,21 +1457,42 @@ def main(argv=None):
pi.add_argument("--force", action="store_true",
help="bypass the source-hub safety guard (adopt CASAN into a CASAN checkout)")
pi.add_argument("--harness", help="override harness root")
pi.add_argument("--json", action="store_true",
help="emit the complete machine-readable result")
pv = sub.add_parser("verify", help="verify the resolved harness matches the project pin")
pv.add_argument("--target", help="project root (default: cwd)")
pv.add_argument("--harness", help="override harness root")
pv.add_argument("--json", action="store_true",
help="emit the complete machine-readable result")
pl = sub.add_parser("level", help="show installed + project packaging level")
pl.add_argument("--show", action="store_true", help="(default) show levels")
pl.add_argument("--target", help="project root (default: cwd)")
pl.add_argument("--harness", help="override harness root")
pl.add_argument("--json", action="store_true",
help="emit the complete machine-readable result")
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")
pd.add_argument("--json", action="store_true",
help="emit the complete machine-readable result")
pu = sub.add_parser(
"uninstall",
help="remove CASAN from a project while preserving user configuration")
pu.add_argument("--target", help="project root (default: cwd)")
pu.add_argument(
"--purge", action="store_true",
help="also remove .specify runtime logs and state")
pu.add_argument(
"--remove-vscode-extension", action="store_true",
help="also uninstall the shared @casan VS Code extension from this machine")
pu.add_argument("--json", action="store_true",
help="emit the complete machine-readable result")
args = parser.parse_args(argv)
if args.cmd == "init":
@@ -1153,6 +1509,8 @@ def main(argv=None):
return cmd_level(args)
if args.cmd == "doctor":
return cmd_doctor(args)
if args.cmd == "uninstall":
return cmd_uninstall(args)
parser.print_help()
return 64
+12 -5
View File
@@ -40,10 +40,17 @@ fi
echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)"
mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain"
# 1) core harness + CLI
RSYNC_EXCLUDES=(--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log')
[[ -f "$TARGET/packages/casan-harness/level5/project-registry.json" ]] && RSYNC_EXCLUDES+=(--exclude='level5/project-registry.json')
rsync -a "${RSYNC_EXCLUDES[@]}" "$SRC/packages/casan-harness/" "$TARGET/packages/casan-harness/"
# 1) production core harness + CLI. Migrate the old Level-5 registry before
# cleaning source-only/test content from an existing vendored installation.
OLD_REG="$TARGET/packages/casan-harness/level5/project-registry.json"
NEW_REG="$TARGET/packages/casan-harness/config/project-registry.json"
if [[ -f "$OLD_REG" && ! -f "$NEW_REG" ]]; then
mkdir -p "$(dirname "$NEW_REG")"
cp "$OLD_REG" "$NEW_REG"
fi
python3 "$SRC/scripts/copy-runtime.py" \
--source-root "$SRC" --destination-root "$TARGET" --component harness --clean \
--preserve packages/casan-harness/config/project-registry.json
cp "$SRC/bin/casan" "$TARGET/bin/casan"; chmod +x "$TARGET/bin/casan"
[[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION"
@@ -107,7 +114,7 @@ path.write_text(path.read_text(encoding="utf-8").replace("__PROJECT_ID__", proje
PY
# 5) register in project-registry.json (append if absent)
REG="$TARGET/packages/casan-harness/level5/project-registry.json"
REG="$NEW_REG"
python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY'
import json, os, sys
reg, pid, dom = sys.argv[1], sys.argv[2], sys.argv[3]
+35 -3
View File
@@ -25,6 +25,35 @@ class ScaffoldError(RuntimeError):
pass
def runtime_exclusions(component: str) -> set[str]:
path = SOURCE_ROOT / "packaging" / "runtime-layout.json"
try:
layout = json.loads(path.read_text(encoding="utf-8"))
values = layout["components"][component]["exclude"]
except (OSError, ValueError, KeyError, TypeError) as error:
raise ScaffoldError(f"production runtime layout is unavailable: {error}") from error
return {
str(value).replace("\\", "/").strip("/")
for value in values
if isinstance(value, str) and value.strip("/")
}
def excluded(relative: Path, exclusions: set[str]) -> bool:
value = relative.as_posix()
return (
any(part in {
"__pycache__", "node_modules", "dist", "build", "coverage",
} for part in relative.parts)
or relative.name == ".DS_Store"
or relative.suffix in {".pyc", ".pyo", ".log", ".tmp"}
or any(
value == item or value.startswith(item.rstrip("/") + "/")
for item in exclusions
)
)
def atomic_write(path: Path, content: bytes, mode: int = 0o644) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
@@ -89,14 +118,17 @@ def copy_domain_pack(target: Path, slug: str, name: str) -> tuple[int, int]:
def install_harness(target: Path) -> tuple[int, int]:
created = unchanged = 0
harness_source = SOURCE_ROOT / "packages" / "casan-harness"
exclusions = runtime_exclusions("harness")
for source in sorted(harness_source.rglob("*")):
if not source.is_file() or "__pycache__" in source.parts or source.suffix == ".pyc":
if not source.is_file():
continue
relative = source.relative_to(harness_source)
if excluded(relative, exclusions):
continue
destination = target / "packages" / "casan-harness" / relative
# The target registry is adoption state, not immutable harness code. Preserve it
# after the first install so repeated scaffolds and upgrades remain idempotent.
if relative.as_posix() == "level5/project-registry.json" and destination.exists():
if relative.as_posix() == "config/project-registry.json" and destination.exists():
unchanged += 1
continue
mode = stat.S_IMODE(source.stat().st_mode)
@@ -114,7 +146,7 @@ def install_harness(target: Path) -> tuple[int, int]:
def register_project(target: Path, slug: str, name: str) -> None:
registry_path = target / "packages" / "casan-harness" / "level5" / "project-registry.json"
registry_path = target / "packages" / "casan-harness" / "config" / "project-registry.json"
data = json.loads(registry_path.read_text(encoding="utf-8"))
harness_version = next((item.get("harness_version") for item in data.get("projects", []) if item.get("harness_version")), "1.0.0")
# A shipped harness may carry source-hub examples. Never register dangling
@@ -1,7 +1,6 @@
# CASAN harness gate — Gitea Actions workflow (adoption template).
# Copy to .gitea/workflows/casan-ci.yml in your project. Assumes the CASAN core harness
# lives at packages/casan-harness/ (via casan-devkit install.sh) and domain data at
# apps/<project>/domain/. Runs the full governance gate on every push/PR.
# CASAN production gate — Gitea Actions workflow (adoption template).
# The runner installs the approved CASAN release globally and the project provides
# CASAN_PROJECT_MANIFEST. Internal CASAN product tests are intentionally not shipped.
name: CASAN Gate
on:
@@ -15,11 +14,7 @@ jobs:
runs-on: ci-runner
timeout-minutes: 45
env:
CASAN_CI_RUN_FRONTEND: "0" # set 1 if your project has a frontend workspace
CASAN_CI_RUN_BACKEND: "0" # set 1 if your project has backend tests
CASAN_CI_RUN_INFRA_LAB: "0"
CASAN_CI_STEP_TIMEOUT_SEC: "1200" # headroom; some suites are model-backed
# CASAN_DOMAIN_ROOT: apps/<project>/domain # uncomment + set for your project
CASAN_PROJECT_MANIFEST: apps/<project>/domain/project.manifest.json
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -30,10 +25,8 @@ jobs:
command -v python3 >/dev/null || { apt-get update && apt-get install -y python3; }
python3 --version
- name: Run CASAN harness gate
run: bash packages/casan-harness/scripts/bash/ci-harness-gate.sh
- name: Verify installed CASAN runtime
run: casan verify-harness
- name: Verify audit chain + policy bundle
run: |
bash packages/casan-harness/scripts/bash/verify-audit-chain.sh
bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh verify
- name: Run project governance gate
run: casan gate
@@ -36,6 +36,9 @@ assert 'CASAN_PROJECT_ID: "sample-project"' in workflow
assert "__PROJECT_ID__" not in workflow
assert (root / "bin/casan-chat").exists()
assert (root / "bin/casan-chat.ps1").exists()
assert not (root / "packages/casan-harness/tests").exists()
assert not (root / "packages/casan-harness/level5").exists()
assert not (root / "packages/casan-harness/scripts/bash/ci-harness-gate.sh").exists()
for relative in ("docs/casan/CASAN_ADOPTION_WINDOWS.md", "docs/casan/CASAN_PROMPT_ENFORCEMENT.md"):
text = (root / relative).read_text(encoding="utf-8")
assert "sample-project" in text
@@ -27,6 +27,57 @@ echo "===== ① global install ====="
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"
python3 - "$CASAN_HOME/current" <<'PY' \
&& pass "global install has the exact production directory layout" \
|| fail "global install contains missing or source-only paths"
from pathlib import Path
import json
import sys
root = Path(sys.argv[1])
assert {path.name for path in root.iterdir()} == {
".casan-level", ".harness-hash", "VERSION", "bin", "packages", "packaging",
}
assert {path.name for path in (root / "packages").iterdir()} == {
"casan-harness", "casan-devkit",
}
assert {path.name for path in (root / "packages/casan-harness").iterdir()} == {
"adapters", "agentops", "config", "governance", "memory", "prompts",
"schemas", "scripts", "security", "templates",
}
assert {path.name for path in (root / "packages/casan-devkit").iterdir()} == {
"casan-init.py", "package-vscode-extension.py", "project-scaffold.py",
"quality-profiles", "schemas", "templates",
}
assert {path.name for path in (root / "packaging").iterdir()} == {
"runtime-layout.json",
}
for forbidden in (
"packages/casan-harness/tests",
"packages/casan-harness/level5",
"packages/casan-harness/scripts/bash/ci-harness-gate.sh",
"packages/casan-harness/scripts/bash/security-gate.sh",
"packages/casan-harness/scripts/bash/test-integrity.py",
"packages/casan-harness/scripts/bash/dashboard-server.py",
"packages/casan-devkit/tests",
"packages/casan-devkit/install.sh",
"install.sh",
"install.ps1",
"docs",
):
assert not (root / forbidden).exists(), forbidden
for required in (
"packages/casan-harness/config/project-registry.json",
"packages/casan-harness/config/tool-registry.yaml",
"packages/casan-harness/scripts/bash/project-gate.sh",
"packages/casan-harness/scripts/python/agentic_bridge.py",
):
assert (root / required).is_file(), required
registry = json.loads(
(root / "packages/casan-harness/config/project-registry.json").read_text(
encoding="utf-8"))
assert registry["projects"] == []
PY
"$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" \
@@ -39,6 +90,33 @@ assert m.select_clients(["codex","copilot"],False)==["codex","vscode-copilot"]
assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"]
PY
RELEASE_DIST="$WORK/release-dist"
CASAN_DIST_DIR="$RELEASE_DIST" bash "$REPO_ROOT/scripts/package-release.sh" core >/dev/null
python3 - "$RELEASE_DIST/casan-core-v$(cat "$REPO_ROOT/VERSION").tar.gz" <<'PY' \
&& pass "core release artifact contains production runtime only" \
|| fail "core release artifact leaked source-only content"
import sys
import tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
names = [name.split("/", 1)[1] for name in archive.getnames() if "/" in name]
for forbidden in (
"packages/casan-harness/tests",
"packages/casan-harness/level5",
"packages/casan-harness/scripts/bash/ci-harness-gate.sh",
"packages/casan-harness/scripts/bash/test-integrity.py",
):
assert not any(name == forbidden or name.startswith(forbidden + "/") for name in names), forbidden
for required in (
"packages/casan-harness/config/tool-registry.yaml",
"packages/casan-harness/scripts/bash/casan-harness.sh",
"packaging/runtime-layout.json",
"scripts/copy-runtime.py",
"install.sh",
):
assert required in names, required
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 ) \
@@ -160,7 +238,7 @@ L2="$WORK/l2"; mkdir -p "$L2"; ( cd "$L2" && "$DKC" init --level 2 --project l2
( [ -f "$L2/.gitea/workflows/casan-ci.yml" ] && [ -d "$L2/apps/l2/domain" ] ) && pass "init --level 2 adds CI + domain-pack" || fail "L2 missing devkit extras"
L4RC=0; L4="$WORK/l4"; mkdir -p "$L4"; ( cd "$L4" && "$DKC" init --level 4 --project l4 >/dev/null 2>&1 ) || L4RC=$?
[ "$L4RC" -eq 3 ] && pass "init --level 4 (enterprise) refused (rc=3)" || fail "L4 init not refused (rc=$L4RC)"
LVL=$( ( cd "$L2" && "$DKC" level show ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null)
LVL=$( ( cd "$L2" && "$DKC" level show --json ) | python3 -c 'import json,sys;print(json.load(sys.stdin)["project_target_level"])' 2>/dev/null)
[ "$LVL" = "2" ] && pass "casan level show reports project target level" || fail "level show wrong ($LVL)"
echo "===== ⑨ SAFETY: init refuses to adopt a CASAN source hub into itself ====="
@@ -269,6 +347,48 @@ CASAN_HOME="$DK_HOME" node "$REPO_ROOT/packages/casan-devkit/tests/vscode-extens
( 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 "===== ⑪ professional output + safe project uninstall ====="
UN="$WORK/uninstall-project"
mkdir -p "$UN/.claude"
cat > "$UN/.claude/settings.json" <<'EOF'
{"model":"claude-opus-4-8","hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"my-user-hook.sh"}]}]}}
EOF
INIT_OUT=$(cd "$UN" && "$DKC" init --project uninstall-project --client claude --non-interactive)
echo "$INIT_OUT" | grep -q "CASAN initialized" \
&& pass "init defaults to concise human-readable output" \
|| fail "init human output missing ($INIT_OUT)"
if echo "$INIT_OUT" | head -1 | grep -q '^[[:space:]]*{'; then
fail "init still defaults to raw JSON"
else
pass "init no longer dumps raw JSON by default"
fi
( cd "$UN" && "$DKC" doctor --json ) | python3 -c \
'import json,sys; d=json.load(sys.stdin); assert d["status"] == "ready"' \
&& pass "doctor --json preserves the machine-readable contract" \
|| fail "doctor --json is not valid/ready"
UN_OUT=$(cd "$UN" && "$DKC" uninstall)
echo "$UN_OUT" | grep -q "CASAN removed from project" \
&& pass "uninstall emits a concise completion summary" \
|| fail "uninstall summary missing ($UN_OUT)"
[ ! -f "$UN/.casan/config.json" ] \
&& pass "uninstall removes CASAN-owned project config" \
|| fail "uninstall left CASAN config enabled"
py_check "$UN/.claude/settings.json" "my-user-hook" \
&& pass "uninstall preserves user-authored hooks" \
|| fail "uninstall removed a user hook"
if py_check "$UN/.claude/settings.json" "casan-hook.py"; then
fail "uninstall left the CASAN Claude hook enabled"
else
pass "uninstall removes only the CASAN hook"
fi
[ -d "$UN/.specify" ] \
&& pass "uninstall retains runtime evidence unless --purge is explicit" \
|| fail "uninstall removed evidence without --purge"
( cd "$UN" && "$DKC" uninstall --purge >/dev/null ) \
&& [ ! -d "$UN/.specify" ] \
&& pass "a follow-up uninstall --purge removes retained evidence" \
|| fail "uninstall --purge did not remove retained evidence"
echo ""
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -75,10 +75,13 @@ class ProjectScaffoldTests(unittest.TestCase):
args = options(directory, with_harness=True)
first = SCAFFOLD.scaffold(args)
second = SCAFFOLD.scaffold(args)
registry = json.loads((Path(directory) / "packages/casan-harness/level5/project-registry.json").read_text(encoding="utf-8"))
registry = json.loads((Path(directory) / "packages/casan-harness/config/project-registry.json").read_text(encoding="utf-8"))
self.assertEqual([item["project_id"] for item in registry["projects"]], ["inventory-app"])
self.assertGreater(first["created"], 100)
self.assertEqual(second["created"], 0)
self.assertFalse((Path(directory) / "packages/casan-harness/tests").exists())
self.assertFalse((Path(directory) / "packages/casan-harness/level5").exists())
self.assertFalse((Path(directory) / "packages/casan-harness/scripts/bash/ci-harness-gate.sh").exists())
def test_invalid_identifiers_and_broad_target_fail_closed(self):
with tempfile.TemporaryDirectory() as directory:
+3 -3
View File
@@ -8,9 +8,9 @@ Layout:
- `scripts/` — bash + powershell gate logic (H1→H7), path resolver `casan-paths.sh`
- `security/` — filter/policy rules (prompt-filter, pii-rules, output-policy, ...)
- `governance/`, `agentops/` — H5/H6 code
- `level5/` — L5 config (drift/kpi/model-fallback/tool-registry YAMLs)
- `templates/`, `config/` — spec/plan templates, loop-policy
- `tests/` — reproducible harness test suites + integrity manifest
- `config/` — runtime policy, registry, model fallback, and tool metadata
- `templates/` — spec/plan templates
- `tests/` — source-hub verification only; excluded from production installs
Runtime state (logs, audit chain, tenant state) is NOT part of this package — it stays with
the app under `CASAN_STATE_ROOT`. Domain data (golden-runs, corpus, input) lives in
@@ -1,5 +1,5 @@
version: 1.0
description: CASAN Level 5 drift detection policy
description: CASAN production drift detection policy
golden_dir: apps/okr/domain/golden-runs
evidence_dir: docs/output/casan/level5-evidence
@@ -5,11 +5,11 @@
"H4-security",
"H5-governance",
"H6-agentops",
"L5-drift-detection",
"L5-model-fallback",
"L5-tool-registry",
"L5-rollback",
"L5-business-feedback"
"drift-detection",
"model-fallback",
"tool-registry",
"rollback",
"business-feedback"
],
"compatibility": {
"speckit": ">=1.0.0",
@@ -18,7 +18,7 @@
},
"entrypoints": {
"wrapper": "packages/casan-harness/scripts/bash/casan-harness.sh",
"test": "packages/casan-harness/tests/run-casan4-harness-tests.sh",
"dashboard": "docs/output/casan/agentops-dashboard.html"
"project_gate": "packages/casan-harness/scripts/bash/project-gate.sh",
"agentic_bridge": "packages/casan-harness/scripts/python/agentic_bridge.py"
}
}
@@ -1,5 +1,5 @@
version: 1.0
description: CASAN Level 5 business KPI feedback schema
description: CASAN business KPI feedback schema
kpis:
- id: cycle_time_minutes
@@ -1,5 +1,5 @@
version: 1.0
description: CASAN Level 5 model/tool fallback policy
description: CASAN model/tool fallback policy
routes:
- step_pattern: "review|test|security"
@@ -1,5 +1,5 @@
version: 1.0
description: CASAN Level 5 tool registry with ownership, schema, risk, idempotency, and rollback metadata
description: CASAN production tool registry with ownership, schema, risk, idempotency, and rollback metadata
tools:
- id: agent_step
@@ -41,8 +41,8 @@ _casan_find_app_root() {
return 1
}
# Harness (CODE) root: the tree that holds scripts/, security/, templates/, tests/,
# config/, level5/*.yaml. Derived from this file's location so it follows the move.
# Harness (CODE) root: the production tree that holds scripts/, security/,
# templates/, config/, adapters/, and schemas/. Derived from this file's location.
if [[ -z "${CASAN_HARNESS_ROOT:-}" ]]; then
CASAN_HARNESS_ROOT="$(cd "$_casan_paths_self/../.." && pwd)"
fi
@@ -36,7 +36,7 @@ BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
MODEL_ROUTER = os.environ.get("CASAN_GOAL_MODEL_ROUTER") or os.path.join(BIN, "model-router.sh")
SECURITY = os.path.join(BIN, "security-check.sh")
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json")
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "config", "project-registry.json")
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
_MANIFEST_SPEC = importlib.util.spec_from_file_location(
"casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py")
@@ -77,7 +77,7 @@ def _manifest_for_files(files: list[str]) -> dict:
if selected or project:
return PROJECT_MANIFEST.load(ROOT, selected, project)
registry = load(os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json"))
registry = load(os.path.join(ROOT, "packages", "casan-harness", "config", "project-registry.json"))
candidates = []
for entry in registry.get("projects", []):
manifest_path = entry.get("manifest")
@@ -70,7 +70,7 @@ def load(root: str, manifest_path: str | None = None, project_id: str | None = N
if selected:
path = within(root, selected, "manifest")
elif requested:
registry = _json(os.path.join(root, "packages/casan-harness/level5/project-registry.json"), "project registry")
registry = _json(os.path.join(root, "packages/casan-harness/config/project-registry.json"), "project registry")
entry = next((item for item in registry.get("projects", []) if item.get("project_id") == requested), None)
if not entry:
_fail(f"project is not registered: {requested}")
@@ -14,7 +14,7 @@ fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
REGISTRY="$CASAN_HARNESS_ROOT/level5/tool-registry.yaml"
REGISTRY="$CASAN_HARNESS_ROOT/config/tool-registry.yaml"
LOG_DIR="$CASAN_STATE_ROOT/logs/level5"
AUDIT_DIR="$CASAN_STATE_ROOT/logs/audit"
mkdir -p "$LOG_DIR" "$AUDIT_DIR"
@@ -6,8 +6,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
REGISTRY="$CASAN_HARNESS_ROOT/level5/project-registry.json"
PACKAGE="$CASAN_HARNESS_ROOT/level5/harness-package.json"
REGISTRY="$CASAN_HARNESS_ROOT/config/project-registry.json"
PACKAGE="$CASAN_HARNESS_ROOT/config/harness-package.json"
python - "$REGISTRY" "$PACKAGE" <<'PY'
import json
@@ -1,5 +1,5 @@
#!/usr/bin/env pwsh
# CASAN L5 Tool Registry Gate - PowerShell port of tool-registry-gate.sh
# CASAN Tool Registry Gate - PowerShell port of tool-registry-gate.sh
# Usage:
# tool-registry-gate.ps1 <tool-id> [idempotency-key]
#
@@ -13,8 +13,9 @@ param(
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$projectRoot = (Resolve-Path (Join-Path $scriptDir "../../..")).Path
$registry = Join-Path $projectRoot ".specify/level5/tool-registry.yaml"
$harnessRoot = if ($env:CASAN_HARNESS_ROOT) { $env:CASAN_HARNESS_ROOT } else { (Resolve-Path (Join-Path $scriptDir "../..")).Path }
$projectRoot = if ($env:CASAN_APP_ROOT) { $env:CASAN_APP_ROOT } else { (Resolve-Path (Join-Path $scriptDir "../../../..")).Path }
$registry = Join-Path $harnessRoot "config/tool-registry.yaml"
$logDir = Join-Path $projectRoot ".specify/logs/level5"
$txLog = Join-Path $logDir "tool-registry.jsonl"
$toolCallLog = Join-Path $projectRoot ".specify/logs/audit/tool-calls.jsonl"
@@ -11,7 +11,7 @@ guarantee from Plan-16 even when the harness lives outside the repo.
Only GATE-relevant trees are hashed (the code that makes security/governance
decisions), never runtime state/logs:
scripts/bash scripts/python security level5 adapters schemas
scripts/bash scripts/python security config adapters schemas
stdlib-only, deterministic (sorted paths), text-mode agnostic (hashes raw bytes).
@@ -30,10 +30,9 @@ import sys
ALGO = "sha256"
HASHED_SUBTREES = (
"scripts/bash", "scripts/python", "security", "level5", "adapters", "schemas")
"scripts/bash", "scripts/python", "security", "config", "adapters", "schemas")
SKIP_DIR_NAMES = {"__pycache__", ".git", "node_modules"}
SKIP_SUFFIXES = (".pyc", ".pyo", ".log", ".tmp", ".DS_Store")
# Within level5, only policy/config, not regenerated runtime artifacts.
SKIP_BASENAMES = {"policy-manifest.json", "policy-manifest.sig", "project-registry.json"}
@@ -141,7 +141,7 @@ HALLU_COUNT="$(tail -n 1 "$CASAN_STATE_ROOT/logs/cost/metrics.jsonl" | sed -n 's
[[ "${HALLU_COUNT:-0}" -ge 3 ]] && pass "H6 detects hallucination signals (count=$HALLU_COUNT)" || fail "H6 did not detect hallucination signals (count=${HALLU_COUNT:-0})"
# H6: imported provider telemetry becomes the authoritative cost source
"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/level5/provider-usage-sample.json" > "$EVIDENCE_DIR/05c-provider-import.stdout"
"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/tests/fixtures/provider-usage-sample.json" > "$EVIDENCE_DIR/05c-provider-import.stdout"
CASAN_AGENT_NAME=demo.agent CASAN_STEP_NAME=speckit.implement \
"$SCRIPTS/agent-metrics.sh" "$METRICS_IN" "$EVIDENCE_DIR/05c-provider-output.txt" > "$EVIDENCE_DIR/05c-provider-metrics.stdout"
assert_contains "$CASAN_STATE_ROOT/logs/cost/metrics.jsonl" '"cost_source":"provider_telemetry"'
@@ -261,7 +261,7 @@ assert_contains "$LEVEL5_DIR/14-business-kpi.stdout" "status=pass"
assert_contains "$LEVEL5_DIR/16-policy-verify.stdout" "POLICY_SIGNATURE_VALID"
# L5: real provider usage telemetry import path
"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/level5/provider-usage-sample.json" > "$LEVEL5_DIR/17-provider-telemetry.stdout"
"$SCRIPTS/import-provider-telemetry.sh" "$CASAN_HARNESS_ROOT/tests/fixtures/provider-usage-sample.json" > "$LEVEL5_DIR/17-provider-telemetry.stdout"
assert_contains "$LEVEL5_DIR/17-provider-telemetry.stdout" "PROVIDER_TELEMETRY_IMPORTED"
# L5: shared harness package is registered by multiple projects