Add selectable CASAN IDE integrations
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user