125 lines
4.5 KiB
Bash
Executable File
125 lines
4.5 KiB
Bash
Executable File
#!/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
|