From 7b2ba60a3a460292cd492362aaa375c0b6434054 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Tue, 21 Jul 2026 23:40:26 +0700 Subject: [PATCH] feat(devkit): enforce governed prompt adoption --- bin/casan | 12 ++ docs/packaging/ADOPTION_GUIDE.md | 22 ++- docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md | 67 +++++++ packages/casan-devkit/install.sh | 75 +++++++- .../gitea-workflow/prompt-enforcement.yml | 28 +++ .../prompt-enforcement/agent-instructions.md | 11 ++ .../templates/prompt-enforcement/casan-chat | 166 ++++++++++++++++++ .../prompt-enforcement/casan-chat.ps1 | 22 +++ .../tests/adoption-install-tests.sh | 75 ++++++++ .../scripts/bash/chat-operator.py | 5 + .../scripts/bash/chat-readonly.py | 6 + .../casan-harness/scripts/bash/chat-turn.py | 1 + .../casan-harness/scripts/bash/model-call.py | 3 + .../scripts/bash/prompt-enforcement-verify.sh | 124 +++++++++++++ .../tests/phase-chat-agent-select-tests.sh | 16 ++ .../tests/phase-project-shell-tests.sh | 1 + 16 files changed, 622 insertions(+), 12 deletions(-) create mode 100644 docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md create mode 100644 packages/casan-devkit/templates/gitea-workflow/prompt-enforcement.yml create mode 100644 packages/casan-devkit/templates/prompt-enforcement/agent-instructions.md create mode 100755 packages/casan-devkit/templates/prompt-enforcement/casan-chat create mode 100644 packages/casan-devkit/templates/prompt-enforcement/casan-chat.ps1 create mode 100755 packages/casan-devkit/tests/adoption-install-tests.sh create mode 100755 packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh diff --git a/bin/casan b/bin/casan index 7bac319..fa43514 100755 --- a/bin/casan +++ b/bin/casan @@ -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 Create an idempotent NestJS/React project shell + prompt verify Verify the adopted prompt-enforcement contract + prompt trace 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 " >&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 " >&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 " >&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; } diff --git a/docs/packaging/ADOPTION_GUIDE.md b/docs/packaging/ADOPTION_GUIDE.md index 6c1b844..5350b3b 100644 --- a/docs/packaging/ADOPTION_GUIDE.md +++ b/docs/packaging/ADOPTION_GUIDE.md @@ -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 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//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//domain/input/requirement.md` (keep the `| FR-xx |` table). diff --git a/docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md b/docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md new file mode 100644 index 0000000..414ad0a --- /dev/null +++ b/docs/packaging/PROMPT_ENFORCEMENT_GUIDE.md @@ -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 +``` + +Only the second command proves that the individual prompt completed H1-H7 and has matching H6 project telemetry. diff --git a/packages/casan-devkit/install.sh b/packages/casan-devkit/install.sh index b8f2e2f..c7446b3 100755 --- a/packages/casan-devkit/install.sh +++ b/packages/casan-devkit/install.sh @@ -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 = "" +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 < done. Next steps in $TARGET: @@ -77,5 +131,8 @@ cat <" + 6. On Windows/WSL2 use: powershell -ExecutionPolicy Bypass -File bin/casan-chat.ps1 "" + 7. Verify enforcement: bin/casan prompt verify +See docs/casan/CASAN_PROMPT_ENFORCEMENT.md. EOF diff --git a/packages/casan-devkit/templates/gitea-workflow/prompt-enforcement.yml b/packages/casan-devkit/templates/gitea-workflow/prompt-enforcement.yml new file mode 100644 index 0000000..d626825 --- /dev/null +++ b/packages/casan-devkit/templates/gitea-workflow/prompt-enforcement.yml @@ -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" diff --git a/packages/casan-devkit/templates/prompt-enforcement/agent-instructions.md b/packages/casan-devkit/templates/prompt-enforcement/agent-instructions.md new file mode 100644 index 0000000..880a91d --- /dev/null +++ b/packages/casan-devkit/templates/prompt-enforcement/agent-instructions.md @@ -0,0 +1,11 @@ + +## 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 `. +- 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. + diff --git a/packages/casan-devkit/templates/prompt-enforcement/casan-chat b/packages/casan-devkit/templates/prompt-enforcement/casan-chat new file mode 100755 index 0000000..9cb7bdf --- /dev/null +++ b/packages/casan-devkit/templates/prompt-enforcement/casan-chat @@ -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 diff --git a/packages/casan-devkit/templates/prompt-enforcement/casan-chat.ps1 b/packages/casan-devkit/templates/prompt-enforcement/casan-chat.ps1 new file mode 100644 index 0000000..fba4d23 --- /dev/null +++ b/packages/casan-devkit/templates/prompt-enforcement/casan-chat.ps1 @@ -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 diff --git a/packages/casan-devkit/tests/adoption-install-tests.sh b/packages/casan-devkit/tests/adoption-install-tests.sh new file mode 100755 index 0000000..76693df --- /dev/null +++ b/packages/casan-devkit/tests/adoption-install-tests.sh @@ -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" diff --git a/packages/casan-harness/scripts/bash/chat-operator.py b/packages/casan-harness/scripts/bash/chat-operator.py index 00cb584..e21597d 100755 --- a/packages/casan-harness/scripts/bash/chat-operator.py +++ b/packages/casan-harness/scripts/bash/chat-operator.py @@ -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" diff --git a/packages/casan-harness/scripts/bash/chat-readonly.py b/packages/casan-harness/scripts/bash/chat-readonly.py index 1f9f784..c1dc250 100755 --- a/packages/casan-harness/scripts/bash/chat-readonly.py +++ b/packages/casan-harness/scripts/bash/chat-readonly.py @@ -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, diff --git a/packages/casan-harness/scripts/bash/chat-turn.py b/packages/casan-harness/scripts/bash/chat-turn.py index 1a6afb3..e15c469 100755 --- a/packages/casan-harness/scripts/bash/chat-turn.py +++ b/packages/casan-harness/scripts/bash/chat-turn.py @@ -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 diff --git a/packages/casan-harness/scripts/bash/model-call.py b/packages/casan-harness/scripts/bash/model-call.py index 40f3fd1..aeb20ee 100755 --- a/packages/casan-harness/scripts/bash/model-call.py +++ b/packages/casan-harness/scripts/bash/model-call.py @@ -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, diff --git a/packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh b/packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh new file mode 100755 index 0000000..a633f93 --- /dev/null +++ b/packages/casan-harness/scripts/bash/prompt-enforcement-verify.sh @@ -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 = "" +marker_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 diff --git a/packages/casan-harness/tests/phase-chat-agent-select-tests.sh b/packages/casan-harness/tests/phase-chat-agent-select-tests.sh index ce29060..83f69e5 100644 --- a/packages/casan-harness/tests/phase-chat-agent-select-tests.sh +++ b/packages/casan-harness/tests/phase-chat-agent-select-tests.sh @@ -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 diff --git a/packages/casan-harness/tests/phase-project-shell-tests.sh b/packages/casan-harness/tests/phase-project-shell-tests.sh index 12c831f..0b8540b 100644 --- a/packages/casan-harness/tests/phase-project-shell-tests.sh +++ b/packages/casan-harness/tests/phase-project-shell-tests.sh @@ -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 \