feat(devkit): enforce governed prompt adoption

This commit is contained in:
thanhnv
2026-07-21 23:40:26 +07:00
parent 4d5dca9400
commit 7b2ba60a3a
16 changed files with 622 additions and 12 deletions
+12
View File
@@ -49,6 +49,8 @@ Commands:
reuse Verify multi-project harness reuse (registry)
project validate [--manifest path] Validate a project manifest and quality profile
project init <scaffold args...> Create an idempotent NestJS/React project shell
prompt verify Verify the adopted prompt-enforcement contract
prompt trace <trace-id> Verify that a prompt trace is H1-H7 certified
pipeline [--manifest path] Run the manifest-driven SRS→test pipeline
dashboard [port] Serve the AgentOps dashboard (default 8787)
version Print version
@@ -85,6 +87,16 @@ case "$cmd" in
exec python3 "$DEVKIT/project-scaffold.py" "$@" ;;
*) echo "casan: usage: casan project <validate|init>" >&2; exit 64 ;;
esac ;;
prompt)
sub="${1:-help}"; shift || true
case "$sub" in
verify) exec bash "$BASH_DIR/prompt-enforcement-verify.sh" --root "$CASAN_APP_ROOT" "$@" ;;
trace)
trace_id="${1:-}"; [[ -n "$trace_id" ]] || { echo "casan: usage: casan prompt trace <trace-id>" >&2; exit 64; }
shift
exec bash "$BASH_DIR/prompt-enforcement-verify.sh" --root "$CASAN_APP_ROOT" --trace-id "$trace_id" "$@" ;;
*) echo "casan: usage: casan prompt <verify|trace>" >&2; exit 64 ;;
esac ;;
pipeline)
RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs"
[[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; }
+19 -3
View File
@@ -1,16 +1,29 @@
# CASAN Adoption Guide
The installer also provisions the mandatory prompt-enforcement pack. After adoption, send project prompts through `bin/casan-chat` (or `bin/casan-chat.ps1` on Windows/WSL2) and run `bin/casan prompt verify`. See [PROMPT_ENFORCEMENT_GUIDE.md](PROMPT_ENFORCEMENT_GUIDE.md) for the technical boundary and per-trace certification.
How a downstream project adopts the CASAN governance harness. Adoption is **config +
domain only** — you never edit gate logic (H1→H7).
## Option A — DevKit install (recommended)
From a CASAN source hub or an extracted `casan-devkit` bundle:
Clone CASAN Core from your Gitea repository once, and pull the latest `main` before each install or upgrade:
```bash
git clone <gitea-casan-core-url> casan-core
cd casan-core
git pull --ff-only origin main
```
Then run the installer from that CASAN checkout:
```bash
packages/casan-devkit/install.sh --target ../my-project --project ticketing --domain "Ticketing"
```
This copies the core harness + `bin/casan` into `../my-project`, scaffolds
`apps/ticketing/domain/` from the domain-pack template, adds `.gitea/workflows/casan-ci.yml`,
and registers the project in `project-registry.json`.
`apps/ticketing/domain/` from the domain-pack template, installs the prompt entrypoints and
standalone `.gitea/workflows/casan-prompt-enforcement.yml`, and registers the project in
`project-registry.json`. Existing domain files, registry state, and project CI are preserved.
## Option A2 — New production project shell
@@ -32,12 +45,15 @@ tar -xzf casan-core-v1.0.0.tar.gz
cp -R casan-core-v1.0.0/{packages,bin,VERSION} /path/to/project/
```
Then create `apps/<project>/domain/` yourself (see `DOMAIN_PACK_GUIDE.md`).
This harness-only option does not install the mandatory repository prompt-enforcement pack;
use Option A when every project prompt must be governed and certifiable.
## Option C — Docker (no install into repo)
```bash
docker run --rm -v "$PWD":/workspace -w /workspace casan-harness:1.0.0 casan gate
```
See `DOCKER_GUIDE.md`.
This runtime-only option also does not enforce repository-agent entrypoints.
## After install
1. Requirement → `apps/<project>/domain/input/requirement.md` (keep the `| FR-xx |` table).
@@ -0,0 +1,67 @@
# CASAN Prompt Enforcement for Adopted Projects
The DevKit installer configures an adopted repository so supported repository agents and team members use CASAN as the certified prompt boundary.
## What is enforced
- `bin/casan-chat` is the macOS/Linux/WSL2 prompt entrypoint.
- `bin/casan-chat.ps1` is the Windows wrapper and executes the same entrypoint through WSL2.
- `.casan/prompt-policy.json` binds prompts and H6 telemetry to one `project_id`.
- `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` tell supported repository agents to refuse direct prompt work and require resubmission through CASAN.
- The standalone `.gitea/workflows/casan-prompt-enforcement.yml` workflow verifies that the policy, launchers, instructions, domain root, and workflow contract are present and have not been stripped. Existing project CI is not overwritten.
- A certified prompt must produce a trace with passing H1-H7 gates and H6 telemetry attributed to the configured project.
## Enforcement boundary
A repository cannot technically intercept text typed directly into an external ChatGPT, Claude, or Copilot website. Such conversations are outside the CASAN runtime and therefore are **not CASAN-certified**. The enforceable rule is:
1. Use a CASAN-owned entrypoint for every project prompt.
2. Repository-aware agents must refuse direct execution when their instruction file is loaded.
3. Accept governed output only when its CASAN trace passes verification.
For stronger organizational control, restrict direct external AI sites at the identity, proxy, or network layer. That control is outside the repository and complements CASAN rather than replacing its H1-H7 evidence.
## Install or upgrade
From a checked-out CASAN Core repository:
```bash
packages/casan-devkit/install.sh \
--target "/absolute/path/to/existing-project" \
--project "project-id" \
--domain "Project display name"
```
The installer is idempotent for the managed instruction blocks. Existing content outside the CASAN markers, project domain documents, project registry, and existing CI workflows is retained.
## Send prompts
macOS, Linux, or WSL2:
```bash
bin/casan-chat "Review the current requirements and identify missing acceptance criteria"
```
Windows PowerShell with WSL2:
```powershell
powershell -ExecutionPolicy Bypass -File bin\casan-chat.ps1 "Review the current requirements"
```
Run without a prompt to enter interactive mode.
## Verify
Verify the repository contract:
```bash
bin/casan prompt verify
```
Verify an individual governed result:
```bash
bin/casan prompt trace <trace-id>
```
Only the second command proves that the individual prompt completed H1-H7 and has matching H6 project telemetry.
+66 -9
View File
@@ -24,31 +24,81 @@ while [[ $# -gt 0 ]]; do
esac
done
[[ -n "$TARGET" && -n "$PROJECT" ]] || { echo "install: --target and --project are required" >&2; exit 64; }
[[ "$PROJECT" =~ ^[a-z][a-z0-9-]{1,62}$ ]] || {
echo "install: --project must match ^[a-z][a-z0-9-]{1,62}$" >&2
exit 64
}
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" # source-hub / bundle root
[[ -d "$SRC/packages/casan-harness" ]] || { echo "install: cannot find packages/casan-harness under $SRC" >&2; exit 1; }
if [[ -n "$TEMPLATE" ]]; then
exec python3 "$SRC/packages/casan-devkit/project-scaffold.py" \
--target "$TARGET" --project "$PROJECT" --name "$DOMAIN" --template "$TEMPLATE" --with-harness
python3 "$SRC/packages/casan-devkit/project-scaffold.py" \
--target "$TARGET" --project "$PROJECT" --name "$DOMAIN" --template "$TEMPLATE"
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 -a --exclude='__pycache__' --exclude='*.pyc' "$SRC/packages/casan-harness/" "$TARGET/packages/casan-harness/"
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/"
cp "$SRC/bin/casan" "$TARGET/bin/casan"; chmod +x "$TARGET/bin/casan"
[[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION"
# 2) per-project domain from the domain-pack template
rsync -a "$SRC/packages/casan-devkit/templates/domain-pack/" "$TARGET/apps/$PROJECT/domain/"
rsync -a --ignore-existing "$SRC/packages/casan-devkit/templates/domain-pack/" "$TARGET/apps/$PROJECT/domain/"
# 3) Gitea CI workflow (adoption)
mkdir -p "$TARGET/.gitea/workflows"
cp "$SRC/packages/casan-devkit/templates/gitea-workflow/ci.yml" "$TARGET/.gitea/workflows/casan-ci.yml"
# 3) Prompt enforcement entrypoints, policy, and repository-agent contracts.
mkdir -p "$TARGET/.casan" "$TARGET/.github" "$TARGET/.gitea/workflows" "$TARGET/docs/casan"
cp "$SRC/packages/casan-devkit/templates/prompt-enforcement/casan-chat" "$TARGET/bin/casan-chat"
cp "$SRC/packages/casan-devkit/templates/prompt-enforcement/casan-chat.ps1" "$TARGET/bin/casan-chat.ps1"
chmod +x "$TARGET/bin/casan-chat"
# 4) register in project-registry.json (append if absent)
python3 - "$TARGET/.casan/prompt-policy.json" "$PROJECT" <<'PY'
import json, sys
path, project = sys.argv[1], sys.argv[2]
policy = {
"schema_version": 1,
"mode": "enforced",
"project_id": project,
"domain_root": f"apps/{project}/domain",
"certified_entrypoints": ["bin/casan-chat", "bin/casan-chat.ps1", "CASAN Control Panel"],
"direct_external_ui": "not_interceptable_not_certified",
"required_instruction_files": ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"],
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(policy, handle, ensure_ascii=False, indent=2)
handle.write("\n")
PY
python3 - "$TARGET" "$SRC/packages/casan-devkit/templates/prompt-enforcement/agent-instructions.md" <<'PY'
import pathlib, re, sys
root = pathlib.Path(sys.argv[1])
snippet = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").strip()
start = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
end = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->"
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
for relative in ("AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
existing = path.read_text(encoding="utf-8") if path.exists() else ""
updated = pattern.sub(snippet, existing) if pattern.search(existing) else (existing.rstrip() + "\n\n" + snippet).lstrip()
path.write_text(updated.rstrip() + "\n", encoding="utf-8")
PY
cp "$SRC/docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md" "$TARGET/docs/casan/CASAN_PROMPT_ENFORCEMENT.md"
# 4) Standalone Gitea contract workflow. The project's existing CI remains untouched.
cp "$SRC/packages/casan-devkit/templates/gitea-workflow/prompt-enforcement.yml" "$TARGET/.gitea/workflows/casan-prompt-enforcement.yml"
python3 - "$TARGET/.gitea/workflows/casan-prompt-enforcement.yml" "$PROJECT" <<'PY'
import pathlib, sys
path, project = pathlib.Path(sys.argv[1]), sys.argv[2]
path.write_text(path.read_text(encoding="utf-8").replace("__PROJECT_ID__", project), encoding="utf-8")
PY
# 5) register in project-registry.json (append if absent)
REG="$TARGET/packages/casan-harness/level5/project-registry.json"
python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY'
import json, os, sys
@@ -70,6 +120,10 @@ else:
print(f"{pid} already registered")
PY
# 6) Fail the installation if a managed enforcement artifact is absent or malformed.
bash "$TARGET/packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh" \
--root "$TARGET" --project "$PROJECT"
cat <<EOF
==> done. Next steps in $TARGET:
@@ -77,5 +131,8 @@ cat <<EOF
2. Add golden baseline in apps/$PROJECT/domain/golden-runs/
3. Run the gate: CASAN_DOMAIN_ROOT=apps/$PROJECT/domain bin/casan gate
4. Prove reuse: bin/casan reuse # expects HARNESS_REUSE_VALID
See docs/packaging/ADOPTION_GUIDE.md and DOMAIN_PACK_GUIDE.md.
5. Send every AI prompt via: bin/casan-chat "<prompt>"
6. On Windows/WSL2 use: powershell -ExecutionPolicy Bypass -File bin/casan-chat.ps1 "<prompt>"
7. Verify enforcement: bin/casan prompt verify
See docs/casan/CASAN_PROMPT_ENFORCEMENT.md.
EOF
@@ -0,0 +1,28 @@
# CASAN prompt enforcement contract
name: CASAN Prompt Contract
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
prompt-contract:
runs-on: ci-runner
timeout-minutes: 5
env:
CASAN_PROJECT_ID: "__PROJECT_ID__"
CASAN_DOMAIN_ROOT: "apps/__PROJECT_ID__/domain"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify toolchain
run: |
set -euo pipefail
command -v bash >/dev/null
command -v python3 >/dev/null
- name: Verify CASAN prompt enforcement contract
run: bash packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh --root "$PWD" --project "$CASAN_PROJECT_ID"
@@ -0,0 +1,11 @@
<!-- CASAN_PROMPT_ENFORCEMENT_START -->
## CASAN prompt enforcement — mandatory
- Do not process a direct user prompt in this repository as a governed or certified task.
- The supported prompt boundary is `bin/casan-chat` (or `bin/casan-chat.ps1` on Windows through WSL2) and the CASAN Control Panel.
- If this agent was opened directly through Codex, Claude Code, Copilot, ChatGPT, or another assistant UI, stop before using tools or changing files and instruct the member to resubmit the request through `bin/casan-chat`.
- Every accepted governed prompt must return a CASAN `trace_id`; verify it with `bin/casan prompt trace <trace-id>`.
- Commands with side effects must use `bin/casan run` or a registered action with the required approval.
- Never claim that a direct external-UI response passed H1-H7. Absence of CASAN evidence means not certified.
- Do not bypass this boundary because a request was denied, blocked, degraded, or requires approval.
<!-- CASAN_PROMPT_ENFORCEMENT_END -->
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN_MANAGED_PROMPT_ENTRYPOINT
# Project-owned governed chat. The DevKit installer places the project identity
# in .casan/prompt-policy.json; this launcher refuses to run when that contract
# or its repository instruction boundary has drifted.
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SELF_DIR/.." && pwd)"
CHAT="$ROOT/packages/casan-harness/scripts/bash/chat-turn.py"
VERIFY="$ROOT/packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh"
POLICY="$ROOT/.casan/prompt-policy.json"
if [[ ! -f "$CHAT" || ! -f "$VERIFY" || ! -f "$POLICY" ]]; then
echo "casan-chat: prompt enforcement runtime is incomplete; reinstall CASAN DevKit" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "casan-chat: python3 is required" >&2
exit 1
fi
if ! bash "$VERIFY" --root "$ROOT" --quiet; then
echo "casan-chat: prompt enforcement verification failed closed" >&2
exit 1
fi
PROJECT="$(python3 - "$POLICY" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8"))["project_id"])
PY
)"
DOMAIN_REL="$(python3 - "$POLICY" <<'PY'
import json, sys
print(json.load(open(sys.argv[1], encoding="utf-8"))["domain_root"])
PY
)"
export CASAN_APP_ROOT="$ROOT"
export CASAN_DOMAIN_ROOT="$ROOT/$DOMAIN_REL"
export CASAN_PROJECT_ID="$PROJECT"
ACTOR="${CASAN_CHAT_ACTOR:-${USER:-member}}"
ROLE="${CASAN_CHAT_ROLE:-viewer}"
AGENT="${CASAN_CHAT_AGENT:-}"
SKILL="${CASAN_CHAT_SKILL:-}"
MODEL_PROVIDER="${CASAN_CHAT_MODEL_PROVIDER:-}"
CHAT_ID="${CASAN_CHAT_ID:-$PROJECT-$(date +%Y%m%d-%H%M%S)-$$}"
usage() {
cat <<'EOF'
Usage:
bin/casan-chat "normal prompt"
bin/casan-chat # interactive mode
Every accepted prompt is submitted to CASAN chat-turn and produces H1-H7
evidence. Direct Claude, ChatGPT, Codex or Copilot UI prompts are outside this
enforcement boundary and must not be described as CASAN-certified.
Optional environment variables:
CASAN_CHAT_ACTOR
CASAN_CHAT_ROLE
CASAN_CHAT_AGENT
CASAN_CHAT_SKILL
CASAN_CHAT_MODEL_PROVIDER
CASAN_CHAT_ID
EOF
}
run_turn() {
local message="$1"
local raw_file rc
raw_file="$(mktemp)"
local command=(python3 "$CHAT" ask
--message "$message"
--actor "$ACTOR"
--role "$ROLE"
--project "$PROJECT"
--chat-id "$CHAT_ID")
[[ -n "$AGENT" ]] && command+=(--agent "$AGENT")
[[ -n "$SKILL" ]] && command+=(--skill "$SKILL")
[[ -n "$MODEL_PROVIDER" ]] && command+=(--model-provider "$MODEL_PROVIDER")
set +e
"${command[@]}" >"$raw_file"
rc=$?
set -e
python3 - "$raw_file" "$ROOT" <<'PY'
import json
import pathlib
import sys
raw_path = pathlib.Path(sys.argv[1])
root = pathlib.Path(sys.argv[2])
lines = [line for line in raw_path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()]
try:
result = json.loads(lines[-1])
except (IndexError, json.JSONDecodeError):
print(raw_path.read_text(encoding="utf-8", errors="replace"))
raise SystemExit(0)
answer = result.get("answer") or result.get("reason") or result.get("decision") or "No answer returned."
print(answer)
print()
trace_id = result.get("trace_id", "")
certified = result.get("certified", False)
decision = result.get("decision", "UNKNOWN")
mode = result.get("mode", "UNKNOWN")
print(f"CASAN decision={decision} mode={mode} certified={str(bool(certified)).lower()} trace_id={trace_id or 'n/a'}")
if trace_id:
trace = root / ".specify" / "logs" / "trace-events" / f"{trace_id}.jsonl"
print(f"CASAN evidence={trace}")
PY
if [[ "$rc" -eq 0 ]]; then
local certification trace_id certified
certification="$(python3 - "$raw_file" <<'PY'
import json, pathlib, sys
lines = [line for line in pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() if line.strip()]
try:
result = json.loads(lines[-1])
except (IndexError, json.JSONDecodeError):
print("|false")
else:
print(f"{result.get('trace_id', '')}|{str(bool(result.get('certified', False))).lower()}")
PY
)"
trace_id="${certification%%|*}"
certified="${certification#*|}"
if [[ -z "$trace_id" || "$certified" != "true" ]]; then
echo "casan-chat: runtime returned success without a certified trace" >&2
rc=2
elif ! bash "$VERIFY" --root "$ROOT" --project "$PROJECT" --trace-id "$trace_id"; then
echo "casan-chat: per-prompt certification verification failed closed" >&2
rc=2
fi
fi
rm -f "$raw_file"
return "$rc"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ "$#" -gt 0 ]]; then
run_turn "$*"
exit $?
fi
echo "CASAN governed chat — project=$PROJECT role=$ROLE actor=$ACTOR"
echo "Every accepted turn writes H1-H7 evidence. Type /quit to exit."
while true; do
printf 'casan> '
IFS= read -r message || break
case "$message" in
/quit|/exit) break ;;
'') continue ;;
esac
run_turn "$message" || true
done
@@ -0,0 +1,22 @@
# CASAN_MANAGED_PROMPT_ENTRYPOINT
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]] $Prompt,
[string] $Distro = 'Ubuntu'
)
$ErrorActionPreference = 'Stop'
$projectRootWindows = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$projectRootWsl = (& wsl.exe -d $Distro -- wslpath -a $projectRootWindows).Trim()
if (-not $projectRootWsl) {
throw 'CASAN could not resolve the project path in WSL2.'
}
$arguments = @('-d', $Distro, '--cd', $projectRootWsl, './bin/casan-chat')
if ($Prompt) {
$arguments += $Prompt
}
& wsl.exe @arguments
exit $LASTEXITCODE
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
DEVKIT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
TARGET="$WORK/existing-project"
mkdir -p "$TARGET/.github"
printf '# Existing agent rules\n' > "$TARGET/AGENTS.md"
printf '# Existing Claude rules\n' > "$TARGET/CLAUDE.md"
printf '# Existing Copilot rules\n' > "$TARGET/.github/copilot-instructions.md"
install_project() {
bash "$DEVKIT_ROOT/install.sh" \
--target "$TARGET" \
--project sample-project \
--domain "Sample Project" >/dev/null
}
install_project
python3 - "$TARGET" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
policy = json.loads((root / ".casan/prompt-policy.json").read_text(encoding="utf-8"))
assert policy["mode"] == "enforced"
assert policy["project_id"] == "sample-project"
assert policy["domain_root"] == "apps/sample-project/domain"
for relative in ("AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"):
text = (root / relative).read_text(encoding="utf-8")
assert "Existing" in text
assert text.count("CASAN_PROMPT_ENFORCEMENT_START") == 1
workflow = (root / ".gitea/workflows/casan-prompt-enforcement.yml").read_text(encoding="utf-8")
assert "CASAN prompt enforcement contract" in workflow
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()
PY
(cd "$TARGET" && bin/casan prompt verify) | grep -q 'CASAN_PROMPT_ENFORCEMENT_VALID project=sample-project'
# A second adoption run upgrades managed files without duplicating instruction blocks.
install_project
python3 - "$TARGET" <<'PY'
import pathlib, sys
root = pathlib.Path(sys.argv[1])
for relative in ("AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"):
assert (root / relative).read_text(encoding="utf-8").count("CASAN_PROMPT_ENFORCEMENT_START") == 1
PY
# Managed-file tampering must fail closed before a prompt reaches chat-turn.
python3 - "$TARGET/CLAUDE.md" <<'PY'
import pathlib, sys
path = pathlib.Path(sys.argv[1])
path.write_text(path.read_text(encoding="utf-8").replace("CASAN_PROMPT_ENFORCEMENT_START", "REMOVED_MARKER"), encoding="utf-8")
PY
set +e
(cd "$TARGET" && bin/casan prompt verify >"$WORK/tamper.out" 2>&1)
TAMPER_RC=$?
set -e
if [[ "$TAMPER_RC" -ne 2 ]] || ! grep -q 'managed_marker_missing:CLAUDE.md' "$WORK/tamper.out"; then
echo "adoption-install-tests: tampered contract was accepted" >&2
cat "$WORK/tamper.out" >&2
exit 1
fi
# New-project scaffolding must continue into the same enforcement installation.
FRESH="$WORK/fresh-project"
bash "$DEVKIT_ROOT/install.sh" --target "$FRESH" --project fresh-app \
--domain "Fresh App" --template nestjs-react >/dev/null
(cd "$FRESH" && bin/casan prompt verify) | grep -q 'CASAN_PROMPT_ENFORCEMENT_VALID project=fresh-app'
test -f "$FRESH/apps/fresh-app/frontend/package.json"
echo "ADOPTION_INSTALL_TESTS_PASS"
@@ -221,6 +221,8 @@ def record_metrics(trace_id: str, message: str, answer: str, status: str, latenc
append_jsonl(metrics_path(), {
"timestamp": now_iso(),
"trace_id": trace_id,
"project": os.environ.get("CASAN_PROJECT_ID", "default"),
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"harness": "H6-agentops",
"agent": "chat.operator",
"step": f"operator:{action_id or 'none'}",
@@ -262,6 +264,7 @@ def finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, route
rec = record_turn({
"timestamp": now_iso(),
"trace_id": trace_id,
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"chat_id": chat_id,
"turn_id": turn_id,
"tenant_id": tenant_id,
@@ -284,6 +287,7 @@ def finish(started, trace_id, chat_id, turn_id, tenant_id, actor, message, route
"chat_id": chat_id,
"turn_id": turn_id,
"trace_id": trace_id,
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"mode": router.get("mode", "OPERATOR"),
"risk": router.get("risk", "medium"),
"decision": decision,
@@ -322,6 +326,7 @@ def run(args) -> int:
os.environ["CASAN_TENANT_ID"] = args.tenant
started = datetime.now(timezone.utc)
trace_id = str(uuid.uuid4())
os.environ["CASAN_RUN_ID"] = trace_id
chat_id = args.chat_id or "chat-default"
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
actor = args.actor or "anonymous"
@@ -601,6 +601,8 @@ def record_metrics(trace_id: str, message: str, answer: str, status: str, latenc
rec = {
"timestamp": now_iso(),
"trace_id": trace_id,
"project": os.environ.get("CASAN_PROJECT_ID", "default"),
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"harness": "H6-agentops",
"agent": "chat.ask-casan",
"step": "ask-casan-readonly",
@@ -627,6 +629,7 @@ def ask(args):
os.environ["CASAN_TENANT_ID"] = args.tenant
started = datetime.now(timezone.utc)
trace_id = str(uuid.uuid4())
os.environ["CASAN_RUN_ID"] = trace_id
message = args.message
router = classify(message)
actor = args.actor or "anonymous"
@@ -647,6 +650,7 @@ def ask(args):
rec = record_turn({
"timestamp": now_iso(),
"trace_id": trace_id,
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"chat_id": chat_id,
"turn_id": turn_id,
"tenant_id": tenant_id,
@@ -672,6 +676,7 @@ def ask(args):
record_trace_event(trace_id, "H6-agentops", "pass" if decision == "ANSWERED" else "error", "Runtime metrics recorded", {
"latency_ms": elapsed,
"status": "success" if decision == "ANSWERED" else "failed",
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"synthesis_mode": (synthesis or {}).get("mode", "deterministic"),
})
record_trace_event(trace_id, "H7-orchestration", "pass" if decision == "ANSWERED" else "blocked", "Harness turn completed" if decision == "ANSWERED" else "Harness stopped with governed outcome", {
@@ -709,6 +714,7 @@ def ask(args):
"chat_id": chat_id,
"turn_id": turn_id,
"trace_id": trace_id,
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"mode": router.get("mode", "BLOCK"),
"risk": router.get("risk", "high"),
"decision": decision,
@@ -703,6 +703,7 @@ def submit_escalation(args, router, binding):
def ask(args) -> int:
os.environ["CASAN_PROJECT_ID"] = args.project or "default"
guard_rc = tenant_runtime_guard(args)
if guard_rc != 0:
return guard_rc
@@ -613,6 +613,9 @@ def main():
usage = {
"timestamp": ts, "harness": "L5-provider-telemetry", "provider": backend,
"model": model_name, "run_id": os.environ.get("CASAN_RUN_ID", "adhoc"),
"trace_id": os.environ.get("CASAN_RUN_ID", "adhoc"),
"project": os.environ.get("CASAN_PROJECT_ID", "default"),
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
"step": os.environ.get("CASAN_STEP_NAME", args.role), "role": args.role,
"input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"],
"total_tokens": total, "cost_usd": 0.0, "cost_source": cost_source,
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
ROOT="$CASAN_APP_ROOT"
TRACE_ID=""
EXPECTED_PROJECT=""
QUIET=0
while [[ $# -gt 0 ]]; do
case "$1" in
--root) ROOT="$(cd "$2" && pwd)"; shift 2 ;;
--trace-id) TRACE_ID="$2"; shift 2 ;;
--project) EXPECTED_PROJECT="$2"; shift 2 ;;
--quiet) QUIET=1; shift ;;
-h|--help)
echo "usage: prompt-enforcement-verify.sh [--root dir] [--project id] [--trace-id uuid] [--quiet]"
exit 0 ;;
*) echo "PROMPT_ENFORCEMENT_INVALID_ARGUMENT value=$1" >&2; exit 64 ;;
esac
done
POLICY="$ROOT/.casan/prompt-policy.json"
python3 - "$ROOT" "$POLICY" "$EXPECTED_PROJECT" "$TRACE_ID" "$QUIET" <<'PY'
import json
import pathlib
import re
import sys
root = pathlib.Path(sys.argv[1])
policy_path = pathlib.Path(sys.argv[2])
expected_project = sys.argv[3]
trace_id = sys.argv[4]
quiet = sys.argv[5] == "1"
marker_start = "<!-- CASAN_PROMPT_ENFORCEMENT_START -->"
marker_end = "<!-- CASAN_PROMPT_ENFORCEMENT_END -->"
def fail(reason: str) -> None:
print(f"CASAN_PROMPT_ENFORCEMENT_INVALID reason={reason}", file=sys.stderr)
raise SystemExit(2)
try:
policy = json.loads(policy_path.read_text(encoding="utf-8"))
except FileNotFoundError:
fail("policy_missing")
except (OSError, ValueError):
fail("policy_unreadable")
project = str(policy.get("project_id") or "")
domain_root = str(policy.get("domain_root") or "")
if policy.get("schema_version") != 1 or policy.get("mode") != "enforced":
fail("policy_contract_invalid")
if not re.fullmatch(r"[a-z][a-z0-9-]{1,62}", project):
fail("project_id_invalid")
if expected_project and project != expected_project:
fail("project_id_mismatch")
if pathlib.PurePosixPath(domain_root).is_absolute() or ".." in pathlib.PurePosixPath(domain_root).parts:
fail("domain_root_unsafe")
if not (root / domain_root).is_dir():
fail("domain_root_missing")
managed = {
"bin/casan-chat": "CASAN_MANAGED_PROMPT_ENTRYPOINT",
"bin/casan-chat.ps1": "CASAN_MANAGED_PROMPT_ENTRYPOINT",
"AGENTS.md": marker_start,
"CLAUDE.md": marker_start,
".github/copilot-instructions.md": marker_start,
".gitea/workflows/casan-prompt-enforcement.yml": "CASAN prompt enforcement contract",
}
for relative, marker in managed.items():
path = root / relative
try:
content = path.read_text(encoding="utf-8")
except OSError:
fail(f"managed_file_missing:{relative}")
if marker not in content:
fail(f"managed_marker_missing:{relative}")
if relative.endswith(".md") and marker_start in content and marker_end not in content:
fail(f"managed_marker_unclosed:{relative}")
if not trace_id:
if not quiet:
print(f"CASAN_PROMPT_ENFORCEMENT_VALID project={project} mode=enforced")
raise SystemExit(0)
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{5,127}", trace_id):
fail("trace_id_invalid")
trace_path = root / ".specify" / "logs" / "trace-events" / f"{trace_id}.jsonl"
metrics_path = root / ".specify" / "logs" / "cost" / "metrics.jsonl"
try:
events = [json.loads(line) for line in trace_path.read_text(encoding="utf-8").splitlines() if line.strip()]
except FileNotFoundError:
fail("trace_missing")
except (OSError, ValueError):
fail("trace_unreadable")
latest = {}
for event in events:
if event.get("trace_id") != trace_id:
fail("trace_identity_mismatch")
latest[str(event.get("gate_id") or "")] = event
required = [f"H{i}-{name}" for i, name in enumerate(("context", "tool", "eval", "security", "governance", "agentops", "orchestration"), 1)]
missing = [gate for gate in required if gate not in latest]
if missing:
fail("trace_gates_missing:" + ",".join(missing))
if any(latest[gate].get("status") != "pass" for gate in required):
fail("trace_not_all_pass")
if latest["H7-orchestration"].get("evidence", {}).get("certified") is not True:
fail("trace_not_certified")
try:
metrics = [json.loads(line) for line in metrics_path.read_text(encoding="utf-8").splitlines() if line.strip()]
except (FileNotFoundError, OSError, ValueError):
fail("metrics_unreadable")
matches = [row for row in metrics if str(row.get("trace_id") or row.get("run_id") or "") == trace_id]
if not matches:
fail("trace_metrics_missing")
if not any(str(row.get("project_id") or row.get("project") or "") == project for row in matches):
fail("trace_project_attribution_missing")
print(f"CASAN_PROMPT_TRACE_CERTIFIED project={project} trace_id={trace_id} gates=7")
PY
@@ -115,6 +115,22 @@ python3 "$RESOLVER" verify-audit > "$WORK/audit.out" \
&& grep -q "CHAT_AGENT_AUDIT ok=true" "$WORK/audit.out" \
&& pass "agent binding audit hash chain verifies" || fail "agent binding audit invalid"
set +e
python3 "$TURN" ask --message "Show project status" --role viewer --actor telemetry-test \
--project okr --chat-id project-telemetry --tenant default > "$WORK/project-turn.json"
RC=$?
set -e 2>/dev/null || true
python3 - "$WORK/project-turn.json" "$WORK/state/logs/cost/metrics.jsonl" "$RC" <<'PY' \
&& pass "chat-turn attributes H6 telemetry to the adopted project" || fail "H6 project attribution missing"
import json, sys
result = json.loads(open(sys.argv[1], encoding="utf-8").read().splitlines()[-1])
rows = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8") if line.strip()]
matches = [row for row in rows if row.get("trace_id") == result.get("trace_id")]
assert int(sys.argv[3]) in (0, 2)
assert result["project_id"] == "okr"
assert matches and matches[-1]["project"] == "okr" and matches[-1]["project_id"] == "okr"
PY
echo ""
echo "===== CHAT AGENT SUMMARY: PASS=$PASS FAIL=$FAIL ====="
[[ "$FAIL" -eq 0 ]] || exit 1
@@ -4,6 +4,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
python3 "$CASAN_APP_ROOT/packages/casan-devkit/tests/project-scaffold-tests.py"
bash "$CASAN_APP_ROOT/packages/casan-devkit/tests/adoption-install-tests.sh"
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \
--root "$CASAN_APP_ROOT" --manifest apps/okr/domain/project.manifest.json >/dev/null
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \