feat(devkit): enforce governed prompt adoption
This commit is contained in:
@@ -49,6 +49,8 @@ Commands:
|
|||||||
reuse Verify multi-project harness reuse (registry)
|
reuse Verify multi-project harness reuse (registry)
|
||||||
project validate [--manifest path] Validate a project manifest and quality profile
|
project validate [--manifest path] Validate a project manifest and quality profile
|
||||||
project init <scaffold args...> Create an idempotent NestJS/React project shell
|
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
|
pipeline [--manifest path] Run the manifest-driven SRS→test pipeline
|
||||||
dashboard [port] Serve the AgentOps dashboard (default 8787)
|
dashboard [port] Serve the AgentOps dashboard (default 8787)
|
||||||
version Print version
|
version Print version
|
||||||
@@ -85,6 +87,16 @@ case "$cmd" in
|
|||||||
exec python3 "$DEVKIT/project-scaffold.py" "$@" ;;
|
exec python3 "$DEVKIT/project-scaffold.py" "$@" ;;
|
||||||
*) echo "casan: usage: casan project <validate|init>" >&2; exit 64 ;;
|
*) echo "casan: usage: casan project <validate|init>" >&2; exit 64 ;;
|
||||||
esac ;;
|
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)
|
pipeline)
|
||||||
RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs"
|
RUNNER="$CASAN_APP_ROOT/scripts/run-casan-pipeline.mjs"
|
||||||
[[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; }
|
[[ -f "$RUNNER" ]] || { echo "casan: pipeline runner is not installed" >&2; exit 1; }
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
# CASAN Adoption Guide
|
# 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 +
|
How a downstream project adopts the CASAN governance harness. Adoption is **config +
|
||||||
domain only** — you never edit gate logic (H1→H7).
|
domain only** — you never edit gate logic (H1→H7).
|
||||||
|
|
||||||
## Option A — DevKit install (recommended)
|
## 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
|
```bash
|
||||||
packages/casan-devkit/install.sh --target ../my-project --project ticketing --domain "Ticketing"
|
packages/casan-devkit/install.sh --target ../my-project --project ticketing --domain "Ticketing"
|
||||||
```
|
```
|
||||||
This copies the core harness + `bin/casan` into `../my-project`, scaffolds
|
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`,
|
`apps/ticketing/domain/` from the domain-pack template, installs the prompt entrypoints and
|
||||||
and registers the project in `project-registry.json`.
|
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
|
## 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/
|
cp -R casan-core-v1.0.0/{packages,bin,VERSION} /path/to/project/
|
||||||
```
|
```
|
||||||
Then create `apps/<project>/domain/` yourself (see `DOMAIN_PACK_GUIDE.md`).
|
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)
|
## Option C — Docker (no install into repo)
|
||||||
```bash
|
```bash
|
||||||
docker run --rm -v "$PWD":/workspace -w /workspace casan-harness:1.0.0 casan gate
|
docker run --rm -v "$PWD":/workspace -w /workspace casan-harness:1.0.0 casan gate
|
||||||
```
|
```
|
||||||
See `DOCKER_GUIDE.md`.
|
See `DOCKER_GUIDE.md`.
|
||||||
|
This runtime-only option also does not enforce repository-agent entrypoints.
|
||||||
|
|
||||||
## After install
|
## After install
|
||||||
1. Requirement → `apps/<project>/domain/input/requirement.md` (keep the `| FR-xx |` table).
|
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.
|
||||||
@@ -24,31 +24,81 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
[[ -n "$TARGET" && -n "$PROJECT" ]] || { echo "install: --target and --project are required" >&2; exit 64; }
|
[[ -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
|
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; }
|
[[ -d "$SRC/packages/casan-harness" ]] || { echo "install: cannot find packages/casan-harness under $SRC" >&2; exit 1; }
|
||||||
|
|
||||||
if [[ -n "$TEMPLATE" ]]; then
|
if [[ -n "$TEMPLATE" ]]; then
|
||||||
exec python3 "$SRC/packages/casan-devkit/project-scaffold.py" \
|
python3 "$SRC/packages/casan-devkit/project-scaffold.py" \
|
||||||
--target "$TARGET" --project "$PROJECT" --name "$DOMAIN" --template "$TEMPLATE" --with-harness
|
--target "$TARGET" --project "$PROJECT" --name "$DOMAIN" --template "$TEMPLATE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)"
|
echo "==> installing CASAN core into $TARGET (project=$PROJECT domain=$DOMAIN)"
|
||||||
mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain"
|
mkdir -p "$TARGET/packages" "$TARGET/bin" "$TARGET/apps/$PROJECT/domain"
|
||||||
|
|
||||||
# 1) core harness + CLI
|
# 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"
|
cp "$SRC/bin/casan" "$TARGET/bin/casan"; chmod +x "$TARGET/bin/casan"
|
||||||
[[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION"
|
[[ -f "$SRC/VERSION" ]] && cp "$SRC/VERSION" "$TARGET/VERSION"
|
||||||
|
|
||||||
# 2) per-project domain from the domain-pack template
|
# 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)
|
# 3) Prompt enforcement entrypoints, policy, and repository-agent contracts.
|
||||||
mkdir -p "$TARGET/.gitea/workflows"
|
mkdir -p "$TARGET/.casan" "$TARGET/.github" "$TARGET/.gitea/workflows" "$TARGET/docs/casan"
|
||||||
cp "$SRC/packages/casan-devkit/templates/gitea-workflow/ci.yml" "$TARGET/.gitea/workflows/casan-ci.yml"
|
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"
|
REG="$TARGET/packages/casan-harness/level5/project-registry.json"
|
||||||
python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY'
|
python3 - "$REG" "$PROJECT" "$DOMAIN" <<'PY'
|
||||||
import json, os, sys
|
import json, os, sys
|
||||||
@@ -70,6 +120,10 @@ else:
|
|||||||
print(f"{pid} already registered")
|
print(f"{pid} already registered")
|
||||||
PY
|
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
|
cat <<EOF
|
||||||
|
|
||||||
==> done. Next steps in $TARGET:
|
==> done. Next steps in $TARGET:
|
||||||
@@ -77,5 +131,8 @@ cat <<EOF
|
|||||||
2. Add golden baseline in apps/$PROJECT/domain/golden-runs/
|
2. Add golden baseline in apps/$PROJECT/domain/golden-runs/
|
||||||
3. Run the gate: CASAN_DOMAIN_ROOT=apps/$PROJECT/domain bin/casan gate
|
3. Run the gate: CASAN_DOMAIN_ROOT=apps/$PROJECT/domain bin/casan gate
|
||||||
4. Prove reuse: bin/casan reuse # expects HARNESS_REUSE_VALID
|
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
|
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"
|
||||||
@@ -221,6 +221,8 @@ def record_metrics(trace_id: str, message: str, answer: str, status: str, latenc
|
|||||||
append_jsonl(metrics_path(), {
|
append_jsonl(metrics_path(), {
|
||||||
"timestamp": now_iso(),
|
"timestamp": now_iso(),
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"harness": "H6-agentops",
|
"harness": "H6-agentops",
|
||||||
"agent": "chat.operator",
|
"agent": "chat.operator",
|
||||||
"step": f"operator:{action_id or 'none'}",
|
"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({
|
rec = record_turn({
|
||||||
"timestamp": now_iso(),
|
"timestamp": now_iso(),
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"tenant_id": tenant_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,
|
"chat_id": chat_id,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"mode": router.get("mode", "OPERATOR"),
|
"mode": router.get("mode", "OPERATOR"),
|
||||||
"risk": router.get("risk", "medium"),
|
"risk": router.get("risk", "medium"),
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
@@ -322,6 +326,7 @@ def run(args) -> int:
|
|||||||
os.environ["CASAN_TENANT_ID"] = args.tenant
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
started = datetime.now(timezone.utc)
|
started = datetime.now(timezone.utc)
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
|
os.environ["CASAN_RUN_ID"] = trace_id
|
||||||
chat_id = args.chat_id or "chat-default"
|
chat_id = args.chat_id or "chat-default"
|
||||||
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
|
turn_id = args.turn_id or f"turn-{trace_id[:12]}"
|
||||||
actor = args.actor or "anonymous"
|
actor = args.actor or "anonymous"
|
||||||
|
|||||||
@@ -601,6 +601,8 @@ def record_metrics(trace_id: str, message: str, answer: str, status: str, latenc
|
|||||||
rec = {
|
rec = {
|
||||||
"timestamp": now_iso(),
|
"timestamp": now_iso(),
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"harness": "H6-agentops",
|
"harness": "H6-agentops",
|
||||||
"agent": "chat.ask-casan",
|
"agent": "chat.ask-casan",
|
||||||
"step": "ask-casan-readonly",
|
"step": "ask-casan-readonly",
|
||||||
@@ -627,6 +629,7 @@ def ask(args):
|
|||||||
os.environ["CASAN_TENANT_ID"] = args.tenant
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
started = datetime.now(timezone.utc)
|
started = datetime.now(timezone.utc)
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
|
os.environ["CASAN_RUN_ID"] = trace_id
|
||||||
message = args.message
|
message = args.message
|
||||||
router = classify(message)
|
router = classify(message)
|
||||||
actor = args.actor or "anonymous"
|
actor = args.actor or "anonymous"
|
||||||
@@ -647,6 +650,7 @@ def ask(args):
|
|||||||
rec = record_turn({
|
rec = record_turn({
|
||||||
"timestamp": now_iso(),
|
"timestamp": now_iso(),
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"tenant_id": tenant_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", {
|
record_trace_event(trace_id, "H6-agentops", "pass" if decision == "ANSWERED" else "error", "Runtime metrics recorded", {
|
||||||
"latency_ms": elapsed,
|
"latency_ms": elapsed,
|
||||||
"status": "success" if decision == "ANSWERED" else "failed",
|
"status": "success" if decision == "ANSWERED" else "failed",
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"synthesis_mode": (synthesis or {}).get("mode", "deterministic"),
|
"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", {
|
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,
|
"chat_id": chat_id,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
|
"project_id": os.environ.get("CASAN_PROJECT_ID", "default"),
|
||||||
"mode": router.get("mode", "BLOCK"),
|
"mode": router.get("mode", "BLOCK"),
|
||||||
"risk": router.get("risk", "high"),
|
"risk": router.get("risk", "high"),
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
|
|||||||
@@ -703,6 +703,7 @@ def submit_escalation(args, router, binding):
|
|||||||
|
|
||||||
|
|
||||||
def ask(args) -> int:
|
def ask(args) -> int:
|
||||||
|
os.environ["CASAN_PROJECT_ID"] = args.project or "default"
|
||||||
guard_rc = tenant_runtime_guard(args)
|
guard_rc = tenant_runtime_guard(args)
|
||||||
if guard_rc != 0:
|
if guard_rc != 0:
|
||||||
return guard_rc
|
return guard_rc
|
||||||
|
|||||||
@@ -613,6 +613,9 @@ def main():
|
|||||||
usage = {
|
usage = {
|
||||||
"timestamp": ts, "harness": "L5-provider-telemetry", "provider": backend,
|
"timestamp": ts, "harness": "L5-provider-telemetry", "provider": backend,
|
||||||
"model": model_name, "run_id": os.environ.get("CASAN_RUN_ID", "adhoc"),
|
"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,
|
"step": os.environ.get("CASAN_STEP_NAME", args.role), "role": args.role,
|
||||||
"input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"],
|
"input_tokens": result["input_tokens"], "output_tokens": result["output_tokens"],
|
||||||
"total_tokens": total, "cost_usd": 0.0, "cost_source": cost_source,
|
"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" \
|
&& grep -q "CHAT_AGENT_AUDIT ok=true" "$WORK/audit.out" \
|
||||||
&& pass "agent binding audit hash chain verifies" || fail "agent binding audit invalid"
|
&& 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 ""
|
||||||
echo "===== CHAT AGENT SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
echo "===== CHAT AGENT SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
[[ "$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"
|
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||||
|
|
||||||
python3 "$CASAN_APP_ROOT/packages/casan-devkit/tests/project-scaffold-tests.py"
|
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 \
|
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \
|
||||||
--root "$CASAN_APP_ROOT" --manifest apps/okr/domain/project.manifest.json >/dev/null
|
--root "$CASAN_APP_ROOT" --manifest apps/okr/domain/project.manifest.json >/dev/null
|
||||||
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \
|
python3 "$CASAN_HARNESS_ROOT/scripts/bash/project_manifest.py" validate \
|
||||||
|
|||||||
Reference in New Issue
Block a user