feat(devkit): enforce governed prompt adoption
This commit is contained in:
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user