update first - 84
This commit is contained in:
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H6 AgentOps Harness
|
||||
# Usage:
|
||||
# agent-metrics.sh <input-file> <output-file> [-- <command> ...]
|
||||
#
|
||||
# If command is omitted, the script performs a pass-through copy. If command is
|
||||
# provided, it runs with CASAN_INPUT and CASAN_OUTPUT environment variables.
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
shift 2 || true
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: agent-metrics.sh <input-file> <output-file> [-- <command> ...]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
METRICS_DIR="$LOG_DIR/cost"
|
||||
ALERT_LOG="$PROJECT_ROOT/.specify/agentops/alerts.log"
|
||||
METRICS_LOG="$METRICS_DIR/metrics.jsonl"
|
||||
mkdir -p "$TRACE_DIR" "$METRICS_DIR" "$(dirname "$OUTPUT_FILE")" "$(dirname "$ALERT_LOG")"
|
||||
|
||||
# shellcheck source=tool-audit-lib.sh
|
||||
source "$SCRIPT_DIR/tool-audit-lib.sh"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "AGENTOPS_FAILED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
epoch_ms() {
|
||||
python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || printf '%s000\n' "$(date +%s)"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
word_count() {
|
||||
wc -w < "$1" | tr -d ' '
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
START_TS="$(timestamp)"
|
||||
START_MS="$(epoch_ms)"
|
||||
STATUS="success"
|
||||
ERROR_MSG=""
|
||||
EXIT_CODE=0
|
||||
RETRY_COUNT="${CASAN_RETRY_COUNT:-0}"
|
||||
AGENT_NAME="${CASAN_AGENT_NAME:-unknown-agent}"
|
||||
STEP_NAME="${CASAN_STEP_NAME:-unknown-step}"
|
||||
|
||||
INPUT_TOKENS="$(word_count "$INPUT_FILE")"
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
set +e
|
||||
CASAN_INPUT="$INPUT_FILE" CASAN_OUTPUT="$OUTPUT_FILE" "$@"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
if [[ "$EXIT_CODE" -ne 0 ]]; then
|
||||
STATUS="failed"
|
||||
ERROR_MSG="command exited with code $EXIT_CODE"
|
||||
fi
|
||||
|
||||
TOOL_AUDIT_RECORD="$(python3 - "$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$*" "$EXIT_CODE" "$STATUS" <<'PY'
|
||||
import json, sys
|
||||
ts, trace, agent, step, cmd, code, status = sys.argv[1:]
|
||||
print(json.dumps({
|
||||
"timestamp": ts, "trace_id": trace, "agent": agent, "step": step,
|
||||
"tool": "Bash", "command": cmd, "exit_code": int(code), "status": status,
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
append_tool_audit "$TOOL_AUDIT_RECORD" "$PROJECT_ROOT"
|
||||
else
|
||||
cp "$INPUT_FILE" "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
END_MS="$(epoch_ms)"
|
||||
LATENCY_MS=$((END_MS - START_MS))
|
||||
|
||||
if [[ ! -f "$OUTPUT_FILE" ]]; then
|
||||
STATUS="failed"
|
||||
ERROR_MSG="${ERROR_MSG:-output file not produced}"
|
||||
: > "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
OUTPUT_TOKENS="$(word_count "$OUTPUT_FILE")"
|
||||
TOTAL_TOKENS=$((INPUT_TOKENS + OUTPUT_TOKENS))
|
||||
COST_PER_1K="${CASAN_COST_PER_1K:-0.002}"
|
||||
COST_ESTIMATE="$(python3 - "$TOTAL_TOKENS" "$COST_PER_1K" <<'PY'
|
||||
import sys
|
||||
tokens = int(sys.argv[1])
|
||||
rate = float(sys.argv[2])
|
||||
print(f"{tokens * rate / 1000:.8f}")
|
||||
PY
|
||||
)"
|
||||
COST_SOURCE="word_count_estimate"
|
||||
|
||||
# Prefer real provider usage when telemetry has been imported; the word-count
|
||||
# figure above is an explicit fallback, not presented as a real billed cost.
|
||||
PROVIDER_LOG="$PROJECT_ROOT/.specify/logs/level5/provider-usage.jsonl"
|
||||
if [[ -f "$PROVIDER_LOG" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
# Use real provider telemetry ONLY when a record genuinely matches this step.
|
||||
# Do NOT fall back to an arbitrary record (that would reuse one sample's cost
|
||||
# across every step and misrepresent it as real per-step billing).
|
||||
PROV="$(python3 "$SCRIPT_DIR/provider-cost-lookup.py" "$PROVIDER_LOG" "$STEP_NAME")"
|
||||
if [[ -n "$PROV" ]]; then
|
||||
TOTAL_TOKENS="${PROV%% *}"
|
||||
COST_ESTIMATE="${PROV##* }"
|
||||
COST_SOURCE="provider_telemetry"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Real hallucination-signal detection (populates hallucination-tracking.yaml's metric).
|
||||
HALLU_YAML="$PROJECT_ROOT/.specify/agentops/hallucination-tracking.yaml"
|
||||
HALLUCINATION_SIGNALS=0
|
||||
HALLUCINATION_MATCHED="[]"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
HSCAN="$(python3 "$SCRIPT_DIR/hallucination-scan.py" "$HALLU_YAML" "$OUTPUT_FILE" 2>/dev/null || printf '0\n[]')"
|
||||
HALLUCINATION_SIGNALS="$(printf '%s' "$HSCAN" | head -1)"
|
||||
HALLUCINATION_MATCHED="$(printf '%s' "$HSCAN" | tail -1)"
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||
OUTPUT_HASH="$(cat "$OUTPUT_FILE" | hash_text)"
|
||||
|
||||
ALERTS=()
|
||||
if [[ "$LATENCY_MS" -gt "${CASAN_LATENCY_ALERT_MS:-5000}" ]]; then
|
||||
ALERTS+=("high-latency")
|
||||
fi
|
||||
if [[ "$RETRY_COUNT" -gt "${CASAN_RETRY_ALERT_THRESHOLD:-2}" ]]; then
|
||||
ALERTS+=("high-retry")
|
||||
fi
|
||||
if [[ "$STATUS" == "failed" ]]; then
|
||||
ALERTS+=("execution-failed")
|
||||
fi
|
||||
if [[ "$TOTAL_TOKENS" -gt "${CASAN_TOKEN_ALERT_THRESHOLD:-5000}" ]]; then
|
||||
ALERTS+=("token-overuse")
|
||||
fi
|
||||
if [[ "$HALLUCINATION_SIGNALS" -ge "${CASAN_HALLUCINATION_WARN:-3}" ]]; then
|
||||
ALERTS+=("hallucination-suspected")
|
||||
fi
|
||||
|
||||
ALERTS_JSON="$(printf '%s\n' "${ALERTS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
TRACE_FILE="$TRACE_DIR/agentops-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$START_TS",
|
||||
"harness": "H6-agentops",
|
||||
"agent": "$AGENT_NAME",
|
||||
"step": "$STEP_NAME",
|
||||
"status": "$STATUS",
|
||||
"exit_code": $EXIT_CODE,
|
||||
"latency_ms": $LATENCY_MS,
|
||||
"retry_count": $RETRY_COUNT,
|
||||
"input_tokens": $INPUT_TOKENS,
|
||||
"output_tokens": $OUTPUT_TOKENS,
|
||||
"total_tokens": $TOTAL_TOKENS,
|
||||
"cost_estimate": $COST_ESTIMATE,
|
||||
"cost_source": "$COST_SOURCE",
|
||||
"hallucination_signals": $HALLUCINATION_SIGNALS,
|
||||
"hallucination_matched": $HALLUCINATION_MATCHED,
|
||||
"alerts": $ALERTS_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH",
|
||||
"error": "$ERROR_MSG"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H6-agentops","agent":"%s","step":"%s","status":"%s","exit_code":%s,"latency_ms":%s,"retry_count":%s,"input_tokens":%s,"output_tokens":%s,"total_tokens":%s,"cost_estimate":%s,"cost_source":"%s","hallucination_signals":%s,"alerts":%s,"input_hash":"%s","output_hash":"%s"}\n' \
|
||||
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$STEP_NAME" "$STATUS" "$EXIT_CODE" "$LATENCY_MS" "$RETRY_COUNT" "$INPUT_TOKENS" "$OUTPUT_TOKENS" "$TOTAL_TOKENS" "$COST_ESTIMATE" "$COST_SOURCE" "$HALLUCINATION_SIGNALS" "$ALERTS_JSON" "$INPUT_HASH" "$OUTPUT_HASH" >> "$METRICS_LOG"
|
||||
|
||||
for alert in "${ALERTS[@]:-}"; do
|
||||
if [[ -n "$alert" ]]; then
|
||||
printf '{"timestamp":"%s","trace_id":"%s","severity":"WARN","resource":{"service.name":"%s","service.version":"1.0.0"},"body":{"message":"Alert triggered: %s","alert.type":"%s","step.name":"%s"},"attributes":{"latency_ms":%s,"status":"%s"}}\n' \
|
||||
"$START_TS" "$TRACE_ID" "$AGENT_NAME" "$alert" "$alert" "$STEP_NAME" "$LATENCY_MS" "$STATUS" >> "$ALERT_LOG"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "AGENTOPS_RECORDED trace_id=$TRACE_ID status=$STATUS latency_ms=$LATENCY_MS tokens=$TOTAL_TOKENS cost=$COST_ESTIMATE output=$OUTPUT_FILE"
|
||||
exit "$EXIT_CODE"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 business KPI feedback report.
|
||||
# Usage:
|
||||
# business-kpi-report.sh <input-json> <output-json>
|
||||
|
||||
INPUT_JSON="${1:-}"
|
||||
OUTPUT_JSON="${2:-}"
|
||||
if [[ -z "$INPUT_JSON" || -z "$OUTPUT_JSON" ]]; then
|
||||
echo "Usage: business-kpi-report.sh <input-json> <output-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_JSON")"
|
||||
|
||||
python3 - "$INPUT_JSON" "$OUTPUT_JSON" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
input_path, output_path = sys.argv[1], sys.argv[2]
|
||||
data = json.load(open(input_path, encoding="utf-8"))
|
||||
results = []
|
||||
for item in data["kpis"]:
|
||||
baseline = float(item["baseline"])
|
||||
current = float(item["current"])
|
||||
target = float(item["target"])
|
||||
direction = item.get("direction", "lower_is_better")
|
||||
if direction == "lower_is_better":
|
||||
improvement = (baseline - current) / baseline if baseline else 0
|
||||
target_met = current <= target
|
||||
else:
|
||||
improvement = (current - baseline) / baseline if baseline else 0
|
||||
target_met = current >= target
|
||||
results.append({
|
||||
"id": item["id"],
|
||||
"baseline": baseline,
|
||||
"current": current,
|
||||
"target": target,
|
||||
"improvement_ratio": round(improvement, 4),
|
||||
"target_met": target_met,
|
||||
})
|
||||
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-business-feedback",
|
||||
"status": "pass" if all(r["target_met"] for r in results) else "warn",
|
||||
"kpis": results,
|
||||
}
|
||||
json.dump(report, open(output_path, "w", encoding="utf-8"), indent=2)
|
||||
print(f"KPI_REPORT status={report['status']} output={output_path}")
|
||||
PY
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 unified harness wrapper.
|
||||
# Usage:
|
||||
# casan-harness.sh <input-file> <output-file> [action-name] [-- <command> ...]
|
||||
#
|
||||
# Flow:
|
||||
# H4 input security -> H5 governance -> H6 metrics around execution/cache -> H4 output filter
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
FINAL_OUTPUT="${2:-}"
|
||||
ACTION_NAME="${3:-agent_step}"
|
||||
shift 3 || true
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$FINAL_OUTPUT" ]]; then
|
||||
echo "Usage: casan-harness.sh <input-file> <output-file> [action-name] [-- <command> ...]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
TMP_DIR="$PROJECT_ROOT/.specify/logs/tmp"
|
||||
CACHE_DIR="$PROJECT_ROOT/.specify/logs/idempotency"
|
||||
mkdir -p "$TMP_DIR" "$CACHE_DIR" "$(dirname "$FINAL_OUTPUT")"
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
CMD_STR="${*:-no_cmd}"
|
||||
INPUT_HASH="$(cat "$INPUT_FILE" | hash_text)"
|
||||
CMD_HASH="$(printf '%s' "$CMD_STR" | hash_text)"
|
||||
IDEMPOTENCY_KEY="$(printf '%s|%s|%s' "$INPUT_HASH" "$CMD_HASH" "$ACTION_NAME" | hash_text)"
|
||||
CACHE_META="$CACHE_DIR/$IDEMPOTENCY_KEY.json"
|
||||
CACHE_OUT="$CACHE_DIR/$IDEMPOTENCY_KEY.output"
|
||||
|
||||
TRACE_SUFFIX="$(date +%s)-$$"
|
||||
SAFE_INPUT="$TMP_DIR/security-input-$TRACE_SUFFIX.txt"
|
||||
APPROVED_INPUT="$TMP_DIR/governance-approved-$TRACE_SUFFIX.txt"
|
||||
RAW_OUTPUT="$TMP_DIR/raw-output-$TRACE_SUFFIX.txt"
|
||||
|
||||
"$SCRIPT_DIR/security-check.sh" "$INPUT_FILE" "$SAFE_INPUT" input
|
||||
"$SCRIPT_DIR/governance-check.sh" "$SAFE_INPUT" "$APPROVED_INPUT" "$ACTION_NAME"
|
||||
|
||||
# H2 tool registry gate is in the line of fire for side-effecting actions:
|
||||
# it enforces idempotency key, per-agent permission, and rollback strategy
|
||||
# before the command is allowed to execute. The wrapper already derived a
|
||||
# content-addressed idempotency key above.
|
||||
case "$ACTION_NAME" in
|
||||
write_code|migration|deploy|db_write|external_api|write_file)
|
||||
CASAN_IDEMPOTENCY_KEY="$IDEMPOTENCY_KEY" "$SCRIPT_DIR/tool-registry-gate.sh" "$ACTION_NAME"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$CACHE_META" && -f "$CACHE_OUT" ]]; then
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- bash -c 'cp "$1" "$CASAN_OUTPUT"' _ "$CACHE_OUT"
|
||||
CACHE_STATUS="cached"
|
||||
elif [[ "$#" -gt 0 ]]; then
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT" -- "$@"
|
||||
CACHE_STATUS="stored"
|
||||
else
|
||||
"$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
|
||||
CACHE_STATUS="stored"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/security-check.sh" "$RAW_OUTPUT" "$FINAL_OUTPUT" output
|
||||
|
||||
if [[ "$CACHE_STATUS" == "stored" ]]; then
|
||||
cat <<EOF > "$CACHE_META"
|
||||
{
|
||||
"idempotency_key": "$IDEMPOTENCY_KEY",
|
||||
"timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"action": "$ACTION_NAME",
|
||||
"command": "$(printf '%s' "$CMD_STR" | sed 's/"/\\"/g')",
|
||||
"output_hash": "$(cat "$FINAL_OUTPUT" | hash_text)"
|
||||
}
|
||||
EOF
|
||||
cp "$FINAL_OUTPUT" "$CACHE_OUT"
|
||||
fi
|
||||
|
||||
echo "CASAN_HARNESS_COMPLETE cache=$CACHE_STATUS key=$IDEMPOTENCY_KEY output=$FINAL_OUTPUT"
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Consolidated prerequisite checking script
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --json Output in JSON format
|
||||
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# --paths-only Only output path variables (no validation)
|
||||
# --help, -h Show help message
|
||||
#
|
||||
# OUTPUTS:
|
||||
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--require-tasks)
|
||||
REQUIRE_TASKS=true
|
||||
;;
|
||||
--include-tasks)
|
||||
INCLUDE_TASKS=true
|
||||
;;
|
||||
--paths-only)
|
||||
PATHS_ONLY=true
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Usage: check-prerequisites.sh [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check-prerequisites.sh --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check-prerequisites.sh --paths-only
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
eval $(get_feature_paths)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
|
||||
else
|
||||
echo "REPO_ROOT: $REPO_ROOT"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "TASKS: $TASKS"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run /speckit.tasks first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build list of available documents
|
||||
docs=()
|
||||
|
||||
# Always check these optional docs
|
||||
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||
docs+=("contracts/")
|
||||
fi
|
||||
|
||||
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||
docs+=("tasks.md")
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '"%s",' "${docs[@]}")
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
|
||||
else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
echo "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
check_file "$RESEARCH" "research.md"
|
||||
check_file "$DATA_MODEL" "data-model.md"
|
||||
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||
check_file "$QUICKSTART" "quickstart.md"
|
||||
|
||||
if $INCLUDE_TASKS; then
|
||||
check_file "$TASKS" "tasks.md"
|
||||
fi
|
||||
fi
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common functions and variables for all scripts
|
||||
|
||||
# Get repository root, with fallback for non-git repositories
|
||||
get_repo_root() {
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
else
|
||||
# Fall back to script location for non-git repos
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
get_current_branch() {
|
||||
# First check if SPECIFY_FEATURE environment variable is set
|
||||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||
echo "$SPECIFY_FEATURE"
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available
|
||||
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local repo_root=$(get_repo_root)
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
local latest_feature=""
|
||||
local highest=0
|
||||
|
||||
for dir in "$specs_dir"/*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
local dirname=$(basename "$dir")
|
||||
if [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||||
local number=${BASH_REMATCH[1]}
|
||||
number=$((10#$number))
|
||||
if [[ "$number" -gt "$highest" ]]; then
|
||||
highest=$number
|
||||
latest_feature=$dirname
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$latest_feature" ]]; then
|
||||
echo "$latest_feature"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available
|
||||
has_git() {
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
get_feature_dir() { echo "$1/specs/$2"; }
|
||||
|
||||
# Find feature directory by numeric prefix instead of exact branch match
|
||||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||
find_feature_dir_by_prefix() {
|
||||
local repo_root="$1"
|
||||
local branch_name="$2"
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
||||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||||
# If branch doesn't have numeric prefix, fall back to exact match
|
||||
echo "$specs_dir/$branch_name"
|
||||
return
|
||||
fi
|
||||
|
||||
local prefix="${BASH_REMATCH[1]}"
|
||||
|
||||
# Search for directories in specs/ that start with this prefix
|
||||
local matches=()
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
for dir in "$specs_dir"/"$prefix"-*; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
matches+=("$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Handle results
|
||||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||
# No match found - return the branch name path (will fail later with clear error)
|
||||
echo "$specs_dir/$branch_name"
|
||||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||
# Exactly one match - perfect!
|
||||
echo "$specs_dir/${matches[0]}"
|
||||
else
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
||||
echo "$specs_dir/$branch_name" # Return something to avoid breaking the script
|
||||
fi
|
||||
}
|
||||
|
||||
get_feature_paths() {
|
||||
local repo_root=$(get_repo_root)
|
||||
local current_branch=$(get_current_branch)
|
||||
local has_git_repo="false"
|
||||
|
||||
if has_git; then
|
||||
has_git_repo="true"
|
||||
fi
|
||||
|
||||
# Use prefix-based lookup to support multiple branches per spec
|
||||
local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch")
|
||||
|
||||
cat <<EOF
|
||||
REPO_ROOT='$repo_root'
|
||||
CURRENT_BRANCH='$current_branch'
|
||||
HAS_GIT='$has_git_repo'
|
||||
FEATURE_DIR='$feature_dir'
|
||||
FEATURE_SPEC='$feature_dir/spec.md'
|
||||
IMPL_PLAN='$feature_dir/plan.md'
|
||||
TASKS='$feature_dir/tasks.md'
|
||||
RESEARCH='$feature_dir/research.md'
|
||||
DATA_MODEL='$feature_dir/data-model.md'
|
||||
QUICKSTART='$feature_dir/quickstart.md'
|
||||
CONTRACTS_DIR='$feature_dir/contracts'
|
||||
EOF
|
||||
}
|
||||
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H1 context validator.
|
||||
# Before a sub-agent trusts pipeline-context.yaml, verify every referenced
|
||||
# artifact / trace file actually exists on disk, and (optionally) is not
|
||||
# staler than CASAN_CONTEXT_TTL_SECONDS relative to the context file itself.
|
||||
# Catches renamed/deleted/missing artifacts before they cause a silent bad read.
|
||||
#
|
||||
# Usage: context-validate.sh <pipeline-context.yaml>
|
||||
# Exit: 0 all good, 2 a referenced path is missing, 3 a referenced path is stale.
|
||||
|
||||
CTX="${1:-}"
|
||||
if [[ -z "$CTX" || ! -f "$CTX" ]]; then
|
||||
echo "Usage: context-validate.sh <pipeline-context.yaml>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
CTX_DIR="$(cd "$(dirname "$CTX")" && pwd)"
|
||||
# Resolve paths relative to the repo root (3 levels up from this script).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
python3 - "$CTX" "$PROJECT_ROOT" "${CASAN_CONTEXT_TTL_SECONDS:-0}" <<'PY'
|
||||
import os, re, sys
|
||||
|
||||
ctx, root, ttl = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||
ctx_mtime = os.path.getmtime(ctx)
|
||||
missing, stale, checked = [], [], 0
|
||||
|
||||
with open(ctx, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"\s*(?:artifact|trace_file|path|data-model|spec|plan):\s*(\S+)", line)
|
||||
if not m:
|
||||
continue
|
||||
ref = m.group(1).strip().strip('"').strip("'")
|
||||
if ref in ("", "null", "<from", "pipeline-context>"):
|
||||
continue
|
||||
if "<" in ref or ref.endswith(">"):
|
||||
continue # unresolved template placeholder, not a concrete path
|
||||
cand = ref if os.path.isabs(ref) else os.path.join(root, ref)
|
||||
checked += 1
|
||||
if not os.path.exists(cand):
|
||||
missing.append(ref)
|
||||
continue
|
||||
if ttl > 0 and (ctx_mtime - os.path.getmtime(cand)) > ttl:
|
||||
stale.append(ref)
|
||||
|
||||
if missing:
|
||||
sys.stderr.write("CONTEXT_INVALID missing=%d: %s\n" % (len(missing), ", ".join(missing)))
|
||||
raise SystemExit(2)
|
||||
if stale:
|
||||
sys.stderr.write("CONTEXT_STALE stale=%d: %s\n" % (len(stale), ", ".join(stale)))
|
||||
raise SystemExit(3)
|
||||
print(f"CONTEXT_VALID checked={checked} all referenced artifacts present")
|
||||
PY
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
# Check if the next argument is another option (starts with --)
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to find the repository root by searching for existing project markers
|
||||
find_repo_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
local highest=0
|
||||
|
||||
# Get all branches (local and remote)
|
||||
branches=$(git branch -a 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$branches" ]; then
|
||||
while IFS= read -r branch; do
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done <<< "$branches"
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches (local and remote) and return next available number
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune 2>/dev/null || true
|
||||
|
||||
# Get highest number from ALL branches (not just matching short name)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
|
||||
# Get highest number from ALL specs (not just matching short name)
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
# Take the maximum of both
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
# Return next number
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root. Prefer git information when available, but fall back
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialised with --no-git.
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
HAS_GIT=true
|
||||
else
|
||||
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
mkdir -p "$SPECS_DIR"
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
# Common stop words to filter out
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
# Convert to lowercase and split into words
|
||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
# Skip empty words
|
||||
[ -z "$word" ] && continue
|
||||
|
||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
# Fallback to original logic if no meaningful words found
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
# Use provided short name, just clean it up
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
# Generate from description with smart filtering
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# Determine branch number
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
# Check existing branches on remotes
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
# Fall back to local directory check
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
MAX_BRANCH_LENGTH=244
|
||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for: feature number (3) + hyphen (1) = 4 chars
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
||||
# Check if branch already exists
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||
|
||||
if $JSON_MODE; then
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 drift detector.
|
||||
# Usage:
|
||||
# drift-detect.sh <golden-file> <candidate-file> <report-json>
|
||||
|
||||
GOLDEN="${1:-}"
|
||||
CANDIDATE="${2:-}"
|
||||
REPORT="${3:-}"
|
||||
|
||||
if [[ -z "$GOLDEN" || -z "$CANDIDATE" || -z "$REPORT" ]]; then
|
||||
echo "Usage: drift-detect.sh <golden-file> <candidate-file> <report-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
mkdir -p "$(dirname "$REPORT")" "$PROJECT_ROOT/.specify/logs/level5"
|
||||
|
||||
python3 - "$GOLDEN" "$CANDIDATE" "$REPORT" <<'PY'
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
golden_path = pathlib.Path(sys.argv[1])
|
||||
candidate_path = pathlib.Path(sys.argv[2])
|
||||
report_path = pathlib.Path(sys.argv[3])
|
||||
|
||||
golden = golden_path.read_text(encoding="utf-8")
|
||||
candidate = candidate_path.read_text(encoding="utf-8")
|
||||
|
||||
similarity = difflib.SequenceMatcher(None, golden, candidate).ratio()
|
||||
length_delta = abs(len(candidate) - len(golden)) / max(len(golden), 1)
|
||||
|
||||
status = "pass"
|
||||
action = "allow"
|
||||
if similarity < 0.70 or length_delta > 0.50:
|
||||
status = "fail"
|
||||
action = "block_or_fallback"
|
||||
elif similarity < 0.85 or length_delta > 0.30:
|
||||
status = "warn"
|
||||
action = "require_review"
|
||||
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-drift-detection",
|
||||
"status": status,
|
||||
"action": action,
|
||||
"similarity_ratio": round(similarity, 4),
|
||||
"length_delta_ratio": round(length_delta, 4),
|
||||
"golden_hash": hashlib.sha256(golden.encode()).hexdigest(),
|
||||
"candidate_hash": hashlib.sha256(candidate.encode()).hexdigest(),
|
||||
"golden_file": str(golden_path),
|
||||
"candidate_file": str(candidate_path),
|
||||
}
|
||||
|
||||
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"DRIFT_{status.upper()} similarity={report['similarity_ratio']} length_delta={report['length_delta_ratio']} report={report_path}")
|
||||
|
||||
if status == "fail":
|
||||
raise SystemExit(2)
|
||||
PY
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H5 Governance Harness
|
||||
# Usage:
|
||||
# governance-check.sh <input-file> <output-file> [action-name]
|
||||
#
|
||||
# Non-interactive by default. High-risk actions are denied unless:
|
||||
# CASAN_APPROVAL_DECISION=approve CASAN_APPROVER=<name>
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
ACTION_NAME="${3:-agent_step}"
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: governance-check.sh <input-file> <output-file> [action-name]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "GOVERNANCE_DENIED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
INPUT="$(cat "$INPUT_FILE")"
|
||||
LOWER_INPUT="$(printf '%s' "$INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
ACTOR="${CASAN_ACTOR:-developer}"
|
||||
APPROVER="${CASAN_APPROVER:-}"
|
||||
APPROVAL_DECISION="${CASAN_APPROVAL_DECISION:-auto}"
|
||||
AUDIT_LOG="$AUDIT_DIR/audit.jsonl"
|
||||
|
||||
RISK_LEVEL="low"
|
||||
REASONS=()
|
||||
|
||||
case "$ACTION_NAME" in
|
||||
deploy|launch|write_code|write_file|migration|db_write|external_api|tool_call)
|
||||
RISK_LEVEL="medium"
|
||||
REASONS+=("sensitive-action:$ACTION_NAME")
|
||||
;;
|
||||
esac
|
||||
|
||||
if printf '%s' "$LOWER_INPUT" | grep -Eq "(delete|drop table|password|api[_-]?key|secret|token|credential|migration|deploy|external api|shutdown|dump database)"; then
|
||||
RISK_LEVEL="high"
|
||||
REASONS+=("high-risk-content")
|
||||
elif printf '%s' "$LOWER_INPUT" | grep -Eq "(internal|config|system|policy|permission)"; then
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
REASONS+=("medium-risk-content")
|
||||
fi
|
||||
|
||||
APPROVAL_STATUS="auto_approved"
|
||||
DECISION="approved"
|
||||
|
||||
if [[ "$RISK_LEVEL" == "medium" ]]; then
|
||||
APPROVAL_STATUS="policy_auto_approved_with_audit"
|
||||
fi
|
||||
|
||||
if [[ "$RISK_LEVEL" == "high" ]]; then
|
||||
if [[ "$APPROVAL_DECISION" == "approve" && -n "$APPROVER" ]]; then
|
||||
if [[ "$APPROVER" == "$ACTOR" ]]; then
|
||||
# Separation of duties: the submitter may not approve their own action.
|
||||
APPROVAL_STATUS="separation_of_duties_violation"
|
||||
DECISION="denied"
|
||||
REASONS+=("separation-of-duties:actor-equals-approver")
|
||||
else
|
||||
APPROVAL_STATUS="human_approved"
|
||||
DECISION="approved"
|
||||
fi
|
||||
else
|
||||
APPROVAL_STATUS="approval_required"
|
||||
DECISION="denied"
|
||||
fi
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$INPUT" | hash_text)"
|
||||
OUTPUT_CONTENT="$INPUT"
|
||||
OUTPUT_HASH="$(printf '%s' "$OUTPUT_CONTENT" | hash_text)"
|
||||
PREV_HASH=""
|
||||
if [[ -s "$AUDIT_LOG" ]]; then
|
||||
PREV_HASH="$(tail -n 1 "$AUDIT_LOG" | sed -n 's/.*"record_hash":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
|
||||
REASONS_JSON="$(printf '%s\n' "${REASONS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
# approver and output_hash are part of the hashed core so they cannot be
|
||||
# silently mutated after the fact.
|
||||
RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH")"
|
||||
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"harness": "H5-governance",
|
||||
"action": "$ACTION_NAME",
|
||||
"actor": "$ACTOR",
|
||||
"risk_level": "$RISK_LEVEL",
|
||||
"decision": "$DECISION",
|
||||
"approval_status": "$APPROVAL_STATUS",
|
||||
"approver": "$APPROVER",
|
||||
"reasons": $REASONS_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH",
|
||||
"previous_record_hash": "$PREV_HASH",
|
||||
"record_hash": "$RECORD_HASH"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$AUDIT_LOG"
|
||||
|
||||
# --- External anchor: cryptographically sign the new chain head ---
|
||||
# A re-forged chain (recomputed hashes) changes the head; without the private
|
||||
# key the attacker cannot produce a matching signature, so verification fails.
|
||||
# Production note: the private key must live off-repo (KMS/HSM). It is local
|
||||
# here only for self-contained demonstration.
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||
# public key is committed. Production: replace with KMS/HSM.
|
||||
PUB_DIR="$PROJECT_ROOT/.specify/level5/central-governance"
|
||||
PRIV_DIR="${CASAN_AUDIT_KEY_DIR:-$HOME/.casan/audit-keys}"
|
||||
AUDIT_PRIV="$PRIV_DIR/audit-private.pem"
|
||||
AUDIT_PUB="$PUB_DIR/audit-public.pem"
|
||||
mkdir -p "$PUB_DIR" "$PRIV_DIR"
|
||||
if [[ ! -f "$AUDIT_PRIV" ]]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
|
||||
chmod 600 "$AUDIT_PRIV"
|
||||
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null
|
||||
fi
|
||||
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
|
||||
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$DECISION" != "approved" ]]; then
|
||||
: > "$OUTPUT_FILE"
|
||||
echo "GOVERNANCE_DENIED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$OUTPUT_CONTENT" > "$OUTPUT_FILE"
|
||||
echo "GOVERNANCE_APPROVED trace_id=$TRACE_ID risk=$RISK_LEVEL approval_status=$APPROVAL_STATUS output=$OUTPUT_FILE"
|
||||
@@ -0,0 +1,25 @@
|
||||
INPUT
|
||||
↓
|
||||
security-check.sh input (H4: prompt injection, PII, secret)
|
||||
↓
|
||||
governance-check.sh (H5: risk, approval, hash-chain audit)
|
||||
↓
|
||||
agent-metrics.sh (H6: latency, tokens, cost, retry, status, alert)
|
||||
↓
|
||||
security-check.sh output (H4: output redaction/filter)
|
||||
↓
|
||||
OUTPUT
|
||||
↓
|
||||
LOG + TRACE + METRICS + AUDIT
|
||||
|
||||
Unified wrapper:
|
||||
|
||||
```bash
|
||||
.specify/scripts/bash/casan-harness.sh input.txt output.txt agent_step
|
||||
```
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
bash .specify/tests/run-casan4-harness-tests.sh
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H6 hallucination signal detector.
|
||||
|
||||
Reads the quoted keyword markers from hallucination-tracking.yaml plus a set of
|
||||
generic uncertainty markers, scans the agent output, and reports how many
|
||||
hallucination signals were found. This turns hallucination-tracking.yaml from
|
||||
dead config into a real, populated metric written to metrics.jsonl.
|
||||
|
||||
Usage: hallucination-scan.py <hallucination-tracking.yaml> <output-file>
|
||||
Output (stdout): line 1 = integer signal count, line 2 = JSON list of matches.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
GENERIC_MARKERS = [
|
||||
"maybe", "might be incorrect", "i am not sure", "uncertain",
|
||||
"i think", "probably", "as far as i know",
|
||||
]
|
||||
|
||||
|
||||
def load_keywords(path):
|
||||
keywords = []
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
# Quoted list items are the hallucination keyword markers
|
||||
# (unquoted list items are structured signal names, not text).
|
||||
m = re.match(r'\s*-\s*"(.+)"\s*$', line)
|
||||
if m:
|
||||
keywords.append(m.group(1))
|
||||
except OSError:
|
||||
pass
|
||||
return keywords
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(0)
|
||||
print("[]")
|
||||
return
|
||||
keywords = load_keywords(sys.argv[1]) + GENERIC_MARKERS
|
||||
try:
|
||||
with open(sys.argv[2], encoding="utf-8") as fh:
|
||||
text = fh.read().lower()
|
||||
except OSError:
|
||||
print(0)
|
||||
print("[]")
|
||||
return
|
||||
matched = []
|
||||
for kw in keywords:
|
||||
k = kw.lower()
|
||||
if not k:
|
||||
continue
|
||||
count = text.count(k)
|
||||
if count:
|
||||
matched.append({"marker": kw, "count": count})
|
||||
total = sum(m["count"] for m in matched)
|
||||
print(total)
|
||||
print(json.dumps(matched))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 provider usage telemetry importer.
|
||||
# Usage:
|
||||
# import-provider-telemetry.sh <provider-usage-json>
|
||||
|
||||
INPUT_JSON="${1:-}"
|
||||
if [[ -z "$INPUT_JSON" || ! -f "$INPUT_JSON" ]]; then
|
||||
echo "Usage: import-provider-telemetry.sh <provider-usage-json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
OUT="$LOG_DIR/provider-usage.jsonl"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
python3 - "$INPUT_JSON" "$OUT" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
data = json.load(open(src, encoding="utf-8"))
|
||||
required = ["provider", "model", "run_id", "step", "input_tokens", "output_tokens", "total_tokens", "cost_usd", "latency_ms", "status"]
|
||||
missing = [key for key in required if key not in data]
|
||||
if missing:
|
||||
raise SystemExit(f"PROVIDER_USAGE_INVALID missing={missing}")
|
||||
record = {
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"harness": "L5-provider-telemetry",
|
||||
**data,
|
||||
}
|
||||
with open(out, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
print(f"PROVIDER_TELEMETRY_IMPORTED provider={data['provider']} model={data['model']} total_tokens={data['total_tokens']} cost_usd={data['cost_usd']} output={out}")
|
||||
PY
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 fallback runner.
|
||||
# Usage:
|
||||
# model-fallback.sh <output-file> --primary '<cmd>' --fallback '<cmd>'
|
||||
|
||||
OUTPUT_FILE="${1:-}"
|
||||
shift || true
|
||||
|
||||
PRIMARY_CMD=""
|
||||
FALLBACK_CMD=""
|
||||
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--primary)
|
||||
PRIMARY_CMD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--fallback)
|
||||
FALLBACK_CMD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$OUTPUT_FILE" || -z "$PRIMARY_CMD" || -z "$FALLBACK_CMD" ]]; then
|
||||
echo "Usage: model-fallback.sh <output-file> --primary '<cmd>' --fallback '<cmd>'" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
mkdir -p "$LOG_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
FALLBACK_LOG="$LOG_DIR/fallback.jsonl"
|
||||
TRACE_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'fallback-%s-%s' "$(date +%s)" "$$")"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
TMP_PRIMARY="$(mktemp)"
|
||||
TMP_FALLBACK="$(mktemp)"
|
||||
|
||||
set +e
|
||||
bash -c "$PRIMARY_CMD" > "$TMP_PRIMARY" 2>&1
|
||||
PRIMARY_RC=$?
|
||||
set -e
|
||||
|
||||
ROUTE="primary"
|
||||
FINAL_RC="$PRIMARY_RC"
|
||||
if [[ "$PRIMARY_RC" -eq 0 && -s "$TMP_PRIMARY" ]]; then
|
||||
cp "$TMP_PRIMARY" "$OUTPUT_FILE"
|
||||
else
|
||||
ROUTE="fallback"
|
||||
set +e
|
||||
bash -c "$FALLBACK_CMD" > "$TMP_FALLBACK" 2>&1
|
||||
FALLBACK_RC=$?
|
||||
set -e
|
||||
FINAL_RC="$FALLBACK_RC"
|
||||
cp "$TMP_FALLBACK" "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"L5-model-fallback","primary_exit":%s,"route":"%s","final_exit":%s,"output":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$PRIMARY_RC" "$ROUTE" "$FINAL_RC" "$OUTPUT_FILE" >> "$FALLBACK_LOG"
|
||||
|
||||
echo "FALLBACK_ROUTE route=$ROUTE primary_exit=$PRIMARY_RC final_exit=$FINAL_RC output=$OUTPUT_FILE"
|
||||
exit "$FINAL_RC"
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CASAN H4 PII masker driven by .specify/security/pii-rules.yaml.
|
||||
|
||||
Reads content on stdin, applies every `action: mask` rule from the rules
|
||||
file, and writes the masked content to stdout. Type-specific replacement
|
||||
tokens are preserved so downstream evidence stays stable
|
||||
(***MASKED_EMAIL***, ***MASKED_PHONE***, ***MASKED_ID***).
|
||||
|
||||
This makes pii-rules.yaml the source of truth for PII masking instead of
|
||||
dead config: editing/removing a rule changes runtime behavior.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPLACEMENT_BY_TYPE = {
|
||||
"email": "***MASKED_EMAIL***",
|
||||
"phone": "***MASKED_PHONE***",
|
||||
"personal_id": "***MASKED_ID***",
|
||||
"address": "***MASKED_ADDRESS***",
|
||||
}
|
||||
|
||||
|
||||
def load_rules(path):
|
||||
rules, cur = [], {}
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for raw in fh:
|
||||
s = raw.strip()
|
||||
m = re.match(r"-\s*id:\s*(\S+)", s)
|
||||
if m:
|
||||
if cur:
|
||||
rules.append(cur)
|
||||
cur = {"id": m.group(1)}
|
||||
continue
|
||||
m = re.match(r'type:\s*"?([^"\s]+)"?', s)
|
||||
if m:
|
||||
cur["type"] = m.group(1)
|
||||
continue
|
||||
m = re.match(r'regex:\s*"(.*)"\s*$', s)
|
||||
if m:
|
||||
# YAML double-quoted: collapse \\ -> \ to recover the real regex.
|
||||
cur["regex"] = m.group(1).replace("\\\\", "\\")
|
||||
continue
|
||||
m = re.match(r"action:\s*(\S+)", s)
|
||||
if m:
|
||||
cur["action"] = m.group(1)
|
||||
continue
|
||||
if cur:
|
||||
rules.append(cur)
|
||||
return rules
|
||||
|
||||
|
||||
def main():
|
||||
data = sys.stdin.read()
|
||||
if len(sys.argv) < 2:
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
try:
|
||||
rules = load_rules(sys.argv[1])
|
||||
except OSError:
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
for rule in rules:
|
||||
if rule.get("action") != "mask" or "regex" not in rule:
|
||||
continue
|
||||
token = REPLACEMENT_BY_TYPE.get(rule.get("type", ""), "***MASKED***")
|
||||
try:
|
||||
data = re.sub(rule["regex"], token, data)
|
||||
except re.error:
|
||||
continue
|
||||
sys.stdout.write(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Return '<total_tokens> <cost_usd>' for the provider-telemetry record that
|
||||
matches the given step, or print nothing if there is no genuine match.
|
||||
|
||||
Never falls back to an arbitrary record — reusing one sample's cost across
|
||||
every step would misrepresent an estimate as real per-step billing.
|
||||
|
||||
Usage: provider-cost-lookup.py <provider-usage.jsonl> <step-name>
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(0)
|
||||
path, step = sys.argv[1], sys.argv[2]
|
||||
match = None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("step") == step:
|
||||
match = rec # last matching record wins
|
||||
except OSError:
|
||||
sys.exit(0)
|
||||
if match is not None:
|
||||
print(f"{match.get('total_tokens', 0)} {match.get('cost_usd', 0)}")
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 rollback transaction recorder.
|
||||
# Usage:
|
||||
# rollback-manager.sh record <action> <rollback-command>
|
||||
# rollback-manager.sh checkpoint <file> # back up a file; records a REAL restore command
|
||||
# rollback-manager.sh execute <transaction-id>
|
||||
|
||||
MODE="${1:-}"
|
||||
ACTION="${2:-}"
|
||||
ROLLBACK_COMMAND="${3:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
BACKUP_DIR="$LOG_DIR/rollback-backups"
|
||||
TX_LOG="$LOG_DIR/rollback-transactions.jsonl"
|
||||
mkdir -p "$LOG_DIR" "$BACKUP_DIR"
|
||||
|
||||
# checkpoint: snapshot a real file and record a real restore command so a later
|
||||
# `execute` genuinely undoes any change (not a marker write).
|
||||
if [[ "$MODE" == "checkpoint" ]]; then
|
||||
TARGET="$ACTION"
|
||||
if [[ -z "$TARGET" || ! -f "$TARGET" ]]; then
|
||||
echo "Usage: rollback-manager.sh checkpoint <existing-file>" >&2
|
||||
exit 64
|
||||
fi
|
||||
TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")"
|
||||
BACKUP="$BACKUP_DIR/$TX_ID.bak"
|
||||
cp "$TARGET" "$BACKUP"
|
||||
ABS_TARGET="$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")"
|
||||
RESTORE_CMD="cp '$BACKUP' '$ABS_TARGET'"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
python3 - "$TX_LOG" "$TIMESTAMP" "$TX_ID" "$ABS_TARGET" "$RESTORE_CMD" "$BACKUP" <<'PY'
|
||||
import json, sys
|
||||
log, ts, tx, target, cmd, backup = sys.argv[1:]
|
||||
rec = {"timestamp": ts, "transaction_id": tx, "action": "checkpoint",
|
||||
"target": target, "backup": backup, "rollback_command": cmd, "status": "recorded"}
|
||||
open(log, "a", encoding="utf-8").write(json.dumps(rec) + "\n")
|
||||
PY
|
||||
echo "ROLLBACK_CHECKPOINT transaction_id=$TX_ID target=$ABS_TARGET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "record" ]]; then
|
||||
if [[ -z "$ACTION" || -z "$ROLLBACK_COMMAND" ]]; then
|
||||
echo "Usage: rollback-manager.sh record <action> <rollback-command>" >&2
|
||||
exit 64
|
||||
fi
|
||||
TX_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tx-%s-%s' "$(date +%s)" "$$")"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
printf '{"timestamp":"%s","transaction_id":"%s","action":"%s","rollback_command":"%s","status":"recorded"}\n' \
|
||||
"$TIMESTAMP" "$TX_ID" "$ACTION" "$ROLLBACK_COMMAND" >> "$TX_LOG"
|
||||
echo "ROLLBACK_RECORDED transaction_id=$TX_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "execute" ]]; then
|
||||
TX_ID="$ACTION"
|
||||
if [[ -z "$TX_ID" || ! -f "$TX_LOG" ]]; then
|
||||
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
COMMAND="$(python3 - "$TX_LOG" "$TX_ID" <<'PY'
|
||||
import json, sys
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
rec=json.loads(line)
|
||||
if rec.get("transaction_id")==sys.argv[2]:
|
||||
print(rec.get("rollback_command",""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
if [[ -z "$COMMAND" ]]; then
|
||||
echo "ROLLBACK_NOT_FOUND transaction_id=$TX_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash -c "$COMMAND"
|
||||
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
printf '{"timestamp":"%s","transaction_id":"%s","status":"rolled_back"}\n' "$TIMESTAMP" "$TX_ID" >> "$TX_LOG"
|
||||
echo "ROLLBACK_EXECUTED transaction_id=$TX_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Usage: rollback-manager.sh record|execute ..." >&2
|
||||
exit 64
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H4 Security Harness
|
||||
# Usage:
|
||||
# security-check.sh <input-file> <output-file> [input|output]
|
||||
#
|
||||
# input mode: blocks prompt injection / critical secrets, masks PII, writes safe prompt.
|
||||
# output mode: redacts PII/secrets from generated output, flags risky language, writes safe output.
|
||||
|
||||
INPUT_FILE="${1:-}"
|
||||
OUTPUT_FILE="${2:-}"
|
||||
MODE="${3:-input}"
|
||||
|
||||
if [[ -z "$INPUT_FILE" || -z "$OUTPUT_FILE" ]]; then
|
||||
echo "Usage: security-check.sh <input-file> <output-file> [input|output]" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs"
|
||||
TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
SECURITY_DIR="$PROJECT_ROOT/.specify/security"
|
||||
|
||||
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
|
||||
|
||||
if [[ ! -f "$INPUT_FILE" ]]; then
|
||||
echo "SECURITY_BLOCKED: input file not found: $INPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
timestamp() {
|
||||
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||
}
|
||||
|
||||
new_trace_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
else
|
||||
printf 'trace-%s-%s\n' "$(date +%s)" "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Normalize text to defeat trivial injection bypasses:
|
||||
# lowercase, fold common leetspeak to letters, collapse punctuation/whitespace.
|
||||
# Used ONLY for injection/jailbreak phrase matching, never for PII/secret regexes.
|
||||
normalize_for_match() {
|
||||
printf '%s' "$1" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr '013457@$' 'oieastas' \
|
||||
| tr -c 'a-z0-9' ' ' \
|
||||
| tr -s ' '
|
||||
}
|
||||
|
||||
load_yaml_values() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
python3 - "$file" "$key" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
path, key = sys.argv[1], sys.argv[2]
|
||||
pattern = re.compile(rf'^\s*{re.escape(key)}:\s*"([^"]+)"\s*$')
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
CONTENT="$(cat "$INPUT_FILE")"
|
||||
STATUS="pass"
|
||||
ACTION="allow"
|
||||
RISK_LEVEL="low"
|
||||
MATCHED_RULES=()
|
||||
|
||||
BLOCK_PATTERNS=(
|
||||
"ignore (all |any )?(previous|prior|above|the above|earlier) (instruction|instructions|rule|rules|prompt|prompts|guideline|guidelines)"
|
||||
"ignore system instruction"
|
||||
"disregard (all |any |the )?.*(rule|instruction|previous|prior|above|earlier|policy|guideline)"
|
||||
"forget (all |everything |the |your )?.*(instruction|rule|previous|prior|above)"
|
||||
"bypass (the )?(system|policy|policies|filter|guardrail|guardrails|rule|rules|safety|restriction|restrictions)"
|
||||
"override (the )?(policy|policies|rule|rules|system|instruction|instructions|guardrail|safety)"
|
||||
"you are now"
|
||||
"act as (an? )?(admin|root|developer|unrestricted|unfiltered|different|evil|dan)"
|
||||
"act without (any )?(restriction|restrictions|rule|rules|limit|limits|filter|guardrail)"
|
||||
"pretend (to be|you are|you're) (system|not|no longer|an unrestricted)"
|
||||
"do anything now"
|
||||
"enable (dan|developer mode|jailbreak)"
|
||||
"developer mode"
|
||||
"reveal (the )?(system|hidden|internal|original) (prompt|instruction|instructions)"
|
||||
"show (the )?(hidden|internal|system) (instruction|instructions|prompt)"
|
||||
)
|
||||
|
||||
while IFS= read -r pattern; do
|
||||
[[ -n "$pattern" ]] && BLOCK_PATTERNS+=("$pattern")
|
||||
done < <(load_yaml_values "$SECURITY_DIR/prompt-filter.yaml" "pattern")
|
||||
|
||||
APPROVAL_PATTERNS=(
|
||||
"delete[[:space:]].*"
|
||||
"drop table"
|
||||
"shutdown system"
|
||||
"export secrets"
|
||||
"dump database"
|
||||
)
|
||||
|
||||
ALERT_PATTERNS=(
|
||||
"show all data"
|
||||
"internal prompt"
|
||||
"system message"
|
||||
"hidden instruction"
|
||||
)
|
||||
|
||||
EMAIL_REGEX='[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
|
||||
PHONE_REGEX='(\+?[0-9][0-9 .-]{8,}[0-9])'
|
||||
PERSONAL_ID_REGEX='\b[0-9]{9,12}\b'
|
||||
CREDIT_CARD_REGEX='\b([0-9]{4}[- ]?){3}[0-9]{4}\b'
|
||||
SECRET_REGEX='(API[_-]?KEY|ACCESS[_-]?TOKEN|REFRESH[_-]?TOKEN|PASSWORD|JWT[_-]?SECRET|SECRET)[[:space:]]*[:=][[:space:]]*[^[:space:]]+'
|
||||
PRIVATE_KEY_REGEX='-----BEGIN (RSA |EC )?PRIVATE KEY-----'
|
||||
DB_CONN_REGEX='(postgres|mysql|mongodb)://[^@[:space:]]+@'
|
||||
AWS_KEY_REGEX='AKIA[0-9A-Z]{16}'
|
||||
|
||||
while IFS= read -r regex; do
|
||||
case "$regex" in
|
||||
*API*|*TOKEN*|*PASSWORD*|*SECRET*)
|
||||
regex="${regex//\\s/[[:space:]]}"
|
||||
regex="${regex//\\S/[^[:space:]]}"
|
||||
SECRET_REGEX="$regex"
|
||||
;;
|
||||
esac
|
||||
done < <(load_yaml_values "$SECURITY_DIR/output-policy.yaml" "regex")
|
||||
|
||||
lower_content="$(printf '%s' "$CONTENT" | tr '[:upper:]' '[:lower:]')"
|
||||
NORM_CONTENT="$(normalize_for_match "$CONTENT")"
|
||||
|
||||
# Matches a pattern against either the raw (case-insensitive) or the
|
||||
# normalization-folded content, so leetspeak/whitespace/punctuation
|
||||
# obfuscation cannot slip past a phrase blocklist.
|
||||
match_either() {
|
||||
local pattern="$1"
|
||||
printf '%s' "$CONTENT" | grep -Eiq -- "$pattern" \
|
||||
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern"
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "input" ]]; then
|
||||
for pattern in "${BLOCK_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("prompt-injection:$pattern")
|
||||
fi
|
||||
done
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eq -- "$CREDIT_CARD_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("pii-credit-card")
|
||||
fi
|
||||
|
||||
if printf '%s' "$CONTENT" | grep -Eiq -- "$SECRET_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$PRIVATE_KEY_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$DB_CONN_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$AWS_KEY_REGEX"; then
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("secret-in-input")
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" != "blocked" ]]; then
|
||||
for pattern in "${APPROVAL_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
STATUS="requires_approval"
|
||||
ACTION="require_approval"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("unsafe-action:$pattern")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" != "blocked" ]]; then
|
||||
for pattern in "${ALERT_PATTERNS[@]}"; do
|
||||
if match_either "$pattern"; then
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
ACTION="alert"
|
||||
MATCHED_RULES+=("suspicious:$pattern")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
SAFE_CONTENT="$CONTENT"
|
||||
# Policy-driven PII masking (source of truth: pii-rules.yaml). Built-in sed
|
||||
# masking below remains as defense-in-depth if the policy file is unavailable.
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && command -v python3 >/dev/null 2>&1; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | python3 "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
fi
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$EMAIL_REGEX/***MASKED_EMAIL***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PHONE_REGEX/***MASKED_PHONE***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PERSONAL_ID_REGEX/***MASKED_ID***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$SECRET_REGEX/[REDACTED_SECRET]/Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PRIVATE_KEY_REGEX/[REDACTED_PRIVATE_KEY]/Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s#$DB_CONN_REGEX#[REDACTED_CONNSTRING]://#Ig")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$AWS_KEY_REGEX/[REDACTED_AWS_KEY]/Ig")"
|
||||
|
||||
if [[ "$MODE" == "output" ]]; then
|
||||
if printf '%s' "$CONTENT" | grep -Eiq -- "$SECRET_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$PRIVATE_KEY_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$DB_CONN_REGEX" \
|
||||
|| printf '%s' "$CONTENT" | grep -Eiq -- "$AWS_KEY_REGEX"; then
|
||||
# output-policy.yaml level4_gate.fail_on: unredacted_secret -> fail closed.
|
||||
STATUS="blocked"
|
||||
ACTION="block"
|
||||
RISK_LEVEL="high"
|
||||
MATCHED_RULES+=("secret-in-output")
|
||||
fi
|
||||
if printf '%s' "$lower_content" | grep -Eq -- "(maybe|might be incorrect|i am not sure|uncertain)"; then
|
||||
ACTION="flag"
|
||||
[[ "$RISK_LEVEL" == "low" ]] && RISK_LEVEL="medium"
|
||||
MATCHED_RULES+=("hallucination-risk-language")
|
||||
fi
|
||||
fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)"
|
||||
OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | python3 -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
{
|
||||
"trace_id": "$TRACE_ID",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"harness": "H4-security",
|
||||
"mode": "$MODE",
|
||||
"status": "$STATUS",
|
||||
"action": "$ACTION",
|
||||
"risk_level": "$RISK_LEVEL",
|
||||
"matched_rules": $RULES_JSON,
|
||||
"input_hash": "$INPUT_HASH",
|
||||
"output_hash": "$OUTPUT_HASH"
|
||||
}
|
||||
EOF
|
||||
|
||||
printf '{"timestamp":"%s","trace_id":"%s","harness":"H4-security","mode":"%s","status":"%s","action":"%s","risk_level":"%s","input_hash":"%s","output_hash":"%s"}\n' \
|
||||
"$TIMESTAMP" "$TRACE_ID" "$MODE" "$STATUS" "$ACTION" "$RISK_LEVEL" "$INPUT_HASH" "$OUTPUT_HASH" >> "$AUDIT_DIR/security.jsonl"
|
||||
|
||||
if [[ "$STATUS" == "blocked" ]]; then
|
||||
: > "$OUTPUT_FILE"
|
||||
echo "SECURITY_BLOCKED trace_id=$TRACE_ID risk=$RISK_LEVEL rules=$RULES_JSON" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "$SAFE_CONTENT" > "$OUTPUT_FILE"
|
||||
STATUS_UPPER="$(printf '%s' "$STATUS" | tr '[:lower:]' '[:upper:]')"
|
||||
echo "SECURITY_${STATUS_UPPER} trace_id=$TRACE_ID risk=$RISK_LEVEL action=$ACTION output=$OUTPUT_FILE"
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# Parse command line arguments
|
||||
JSON_MODE=false
|
||||
ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json]"
|
||||
echo " --json Output results in JSON format"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
eval $(get_feature_paths)
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# Ensure the feature directory exists
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||
if [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
else
|
||||
echo "Warning: Plan template not found at $TEMPLATE"
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT"
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
echo "SPECS_DIR: $FEATURE_DIR"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
echo "HAS_GIT: $HAS_GIT"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 signed central policy bundle.
|
||||
# Usage:
|
||||
# sign-policy-bundle.sh sign
|
||||
# sign-policy-bundle.sh verify
|
||||
|
||||
MODE="${1:-}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
GOV_DIR="$PROJECT_ROOT/.specify/level5/central-governance"
|
||||
BUNDLE="$GOV_DIR/policy-bundle.yaml"
|
||||
MANIFEST="$GOV_DIR/policy-manifest.json"
|
||||
PRIVATE_KEY="$GOV_DIR/policy-private.pem"
|
||||
PUBLIC_KEY="$GOV_DIR/policy-public.pem"
|
||||
SIGNATURE="$GOV_DIR/policy-manifest.sig"
|
||||
mkdir -p "$GOV_DIR"
|
||||
|
||||
if [[ "$MODE" != "sign" && "$MODE" != "verify" ]]; then
|
||||
echo "Usage: sign-policy-bundle.sh sign|verify" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "POLICY_SIGNING_UNAVAILABLE openssl not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_manifest() {
|
||||
python3 - "$PROJECT_ROOT" "$BUNDLE" "$MANIFEST" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
bundle = pathlib.Path(sys.argv[2])
|
||||
manifest = pathlib.Path(sys.argv[3])
|
||||
text = bundle.read_text(encoding="utf-8")
|
||||
paths = re.findall(r"^\s*path:\s*(.+?)\s*$", text, flags=re.MULTILINE)
|
||||
files = []
|
||||
for raw in paths:
|
||||
rel = raw.strip().strip('"')
|
||||
path = root / rel
|
||||
if not path.exists():
|
||||
raise SystemExit(f"missing policy file: {rel}")
|
||||
data = path.read_bytes()
|
||||
files.append({
|
||||
"path": rel,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data),
|
||||
})
|
||||
payload = {
|
||||
"bundle_id": "casan-okr-harness-policy",
|
||||
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"files": files,
|
||||
}
|
||||
manifest.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"POLICY_MANIFEST_GENERATED files={len(files)} manifest={manifest}")
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "sign" ]]; then
|
||||
generate_manifest
|
||||
if [[ ! -f "$PRIVATE_KEY" ]]; then
|
||||
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1
|
||||
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1
|
||||
fi
|
||||
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
|
||||
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - "$PROJECT_ROOT" "$MANIFEST" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
manifest = json.loads(pathlib.Path(sys.argv[2]).read_text(encoding="utf-8"))
|
||||
for item in manifest["files"]:
|
||||
data = (root / item["path"]).read_bytes()
|
||||
actual = hashlib.sha256(data).hexdigest()
|
||||
if actual != item["sha256"]:
|
||||
raise SystemExit(f"POLICY_HASH_MISMATCH path={item['path']} expected={item['sha256']} actual={actual}")
|
||||
print(f"POLICY_HASHES_VALID files={len(manifest['files'])}")
|
||||
PY
|
||||
openssl dgst -sha256 -verify "$PUBLIC_KEY" -signature "$SIGNATURE" "$MANIFEST" >/dev/null
|
||||
echo "POLICY_SIGNATURE_VALID manifest=$MANIFEST"
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared tamper-evident appender for the central tool-call audit log
|
||||
# (.specify/logs/audit/tool-calls.jsonl).
|
||||
#
|
||||
# Every record is chained: record_hash = SHA-256(previous_record_hash | core),
|
||||
# where core is the canonical (sorted-key) JSON of the record minus record_hash.
|
||||
# After each append the chain head is signed with the audit RSA key, so a
|
||||
# re-forged chain (recomputed hashes) cannot produce a valid head signature
|
||||
# without the private key. Used by both agent-metrics.sh and tool-registry-gate.sh
|
||||
# so the combined audit is tamper-evident regardless of which writer appended.
|
||||
#
|
||||
# Production note: the private key must live off-repo (KMS/HSM); it is local
|
||||
# here only for self-contained demonstration.
|
||||
|
||||
append_tool_audit() {
|
||||
local record_json="$1"
|
||||
local project_root="$2"
|
||||
local audit_dir="$project_root/.specify/logs/audit"
|
||||
local log="$audit_dir/tool-calls.jsonl"
|
||||
mkdir -p "$audit_dir"
|
||||
|
||||
local head
|
||||
head="$(python3 - "$log" "$record_json" <<'PY'
|
||||
import hashlib, json, sys
|
||||
log, rec_json = sys.argv[1], sys.argv[2]
|
||||
rec = json.loads(rec_json)
|
||||
prev = ""
|
||||
try:
|
||||
with open(log, encoding="utf-8") as f:
|
||||
lines = [l for l in f if l.strip()]
|
||||
if lines:
|
||||
prev = json.loads(lines[-1]).get("record_hash", "")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
rec.pop("record_hash", None)
|
||||
rec["previous_record_hash"] = prev
|
||||
core = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||
rec["record_hash"] = hashlib.sha256((prev + "|" + core).encode()).hexdigest()
|
||||
with open(log, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
sys.stdout.write(rec["record_hash"])
|
||||
PY
|
||||
)"
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || return 0
|
||||
# Private signing key lives OFF-REPO (default ~/.casan/audit-keys); only the
|
||||
# public key is committed, for verification. Production: replace with KMS/HSM.
|
||||
local pub_dir="$project_root/.specify/level5/central-governance"
|
||||
local priv_dir="${CASAN_AUDIT_KEY_DIR:-$HOME/.casan/audit-keys}"
|
||||
local priv="$priv_dir/audit-private.pem" pub="$pub_dir/audit-public.pem"
|
||||
mkdir -p "$pub_dir" "$priv_dir"
|
||||
if [[ ! -f "$priv" ]]; then
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$priv" 2>/dev/null
|
||||
chmod 600 "$priv"
|
||||
openssl rsa -in "$priv" -pubout -out "$pub" 2>/dev/null
|
||||
fi
|
||||
printf '%s' "$head" > "$audit_dir/tool-calls-head.txt"
|
||||
openssl dgst -sha256 -sign "$priv" -out "$audit_dir/tool-calls-head.sig" "$audit_dir/tool-calls-head.txt" 2>/dev/null || true
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# CASAN H4 tool-execution guard.
|
||||
# Runs a command under a hard wall-clock timeout so a runaway/hung tool call
|
||||
# cannot block the pipeline indefinitely. Portable (uses `timeout` if present,
|
||||
# else a perl alarm) — macOS has no coreutils `timeout` by default.
|
||||
#
|
||||
# Scope (honest): this enforces a TIMEOUT only. True kernel sandboxing
|
||||
# (namespaces/seccomp/network isolation) requires running the tool inside a
|
||||
# container and is documented as a production requirement — it is NOT emulated
|
||||
# here. Do not present this as full sandboxing.
|
||||
#
|
||||
# Usage: tool-exec.sh <timeout-seconds> -- <command...>
|
||||
# Exit: command's exit code, or 124 on timeout.
|
||||
|
||||
TIMEOUT="${1:-${CASAN_TOOL_TIMEOUT_SECONDS:-30}}"
|
||||
shift || true
|
||||
if [[ "${1:-}" == "--" ]]; then shift; fi
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
echo "Usage: tool-exec.sh <timeout-seconds> -- <command...>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$TIMEOUT" "$@"
|
||||
rc=$?
|
||||
elif command -v perl >/dev/null 2>&1; then
|
||||
perl -e 'my $t=shift; $SIG{ALRM}=sub{exit 124}; alarm($t); exec @ARGV or exit 127;' "$TIMEOUT" "$@"
|
||||
rc=$?
|
||||
else
|
||||
echo "TOOL_EXEC_NO_TIMEOUT_BACKEND" >&2
|
||||
"$@"
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [[ "$rc" -eq 124 || "$rc" -eq 142 ]]; then
|
||||
echo "TOOL_EXEC_TIMEOUT after ${TIMEOUT}s" >&2
|
||||
exit 124
|
||||
fi
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN Level 5 tool registry gate.
|
||||
# Usage:
|
||||
# tool-registry-gate.sh <tool-id>
|
||||
|
||||
TOOL_ID="${1:-}"
|
||||
if [[ -z "$TOOL_ID" ]]; then
|
||||
echo "Usage: tool-registry-gate.sh <tool-id>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
REGISTRY="$PROJECT_ROOT/.specify/level5/tool-registry.yaml"
|
||||
LOG_DIR="$PROJECT_ROOT/.specify/logs/level5"
|
||||
AUDIT_DIR="$PROJECT_ROOT/.specify/logs/audit"
|
||||
mkdir -p "$LOG_DIR" "$AUDIT_DIR"
|
||||
TRACE_ID="$(uuidgen 2>/dev/null | tr '[:upper:]' '[:lower:]' || printf 'tool-%s-%s' "$(date +%s)" "$$")"
|
||||
|
||||
# shellcheck source=tool-audit-lib.sh
|
||||
source "$SCRIPT_DIR/tool-audit-lib.sh"
|
||||
|
||||
AUDIT_TMP="$(mktemp)"
|
||||
trap 'rm -f "$AUDIT_TMP"' EXIT
|
||||
|
||||
DECISION_LINE="$(python3 - "$REGISTRY" "$TOOL_ID" "${CASAN_IDEMPOTENCY_KEY:-}" "$LOG_DIR/tool-registry.jsonl" "$TRACE_ID" "${CASAN_AGENT:-}" "$AUDIT_TMP" "${CASAN_RUN_ID:-adhoc-$$}" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
registry_path, tool_id, idempotency_key, log_path, trace_id, agent, audit_tmp, run_id = sys.argv[1:]
|
||||
text = open(registry_path, encoding="utf-8").read()
|
||||
blocks = re.split(r"\n\s*-\s+id:\s+", text)
|
||||
tools = {}
|
||||
for block in blocks[1:]:
|
||||
lines = block.splitlines()
|
||||
current_id = lines[0].strip()
|
||||
attrs = {"id": current_id, "has_rollback": "strategy:" in block}
|
||||
for line in lines[1:]:
|
||||
if ":" in line and not line.startswith(" "):
|
||||
key, value = line.split(":", 1)
|
||||
attrs[key.strip()] = value.strip().strip('"')
|
||||
tools[current_id] = attrs
|
||||
|
||||
tool = tools.get(tool_id)
|
||||
decision, reason = "approved", "registered"
|
||||
|
||||
if not tool:
|
||||
decision, reason = "denied", "unknown_tool"
|
||||
side_effect = idem_required = False
|
||||
owner = risk = ""
|
||||
allowed = ""
|
||||
else:
|
||||
side_effect = tool.get("side_effect", "false") == "true"
|
||||
idem_required = tool.get("idempotency_required", "false") == "true"
|
||||
owner = tool.get("owner", "")
|
||||
risk = tool.get("risk_level", "")
|
||||
allowed = tool.get("allowed_agents", "")
|
||||
allowed_list = [a.strip() for a in allowed.split(",") if a.strip()]
|
||||
|
||||
# 1. Per-agent least-privilege: restricted tools require an authorized caller.
|
||||
if allowed_list:
|
||||
if not agent:
|
||||
decision, reason = "denied", "missing_agent_identity"
|
||||
elif agent not in allowed_list:
|
||||
decision, reason = "denied", "unauthorized_agent"
|
||||
# 2. Side-effecting + idempotency-required tools must carry an idempotency key.
|
||||
if decision == "approved" and side_effect and idem_required and not idempotency_key:
|
||||
decision, reason = "denied", "missing_idempotency_key"
|
||||
# 3. Every side-effecting tool must declare a rollback strategy.
|
||||
if decision == "approved" and side_effect and not tool.get("has_rollback"):
|
||||
decision, reason = "denied", "missing_rollback_strategy"
|
||||
# 4. Runtime rate limit: count prior APPROVED calls for this tool in this run.
|
||||
if decision == "approved" and tool.get("rate_limit_per_run", "").isdigit():
|
||||
limit = int(tool["rate_limit_per_run"])
|
||||
prior = 0
|
||||
if os.path.exists(log_path):
|
||||
for line in open(log_path, encoding="utf-8"):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if (r.get("tool_id") == tool_id and r.get("run_id") == run_id
|
||||
and r.get("decision") == "approved"):
|
||||
prior += 1
|
||||
if prior >= limit:
|
||||
decision, reason = "denied", f"rate_limit_exceeded(limit={limit})"
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
record = {
|
||||
"timestamp": ts, "trace_id": trace_id, "harness": "L5-tool-registry",
|
||||
"tool_id": tool_id, "agent": agent, "run_id": run_id, "owner": owner, "risk_level": risk,
|
||||
"side_effect": side_effect, "idempotency_required": idem_required,
|
||||
"idempotency_key_present": bool(idempotency_key),
|
||||
"decision": decision, "reason": reason,
|
||||
}
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
# Tamper-evident central audit record (appended + signed by the bash caller).
|
||||
with open(audit_tmp, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({
|
||||
"timestamp": ts, "trace_id": trace_id, "tool": tool_id, "agent": agent,
|
||||
"idempotency_key": idempotency_key, "decision": decision, "reason": reason,
|
||||
"risk_level": risk, "owner": owner,
|
||||
}))
|
||||
|
||||
print(f"{decision} {reason}")
|
||||
PY
|
||||
)"
|
||||
|
||||
DECISION="${DECISION_LINE%% *}"
|
||||
REASON="${DECISION_LINE#* }"
|
||||
|
||||
append_tool_audit "$(cat "$AUDIT_TMP")" "$PROJECT_ROOT"
|
||||
|
||||
if [[ "$DECISION" == "approved" ]]; then
|
||||
echo "TOOL_APPROVED tool=$TOOL_ID reason=$REASON"
|
||||
else
|
||||
echo "TOOL_DENIED tool=$TOOL_ID reason=$REASON" >&2
|
||||
exit 2
|
||||
fi
|
||||
@@ -0,0 +1,829 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Update agent context files with information from plan.md
|
||||
#
|
||||
# This script maintains AI agent context files by parsing feature specifications
|
||||
# and updating agent-specific configuration files with project information.
|
||||
#
|
||||
# MAIN FUNCTIONS:
|
||||
# 1. Environment Validation
|
||||
# - Verifies git repository structure and branch information
|
||||
# - Checks for required plan.md files and templates
|
||||
# - Validates file permissions and accessibility
|
||||
#
|
||||
# 2. Plan Data Extraction
|
||||
# - Parses plan.md files to extract project metadata
|
||||
# - Identifies language/version, frameworks, databases, and project types
|
||||
# - Handles missing or incomplete specification data gracefully
|
||||
#
|
||||
# 3. Agent File Management
|
||||
# - Creates new agent context files from templates when needed
|
||||
# - Updates existing agent files with new project information
|
||||
# - Preserves manual additions and custom configurations
|
||||
# - Supports multiple AI agent formats and directory structures
|
||||
#
|
||||
# 4. Content Generation
|
||||
# - Generates language-specific build/test commands
|
||||
# - Creates appropriate project directory structures
|
||||
# - Updates technology stacks and recent changes sections
|
||||
# - Maintains consistent formatting and timestamps
|
||||
#
|
||||
# 5. Multi-Agent Support
|
||||
# - Handles agent-specific file paths and naming conventions
|
||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Kiro CLI, or Antigravity
|
||||
# - Can update single agents or all existing agent files
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
|
||||
# Enable strict error handling
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
#==============================================================================
|
||||
# Configuration and Global Variables
|
||||
#==============================================================================
|
||||
|
||||
# Get script directory and load common functions
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
eval $(get_feature_paths)
|
||||
|
||||
NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code
|
||||
AGENT_TYPE="${1:-}"
|
||||
|
||||
# Agent-specific file paths
|
||||
CLAUDE_FILE="$REPO_ROOT/CLAUDE.md"
|
||||
GEMINI_FILE="$REPO_ROOT/GEMINI.md"
|
||||
COPILOT_FILE="$REPO_ROOT/.github/agents/copilot-instructions.md"
|
||||
CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
|
||||
QWEN_FILE="$REPO_ROOT/QWEN.md"
|
||||
AGENTS_FILE="$REPO_ROOT/AGENTS.md"
|
||||
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
|
||||
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
|
||||
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
KIRO_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
|
||||
# Global variables for parsed plan data
|
||||
NEW_LANG=""
|
||||
NEW_FRAMEWORK=""
|
||||
NEW_DB=""
|
||||
NEW_PROJECT_TYPE=""
|
||||
|
||||
#==============================================================================
|
||||
# Utility Functions
|
||||
#==============================================================================
|
||||
|
||||
log_info() {
|
||||
echo "INFO: $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo "✓ $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "ERROR: $1" >&2
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo "WARNING: $1" >&2
|
||||
}
|
||||
|
||||
# Cleanup function for temporary files
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
rm -f /tmp/agent_update_*_$$
|
||||
rm -f /tmp/manual_additions_$$
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
# Set up cleanup trap
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
#==============================================================================
|
||||
# Validation Functions
|
||||
#==============================================================================
|
||||
|
||||
validate_environment() {
|
||||
# Check if we have a current branch/feature (git or non-git)
|
||||
if [[ -z "$CURRENT_BRANCH" ]]; then
|
||||
log_error "Unable to determine current feature"
|
||||
if [[ "$HAS_GIT" == "true" ]]; then
|
||||
log_info "Make sure you're on a feature branch"
|
||||
else
|
||||
log_info "Set SPECIFY_FEATURE environment variable or create a feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if plan.md exists
|
||||
if [[ ! -f "$NEW_PLAN" ]]; then
|
||||
log_error "No plan.md found at $NEW_PLAN"
|
||||
log_info "Make sure you're working on a feature with a corresponding spec directory"
|
||||
if [[ "$HAS_GIT" != "true" ]]; then
|
||||
log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if template exists (needed for new files)
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
log_warning "Template file not found at $TEMPLATE_FILE"
|
||||
log_warning "Creating new agent files will fail"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Plan Parsing Functions
|
||||
#==============================================================================
|
||||
|
||||
extract_plan_field() {
|
||||
local field_pattern="$1"
|
||||
local plan_file="$2"
|
||||
|
||||
grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \
|
||||
head -1 | \
|
||||
sed "s|^\*\*${field_pattern}\*\*: ||" | \
|
||||
sed 's/^[ \t]*//;s/[ \t]*$//' | \
|
||||
grep -v "NEEDS CLARIFICATION" | \
|
||||
grep -v "^N/A$" || echo ""
|
||||
}
|
||||
|
||||
parse_plan_data() {
|
||||
local plan_file="$1"
|
||||
|
||||
if [[ ! -f "$plan_file" ]]; then
|
||||
log_error "Plan file not found: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$plan_file" ]]; then
|
||||
log_error "Plan file is not readable: $plan_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Parsing plan data from $plan_file"
|
||||
|
||||
NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file")
|
||||
NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file")
|
||||
NEW_DB=$(extract_plan_field "Storage" "$plan_file")
|
||||
NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file")
|
||||
|
||||
# Log what we found
|
||||
if [[ -n "$NEW_LANG" ]]; then
|
||||
log_info "Found language: $NEW_LANG"
|
||||
else
|
||||
log_warning "No language information found in plan"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_FRAMEWORK" ]]; then
|
||||
log_info "Found framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then
|
||||
log_info "Found database: $NEW_DB"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_PROJECT_TYPE" ]]; then
|
||||
log_info "Found project type: $NEW_PROJECT_TYPE"
|
||||
fi
|
||||
}
|
||||
|
||||
format_technology_stack() {
|
||||
local lang="$1"
|
||||
local framework="$2"
|
||||
local parts=()
|
||||
|
||||
# Add non-empty parts
|
||||
[[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang")
|
||||
[[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework")
|
||||
|
||||
# Join with proper formatting
|
||||
if [[ ${#parts[@]} -eq 0 ]]; then
|
||||
echo ""
|
||||
elif [[ ${#parts[@]} -eq 1 ]]; then
|
||||
echo "${parts[0]}"
|
||||
else
|
||||
# Join multiple parts with " + "
|
||||
local result="${parts[0]}"
|
||||
for ((i=1; i<${#parts[@]}; i++)); do
|
||||
result="$result + ${parts[i]}"
|
||||
done
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Template and Content Generation Functions
|
||||
#==============================================================================
|
||||
|
||||
get_project_structure() {
|
||||
local project_type="$1"
|
||||
|
||||
if [[ "$project_type" == *"web"* ]]; then
|
||||
echo "backend/\\nfrontend/\\ntests/"
|
||||
else
|
||||
echo "src/\\ntests/"
|
||||
fi
|
||||
}
|
||||
|
||||
get_commands_for_language() {
|
||||
local lang="$1"
|
||||
|
||||
case "$lang" in
|
||||
*"Python"*)
|
||||
echo "cd src && pytest && ruff check ."
|
||||
;;
|
||||
*"Rust"*)
|
||||
echo "cargo test && cargo clippy"
|
||||
;;
|
||||
*"JavaScript"*|*"TypeScript"*)
|
||||
echo "npm test \\&\\& npm run lint"
|
||||
;;
|
||||
*)
|
||||
echo "# Add commands for $lang"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_language_conventions() {
|
||||
local lang="$1"
|
||||
echo "$lang: Follow standard conventions"
|
||||
}
|
||||
|
||||
create_new_agent_file() {
|
||||
local target_file="$1"
|
||||
local temp_file="$2"
|
||||
local project_name="$3"
|
||||
local current_date="$4"
|
||||
|
||||
if [[ ! -f "$TEMPLATE_FILE" ]]; then
|
||||
log_error "Template not found at $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -r "$TEMPLATE_FILE" ]]; then
|
||||
log_error "Template file is not readable: $TEMPLATE_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Creating new agent context file from template..."
|
||||
|
||||
if ! cp "$TEMPLATE_FILE" "$temp_file"; then
|
||||
log_error "Failed to copy template file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Replace template placeholders
|
||||
local project_structure
|
||||
project_structure=$(get_project_structure "$NEW_PROJECT_TYPE")
|
||||
|
||||
local commands
|
||||
commands=$(get_commands_for_language "$NEW_LANG")
|
||||
|
||||
local language_conventions
|
||||
language_conventions=$(get_language_conventions "$NEW_LANG")
|
||||
|
||||
# Perform substitutions with error checking using safer approach
|
||||
# Escape special characters for sed by using a different delimiter or escaping
|
||||
local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g')
|
||||
|
||||
# Build technology stack and recent change strings conditionally
|
||||
local tech_stack
|
||||
if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then
|
||||
tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)"
|
||||
elif [[ -n "$escaped_lang" ]]; then
|
||||
tech_stack="- $escaped_lang ($escaped_branch)"
|
||||
elif [[ -n "$escaped_framework" ]]; then
|
||||
tech_stack="- $escaped_framework ($escaped_branch)"
|
||||
else
|
||||
tech_stack="- ($escaped_branch)"
|
||||
fi
|
||||
|
||||
local recent_change
|
||||
if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework"
|
||||
elif [[ -n "$escaped_lang" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_lang"
|
||||
elif [[ -n "$escaped_framework" ]]; then
|
||||
recent_change="- $escaped_branch: Added $escaped_framework"
|
||||
else
|
||||
recent_change="- $escaped_branch: Added"
|
||||
fi
|
||||
|
||||
local substitutions=(
|
||||
"s|\[PROJECT NAME\]|$project_name|"
|
||||
"s|\[DATE\]|$current_date|"
|
||||
"s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|"
|
||||
"s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g"
|
||||
"s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|"
|
||||
"s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|"
|
||||
"s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|"
|
||||
)
|
||||
|
||||
for substitution in "${substitutions[@]}"; do
|
||||
if ! sed -i.bak -e "$substitution" "$temp_file"; then
|
||||
log_error "Failed to perform substitution: $substitution"
|
||||
rm -f "$temp_file" "$temp_file.bak"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Convert \n sequences to actual newlines
|
||||
newline=$(printf '\n')
|
||||
sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file"
|
||||
|
||||
# Clean up backup files
|
||||
rm -f "$temp_file.bak" "$temp_file.bak2"
|
||||
|
||||
# Prepend Cursor frontmatter for .mdc files so rules are auto-included
|
||||
if [[ "$target_file" == *.mdc ]]; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || return 1
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
update_existing_agent_file() {
|
||||
local target_file="$1"
|
||||
local current_date="$2"
|
||||
|
||||
log_info "Updating existing agent context file..."
|
||||
|
||||
# Use a single temporary file for atomic update
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Process the file in one pass
|
||||
local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK")
|
||||
local new_tech_entries=()
|
||||
local new_change_entry=""
|
||||
|
||||
# Prepare new technology entries
|
||||
if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then
|
||||
new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then
|
||||
new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)")
|
||||
fi
|
||||
|
||||
# Prepare new change entry
|
||||
if [[ -n "$tech_stack" ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $tech_stack"
|
||||
elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then
|
||||
new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB"
|
||||
fi
|
||||
|
||||
# Check if sections exist in the file
|
||||
local has_active_technologies=0
|
||||
local has_recent_changes=0
|
||||
|
||||
if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then
|
||||
has_active_technologies=1
|
||||
fi
|
||||
|
||||
if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then
|
||||
has_recent_changes=1
|
||||
fi
|
||||
|
||||
# Process file line by line
|
||||
local in_tech_section=false
|
||||
local in_changes_section=false
|
||||
local tech_entries_added=false
|
||||
local changes_entries_added=false
|
||||
local existing_changes_count=0
|
||||
local file_ended=false
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Handle Active Technologies section
|
||||
if [[ "$line" == "## Active Technologies" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
in_tech_section=true
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then
|
||||
# Add new tech entries before closing the section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >> "$temp_file"
|
||||
in_tech_section=false
|
||||
continue
|
||||
elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then
|
||||
# Add new tech entries before empty line in tech section
|
||||
if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
echo "$line" >> "$temp_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Handle Recent Changes section
|
||||
if [[ "$line" == "## Recent Changes" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
# Add new change entry right after the heading
|
||||
if [[ -n "$new_change_entry" ]]; then
|
||||
echo "$new_change_entry" >> "$temp_file"
|
||||
fi
|
||||
in_changes_section=true
|
||||
changes_entries_added=true
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
in_changes_section=false
|
||||
continue
|
||||
elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then
|
||||
# Keep only first 2 existing changes
|
||||
if [[ $existing_changes_count -lt 2 ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
((existing_changes_count++))
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Update timestamp
|
||||
if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
|
||||
echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file"
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
fi
|
||||
done < "$target_file"
|
||||
|
||||
# Post-loop check: if we're still in the Active Technologies section and haven't added new entries
|
||||
if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
# If sections don't exist, add them at the end of the file
|
||||
if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then
|
||||
echo "" >> "$temp_file"
|
||||
echo "## Active Technologies" >> "$temp_file"
|
||||
printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file"
|
||||
tech_entries_added=true
|
||||
fi
|
||||
|
||||
if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then
|
||||
echo "" >> "$temp_file"
|
||||
echo "## Recent Changes" >> "$temp_file"
|
||||
echo "$new_change_entry" >> "$temp_file"
|
||||
changes_entries_added=true
|
||||
fi
|
||||
|
||||
# Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion
|
||||
if [[ "$target_file" == *.mdc ]]; then
|
||||
if ! head -1 "$temp_file" | grep -q '^---'; then
|
||||
local frontmatter_file
|
||||
frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; }
|
||||
printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file"
|
||||
cat "$temp_file" >> "$frontmatter_file"
|
||||
mv "$frontmatter_file" "$temp_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Move temp file to target atomically
|
||||
if ! mv "$temp_file" "$target_file"; then
|
||||
log_error "Failed to update target file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
#==============================================================================
|
||||
# Main Agent File Update Function
|
||||
#==============================================================================
|
||||
|
||||
update_agent_file() {
|
||||
local target_file="$1"
|
||||
local agent_name="$2"
|
||||
|
||||
if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then
|
||||
log_error "update_agent_file requires target_file and agent_name parameters"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Updating $agent_name context file: $target_file"
|
||||
|
||||
local project_name
|
||||
project_name=$(basename "$REPO_ROOT")
|
||||
local current_date
|
||||
current_date=$(date +%Y-%m-%d)
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
local target_dir
|
||||
target_dir=$(dirname "$target_file")
|
||||
if [[ ! -d "$target_dir" ]]; then
|
||||
if ! mkdir -p "$target_dir"; then
|
||||
log_error "Failed to create directory: $target_dir"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$target_file" ]]; then
|
||||
# Create new file from template
|
||||
local temp_file
|
||||
temp_file=$(mktemp) || {
|
||||
log_error "Failed to create temporary file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then
|
||||
if mv "$temp_file" "$target_file"; then
|
||||
log_success "Created new $agent_name context file"
|
||||
else
|
||||
log_error "Failed to move temporary file to $target_file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_error "Failed to create new agent file"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Update existing file
|
||||
if [[ ! -r "$target_file" ]]; then
|
||||
log_error "Cannot read existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -w "$target_file" ]]; then
|
||||
log_error "Cannot write to existing file: $target_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if update_existing_agent_file "$target_file" "$current_date"; then
|
||||
log_success "Updated existing $agent_name context file"
|
||||
else
|
||||
log_error "Failed to update existing agent file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Agent Selection and Processing
|
||||
#==============================================================================
|
||||
|
||||
update_specific_agent() {
|
||||
local agent_type="$1"
|
||||
|
||||
case "$agent_type" in
|
||||
claude)
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
;;
|
||||
gemini)
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
;;
|
||||
copilot)
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
;;
|
||||
cursor-agent)
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
;;
|
||||
qwen)
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
;;
|
||||
opencode)
|
||||
update_agent_file "$AGENTS_FILE" "opencode"
|
||||
;;
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
;;
|
||||
auggie)
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
;;
|
||||
roo)
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
;;
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
;;
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
;;
|
||||
amp)
|
||||
update_agent_file "$AMP_FILE" "Amp"
|
||||
;;
|
||||
shai)
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
;;
|
||||
kiro-cli)
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
update_all_existing_agents() {
|
||||
local found_agent=false
|
||||
|
||||
# Check each possible agent file and update if it exists
|
||||
if [[ -f "$CLAUDE_FILE" ]]; then
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$GEMINI_FILE" ]]; then
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$COPILOT_FILE" ]]; then
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CURSOR_FILE" ]]; then
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QWEN_FILE" ]]; then
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGENTS_FILE" ]]; then
|
||||
update_agent_file "$AGENTS_FILE" "Codex/opencode"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KILOCODE_FILE" ]]; then
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AUGGIE_FILE" ]]; then
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$ROO_FILE" ]]; then
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CODEBUDDY_FILE" ]]; then
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$SHAI_FILE" ]]; then
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QODER_FILE" ]]; then
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KIRO_FILE" ]]; then
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGY_FILE" ]]; then
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
found_agent=true
|
||||
fi
|
||||
if [[ -f "$BOB_FILE" ]]; then
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$found_agent" == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
fi
|
||||
}
|
||||
print_summary() {
|
||||
echo
|
||||
log_info "Summary of changes:"
|
||||
|
||||
if [[ -n "$NEW_LANG" ]]; then
|
||||
echo " - Added language: $NEW_LANG"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_FRAMEWORK" ]]; then
|
||||
echo " - Added framework: $NEW_FRAMEWORK"
|
||||
fi
|
||||
|
||||
if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then
|
||||
echo " - Added database: $NEW_DB"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|kiro-cli|agy|bob|qodercli]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
# Main Execution
|
||||
#==============================================================================
|
||||
|
||||
main() {
|
||||
# Validate environment before proceeding
|
||||
validate_environment
|
||||
|
||||
log_info "=== Updating agent context files for feature $CURRENT_BRANCH ==="
|
||||
|
||||
# Parse the plan file to extract project information
|
||||
if ! parse_plan_data "$NEW_PLAN"; then
|
||||
log_error "Failed to parse plan data"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Process based on agent type argument
|
||||
local success=true
|
||||
|
||||
if [[ -z "$AGENT_TYPE" ]]; then
|
||||
# No specific agent provided - update all existing agent files
|
||||
log_info "No agent specified, updating all existing agent files..."
|
||||
if ! update_all_existing_agents; then
|
||||
success=false
|
||||
fi
|
||||
else
|
||||
# Specific agent provided - update only that agent
|
||||
log_info "Updating specific agent: $AGENT_TYPE"
|
||||
if ! update_specific_agent "$AGENT_TYPE"; then
|
||||
success=false
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print summary
|
||||
print_summary
|
||||
|
||||
if [[ "$success" == true ]]; then
|
||||
log_success "Agent context update completed successfully"
|
||||
exit 0
|
||||
else
|
||||
log_error "Agent context update completed with errors"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Execute main function if script is run directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CASAN H2 tool-input validation.
|
||||
# Validates a tool-call input JSON against a JSON-Schema-style spec
|
||||
# (required fields + property types + additionalProperties:false).
|
||||
# Stdlib-only — supports type, required, properties, additionalProperties,
|
||||
# enum. NOT a full JSON Schema engine (no $ref, no nested object recursion
|
||||
# beyond one level); scoped deliberately and labeled as such.
|
||||
#
|
||||
# Usage: validate-tool-input.sh <schema.json> <input.json>
|
||||
# Exit: 0 valid, 2 invalid, 64 usage error.
|
||||
|
||||
SCHEMA="${1:-}"
|
||||
INPUT="${2:-}"
|
||||
if [[ -z "$SCHEMA" || -z "$INPUT" || ! -f "$SCHEMA" || ! -f "$INPUT" ]]; then
|
||||
echo "Usage: validate-tool-input.sh <schema.json> <input.json>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
python3 - "$SCHEMA" "$INPUT" <<'PY'
|
||||
import json, sys
|
||||
|
||||
schema = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
data = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
|
||||
TYPES = {
|
||||
"string": str, "integer": int, "number": (int, float),
|
||||
"boolean": bool, "object": dict, "array": list,
|
||||
}
|
||||
errors = []
|
||||
|
||||
if schema.get("type") == "object" and not isinstance(data, dict):
|
||||
errors.append("root: expected object")
|
||||
else:
|
||||
props = schema.get("properties", {})
|
||||
for field in schema.get("required", []):
|
||||
if field not in data:
|
||||
errors.append(f"missing required field: {field}")
|
||||
if schema.get("additionalProperties") is False:
|
||||
for key in data:
|
||||
if key not in props:
|
||||
errors.append(f"unexpected field: {key}")
|
||||
for key, spec in props.items():
|
||||
if key not in data:
|
||||
continue
|
||||
expected = spec.get("type")
|
||||
py = TYPES.get(expected)
|
||||
# bool is a subclass of int — guard so a boolean isn't accepted as integer
|
||||
if py and (not isinstance(data[key], py)
|
||||
or (expected in ("integer", "number") and isinstance(data[key], bool))):
|
||||
errors.append(f"field {key}: expected {expected}")
|
||||
if "enum" in spec and data[key] not in spec["enum"]:
|
||||
errors.append(f"field {key}: not in enum {spec['enum']}")
|
||||
|
||||
if errors:
|
||||
sys.stderr.write("TOOL_INPUT_INVALID " + "; ".join(errors) + "\n")
|
||||
raise SystemExit(2)
|
||||
print("TOOL_INPUT_VALID")
|
||||
PY
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify CASAN H5 append-only hash-chain audit log.
|
||||
# Usage:
|
||||
# verify-audit-chain.sh [audit-jsonl]
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
AUDIT_LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/audit.jsonl}"
|
||||
|
||||
if [[ ! -f "$AUDIT_LOG" ]]; then
|
||||
echo "AUDIT_CHAIN_MISSING file=$AUDIT_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPUTED_HEAD="$(python3 - "$AUDIT_LOG" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
previous = ""
|
||||
count = 0
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line_no, line in enumerate(f, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
record = json.loads(line)
|
||||
expected_previous = record.get("previous_record_hash", "")
|
||||
if expected_previous != previous:
|
||||
raise SystemExit(
|
||||
f"AUDIT_CHAIN_BROKEN line={line_no} expected_previous={previous} actual_previous={expected_previous}"
|
||||
)
|
||||
|
||||
core = "|".join(
|
||||
[
|
||||
record.get("timestamp", ""),
|
||||
record.get("trace_id", ""),
|
||||
record.get("action", ""),
|
||||
record.get("actor", ""),
|
||||
record.get("risk_level", ""),
|
||||
record.get("decision", ""),
|
||||
record.get("approval_status", ""),
|
||||
record.get("approver", ""),
|
||||
record.get("input_hash", ""),
|
||||
record.get("output_hash", ""),
|
||||
expected_previous,
|
||||
]
|
||||
)
|
||||
expected_hash = hashlib.sha256(core.encode()).hexdigest()
|
||||
actual_hash = record.get("record_hash", "")
|
||||
if expected_hash != actual_hash:
|
||||
raise SystemExit(
|
||||
f"AUDIT_HASH_MISMATCH line={line_no} expected={expected_hash} actual={actual_hash}"
|
||||
)
|
||||
previous = actual_hash
|
||||
count += 1
|
||||
|
||||
# Emit count and head on stderr (human) and the head on stdout (captured).
|
||||
sys.stderr.write(f"AUDIT_CHAIN_INTEGRITY_OK records={count}\n")
|
||||
sys.stdout.write(previous)
|
||||
PY
|
||||
)"
|
||||
|
||||
# --- External anchor verification ---
|
||||
# Recomputing a forged chain yields a different head; the stored head signature
|
||||
# was produced with a private key the forger does not have, so it will not match.
|
||||
AUDIT_DIR="$(dirname "$AUDIT_LOG")"
|
||||
HEAD_FILE="$AUDIT_DIR/audit-head.txt"
|
||||
HEAD_SIG="$AUDIT_DIR/audit-head.sig"
|
||||
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
|
||||
|
||||
if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v openssl >/dev/null 2>&1; then
|
||||
STORED_HEAD="$(cat "$HEAD_FILE")"
|
||||
if [[ "$STORED_HEAD" != "$COMPUTED_HEAD" ]]; then
|
||||
echo "AUDIT_HEAD_MISMATCH computed=$COMPUTED_HEAD stored=$STORED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! openssl dgst -sha256 -verify "$AUDIT_PUB" -signature "$HEAD_SIG" "$HEAD_FILE" >/dev/null 2>&1; then
|
||||
echo "AUDIT_HEAD_SIGNATURE_INVALID head=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "AUDIT_CHAIN_VALID anchor=signed last_hash=$COMPUTED_HEAD"
|
||||
else
|
||||
echo "AUDIT_CHAIN_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify CASAN Level 5 shared harness reuse across more than one project.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
REGISTRY="$PROJECT_ROOT/.specify/level5/project-registry.json"
|
||||
PACKAGE="$PROJECT_ROOT/.specify/level5/harness-package.json"
|
||||
|
||||
python3 - "$REGISTRY" "$PACKAGE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
registry = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
package = json.load(open(sys.argv[2], encoding="utf-8"))
|
||||
name = package["package"]
|
||||
version = package["version"]
|
||||
projects = [
|
||||
p for p in registry["projects"]
|
||||
if p.get("harness_package") == name and p.get("harness_version") == version
|
||||
]
|
||||
if len(projects) < 2:
|
||||
raise SystemExit(f"HARNESS_REUSE_INSUFFICIENT package={name} version={version} count={len(projects)}")
|
||||
print(f"HARNESS_REUSE_VALID package={name} version={version} project_count={len(projects)}")
|
||||
PY
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Verify the tamper-evident central tool-call audit log:
|
||||
# 1. recompute the SHA-256 hash chain
|
||||
# 2. confirm the stored head equals the computed head
|
||||
# 3. verify the head's RSA signature
|
||||
# Usage: verify-tool-audit.sh [tool-calls.jsonl]
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/tool-calls.jsonl}"
|
||||
|
||||
if [[ ! -f "$LOG" ]]; then
|
||||
echo "TOOL_AUDIT_MISSING file=$LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPUTED_HEAD="$(python3 - "$LOG" <<'PY'
|
||||
import hashlib, json, sys
|
||||
path = sys.argv[1]
|
||||
prev = ""
|
||||
count = 0
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line_no, line in enumerate(f, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("previous_record_hash", "") != prev:
|
||||
raise SystemExit(f"TOOL_AUDIT_CHAIN_BROKEN line={line_no}")
|
||||
stored = rec.pop("record_hash", "")
|
||||
core = json.dumps(rec, sort_keys=True, separators=(",", ":"))
|
||||
expected = hashlib.sha256((prev + "|" + core).encode()).hexdigest()
|
||||
if expected != stored:
|
||||
raise SystemExit(f"TOOL_AUDIT_HASH_MISMATCH line={line_no}")
|
||||
prev = stored
|
||||
count += 1
|
||||
sys.stderr.write(f"TOOL_AUDIT_INTEGRITY_OK records={count}\n")
|
||||
sys.stdout.write(prev)
|
||||
PY
|
||||
)"
|
||||
|
||||
AUDIT_DIR="$(dirname "$LOG")"
|
||||
HEAD_FILE="$AUDIT_DIR/tool-calls-head.txt"
|
||||
HEAD_SIG="$AUDIT_DIR/tool-calls-head.sig"
|
||||
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
|
||||
|
||||
if [[ -f "$HEAD_FILE" && -f "$HEAD_SIG" && -f "$AUDIT_PUB" ]] && command -v openssl >/dev/null 2>&1; then
|
||||
if [[ "$(cat "$HEAD_FILE")" != "$COMPUTED_HEAD" ]]; then
|
||||
echo "TOOL_AUDIT_HEAD_MISMATCH computed=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! openssl dgst -sha256 -verify "$AUDIT_PUB" -signature "$HEAD_SIG" "$HEAD_FILE" >/dev/null 2>&1; then
|
||||
echo "TOOL_AUDIT_HEAD_SIGNATURE_INVALID head=$COMPUTED_HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "TOOL_AUDIT_VALID anchor=signed last_hash=$COMPUTED_HEAD"
|
||||
else
|
||||
echo "TOOL_AUDIT_VALID anchor=unsigned last_hash=$COMPUTED_HEAD"
|
||||
fi
|
||||
Reference in New Issue
Block a user