feat(h4-hardening): Plan-07 Track A — A1 strict semantic, A2 unicode/encoding, A3 tool-output scan

A1 (V1): CASAN_SECURITY_STRICT=1 makes semantic classification REQUIRED and
  fail-closed — model unavailable/no-verdict → BLOCK, never a silent SKIP.
  Non-strict CASAN_SEMANTIC_CLASSIFY=1 keeps regex verdict but logs
  SEMANTIC_SKIPPED loudly (sourced casan-log.sh). Default (no flags) unchanged.
A2 (V3/V4): unicode-normalize.py (NFKC + zero-width strip + Cyrillic/Greek
  homoglyph fold) and decode-suspicious.py (base64/hex decode + rescan, printable
  filter to avoid false positives) feed new match_either/secret_match haystacks.
  Blocks homoglyph, zero-width, fullwidth, base64/hex-smuggled injection & secrets.
A3 (V7): tool-output-scan.sh scans tool output for injection/secret before it
  re-enters model context; wrapper runs it after H6-exec (mode off|warn|block,
  strict→block). warn is default to preserve benign-draft behaviour.

Baseline preserved: run-casan4 35/35, adversarial 44/44.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-03 22:20:50 +09:00
co-authored by Claude Opus 4.8
parent fbcef967e5
commit ac40c0b281
5 changed files with 283 additions and 24 deletions
@@ -115,6 +115,32 @@ else
run_phase "H6-exec" "$SCRIPT_DIR/agent-metrics.sh" "$APPROVED_INPUT" "$RAW_OUTPUT"
fi
# V7: tool output can carry indirect injection that would re-enter a downstream
# model's context. Scan RAW_OUTPUT for injection/secret patterns before it is
# reused. Mode: off | warn (default) | block. Strict mode upgrades to block.
# warn keeps existing behaviour (logged, non-blocking) so benign drafts are not
# broken; block enforces (fail-closed) for production/strict runs.
TOOL_OUTPUT_SCAN_MODE="${CASAN_TOOL_OUTPUT_SCAN:-}"
if [[ -z "$TOOL_OUTPUT_SCAN_MODE" ]]; then
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then TOOL_OUTPUT_SCAN_MODE="block"; else TOOL_OUTPUT_SCAN_MODE="warn"; fi
fi
if [[ "$TOOL_OUTPUT_SCAN_MODE" != "off" ]]; then
TOS_RC=0
"$SCRIPT_DIR/tool-output-scan.sh" "$RAW_OUTPUT" "$ACTION_NAME" >/dev/null 2>&1 || TOS_RC=$?
record_phase "H4-tool-output" "$TOS_RC"
if [[ "$TOS_RC" -eq 2 ]]; then
if [[ "$TOOL_OUTPUT_SCAN_MODE" == "block" ]]; then
casan_log error harness "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME"
: > "$FINAL_OUTPUT"
write_phase_report
echo "TOOL_OUTPUT_INJECTION_BLOCKED action=$ACTION_NAME" >&2
exit 2
else
casan_log warn harness "TOOL_OUTPUT_INJECTION_SUSPECTED action=$ACTION_NAME mode=warn hint=set_CASAN_TOOL_OUTPUT_SCAN=block_or_CASAN_SECURITY_STRICT=1_to_enforce"
fi
fi
fi
run_phase "H4-out" "$SCRIPT_DIR/security-check.sh" "$RAW_OUTPUT" "$FINAL_OUTPUT" output
if [[ "$CACHE_STATUS" == "stored" ]]; then
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""CASAN H4 — Suspicious base64/hex decoder (Track A, V4 encoding smuggling).
Reads text on stdin. Finds embedded base64 and hex blobs, decodes them, and
prints the decoded plaintext (one blob per line) on stdout so the caller can
re-run its injection/secret pattern scan on the *decoded* content.
Safety / no-false-positive design:
* Only blobs >= MIN_LEN characters are considered (short words are ignored).
* A decoded blob is emitted ONLY if it is mostly printable text. Random
base64-looking words (e.g. "objectives", DER key bytes) decode to
non-printable garbage and are dropped, so they can never trigger a match.
* Output is advisory: the caller decides a decoded blob is malicious only if
the decoded text itself matches a block/secret pattern.
Deterministic: same input always yields the same output.
"""
import base64
import binascii
import re
import sys
MIN_LEN = 16
PRINTABLE_RATIO = 0.8
B64_RE = re.compile(r"[A-Za-z0-9+/]{%d,}={0,2}" % MIN_LEN)
HEX_RE = re.compile(r"\b[0-9a-fA-F]{%d,}\b" % MIN_LEN)
def _mostly_printable(text: str) -> bool:
if not text:
return False
ok = sum(1 for c in text if c.isprintable() or c.isspace())
return ok >= PRINTABLE_RATIO * len(text)
def decode_blobs(data: str):
out = []
for m in B64_RE.findall(data):
pad = m + "=" * ((4 - len(m) % 4) % 4)
try:
dec = base64.b64decode(pad, validate=True)
except (binascii.Error, ValueError):
continue
txt = dec.decode("utf-8", "ignore")
if _mostly_printable(txt):
out.append(txt)
for m in HEX_RE.findall(data):
if len(m) % 2 != 0:
continue
try:
dec = bytes.fromhex(m)
except ValueError:
continue
txt = dec.decode("utf-8", "ignore")
if _mostly_printable(txt):
out.append(txt)
return out
if __name__ == "__main__":
blobs = decode_blobs(sys.stdin.read())
if blobs:
sys.stdout.write("\n".join(blobs))
@@ -24,6 +24,11 @@ TRACE_DIR="$LOG_DIR/trace"
AUDIT_DIR="$LOG_DIR/audit"
SECURITY_DIR="$PROJECT_ROOT/.specify/security"
# Shared log taxonomy (error<warn<info<debug<trace via CASAN_LOG_LEVEL). Used to
# make semantic skips loud (never silent) — stderr only, stdout contract intact.
# shellcheck source=casan-log.sh
source "$SCRIPT_DIR/casan-log.sh"
mkdir -p "$TRACE_DIR" "$AUDIT_DIR" "$(dirname "$OUTPUT_FILE")"
if [[ ! -f "$INPUT_FILE" ]]; then
@@ -150,13 +155,45 @@ 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.
# Unicode-aware normalization (NFKC + zero-width strip + homoglyph fold) so
# fullwidth/zero-width/Cyrillic-lookalike obfuscation cannot split or disguise
# a blocked phrase (V3). Falls back to the raw content if python is missing.
UNI_CONTENT="$CONTENT"
if command -v python >/dev/null 2>&1; then
UNI_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
[[ -n "$UNI_CONTENT" ]] || UNI_CONTENT="$CONTENT"
fi
UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
# Encoding smuggling (V4): decode embedded base64/hex blobs and expose the
# decoded plaintext so the same block/secret patterns can be re-run on it.
# Only mostly-printable decodes survive, so random base64-looking words never
# create a false positive.
DECODED_CONTENT=""
if command -v python >/dev/null 2>&1; then
DECODED_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
fi
# Matches a pattern against the raw (case-insensitive), leetspeak-folded,
# unicode-normalized, and decoded-payload views of the content, so leetspeak,
# whitespace/punctuation, homoglyph, zero-width and base64/hex obfuscation
# cannot slip a phrase past the blocklist.
match_either() {
local pattern="$1"
printf '%s' "$CONTENT" | grep -Eiq -- "$pattern" \
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern"
|| printf '%s' "$NORM_CONTENT" | grep -Eq -- "$pattern" \
|| printf '%s' "$UNI_CONTENT" | grep -Eiq -- "$pattern" \
|| printf '%s' "$UNI_NORM_CONTENT" | grep -Eq -- "$pattern" \
|| { [[ -n "$DECODED_CONTENT" ]] && printf '%s' "$DECODED_CONTENT" | grep -Eiq -- "$pattern"; }
}
# Secret/PII regex match against the raw content OR any decoded base64/hex
# payload, so a secret smuggled through encoding is still caught (V4). Additive
# only: with no decoded payload this is identical to the previous raw check.
secret_match() {
local regex="$1"
printf '%s' "$CONTENT" | grep -Eiq -- "$regex" \
|| { [[ -n "$DECODED_CONTENT" ]] && printf '%s' "$DECODED_CONTENT" | grep -Eiq -- "$regex"; }
}
if [[ "$MODE" == "input" ]]; then
@@ -169,17 +206,17 @@ if [[ "$MODE" == "input" ]]; then
fi
done
if printf '%s' "$CONTENT" | grep -Eq -- "$CREDIT_CARD_REGEX"; then
if secret_match "$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
if secret_match "$SECRET_REGEX" \
|| secret_match "$PRIVATE_KEY_REGEX" \
|| secret_match "$DB_CONN_REGEX" \
|| secret_match "$AWS_KEY_REGEX"; then
STATUS="blocked"
ACTION="block"
RISK_LEVEL="high"
@@ -207,23 +244,41 @@ if [[ "$MODE" == "input" ]]; then
done
fi
# Optional semantic escalation (opt-in: CASAN_SEMANTIC_CLASSIFY=1). The regex
# layer above catches known phrasings; a genuinely novel paraphrase slips
# through as low-risk. When enabled, route still-allowed input to the model
# classifier. It can only ADD a block, never remove one. If the model backend
# is unreachable, record it and keep the regex verdict (no silent pass of a
# blocked item; no hard pipeline failure on infra outage).
if [[ "$STATUS" != "blocked" && "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" && -x "$SCRIPT_DIR/model-router.sh" ]]; then
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
if [[ -f "$SEM_JSON" ]]; then
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
# Semantic escalation. The regex layer above catches known phrasings; a
# genuinely novel paraphrase slips through as low-risk, so a still-allowed
# input is routed to the model classifier. Semantic can only ADD a block,
# never remove one. Two modes:
# * CASAN_SECURITY_STRICT=1 — semantic is REQUIRED (V1). If the model is
# unreachable or returns no usable verdict we FAIL CLOSED (block); never a
# silent skip. Off by default so CI without a model stays non-strict.
# * CASAN_SEMANTIC_CLASSIFY=1 (non-strict) — best-effort. On model outage we
# keep the regex verdict but log SEMANTIC_SKIPPED loudly (no silent pass).
SEMANTIC_REQUIRED=0
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" || "${CASAN_SEMANTIC_CLASSIFY:-0}" == "1" ]]; then
SEMANTIC_REQUIRED=1
fi
if [[ "$STATUS" != "blocked" && "$SEMANTIC_REQUIRED" == "1" ]]; then
SEM_VERDICT=""
if [[ -x "$SCRIPT_DIR/model-router.sh" ]]; then
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
if [[ -f "$SEM_JSON" ]]; then
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
fi
fi
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
MATCHED_RULES+=("semantic-injection")
elif [[ -z "$SEM_VERDICT" ]]; then
# Model unreachable / no usable verdict.
if [[ "${CASAN_SECURITY_STRICT:-0}" == "1" ]]; then
STATUS="blocked"; ACTION="block"; RISK_LEVEL="high"
MATCHED_RULES+=("semantic-injection")
MATCHED_RULES+=("semantic-strict-unavailable")
casan_log error security "SEMANTIC_STRICT_FAIL_CLOSED trace_id=$TRACE_ID reason=model_unavailable action=block"
else
MATCHED_RULES+=("semantic-unavailable")
casan_log warn security "SEMANTIC_SKIPPED trace_id=$TRACE_ID reason=model_unavailable action=keep_regex_verdict hint=set_CASAN_SECURITY_STRICT=1_to_fail_closed"
fi
else
MATCHED_RULES+=("semantic-unavailable")
fi
fi
fi
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN H4 — Tool-output indirect-injection scanner (Track A, V7).
#
# A tool (shell command, file read, web fetch, sub-agent) can return content
# that is then fed back into a downstream model's context. If that content
# carries a prompt injection, the model can be hijacked even though the ORIGINAL
# user input was clean. This scans a tool-output file the same way an untrusted
# artifact is scanned, BEFORE the output is allowed to re-enter model context.
#
# It reuses security-check.sh in `input` mode (block-pattern + unicode/encoding
# normalization + secret detection) but forces the semantic/strict model path
# OFF so the scan is deterministic and needs no model backend — this is a
# pattern scan of machine output, not a user-intent classification.
#
# Usage:
# tool-output-scan.sh <tool-output-file> [context-label]
# Exit:
# 0 — safe to reuse
# 2 — injection / secret pattern detected (caller should reject/quarantine)
# 64 — usage error (file missing)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_FILE="${1:-}"
LABEL="${2:-unknown-tool}"
if [[ -z "$OUTPUT_FILE" || ! -f "$OUTPUT_FILE" ]]; then
echo "Usage: tool-output-scan.sh <tool-output-file> [context-label]" >&2
exit 64
fi
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
SCAN_OUT="$WORK/tool-output-scan.txt"
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
# Deterministic pattern scan: semantic + strict explicitly disabled here so a
# tool-output scan never depends on (or is blocked by) model availability.
CASAN_SECURITY_STRICT=0 CASAN_SEMANTIC_CLASSIFY=0 \
bash "$SCRIPT_DIR/security-check.sh" "$OUTPUT_FILE" "$SCAN_OUT" input >/dev/null 2>&1
SC_RC=$?
if [[ "$SC_RC" -eq 2 ]]; then
echo "TOOL_OUTPUT_SCAN_BLOCKED label=$LABEL file=$OUTPUT_FILE reason=injection_or_secret timestamp=$TIMESTAMP"
exit 2
elif [[ "$SC_RC" -ne 0 ]]; then
echo "TOOL_OUTPUT_SCAN_ERROR label=$LABEL rc=$SC_RC" >&2
exit 2 # fail closed on scan error
fi
echo "TOOL_OUTPUT_SCAN_CLEAN label=$LABEL file=$OUTPUT_FILE timestamp=$TIMESTAMP"
exit 0
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""CASAN H4 — Unicode confusable / obfuscation normalizer (Track A, V3).
Reads text on stdin, writes a normalized variant on stdout used ONLY for
injection/jailbreak phrase matching (never for PII/secret redaction, so we
never widen what counts as a secret).
Deterministic transforms, in order:
1. NFKC compatibility normalization — folds fullwidth (ignore) and
other compatibility forms to their ASCII equivalents.
2. Strip zero-width / BOM / soft-hyphen formatting characters that split a
word so a blocklist never sees it (i·g·n·o·r·e).
3. Fold a fixed table of common Cyrillic/Greek homoglyphs to their Latin
lookalikes (іgnоrе -> ignore). NFKC does NOT do this — confusables are a
separate Unicode concern — so the table is explicit and auditable.
The table is intentionally small and covers the lookalikes actually used in
prompt-injection homoglyph attacks; extend it as new vectors appear.
"""
import sys
import unicodedata
# Zero-width, BOM, and invisible formatting code points.
ZERO_WIDTH = {
0x200B, # zero-width space
0x200C, # zero-width non-joiner
0x200D, # zero-width joiner
0x2060, # word joiner
0xFEFF, # BOM / zero-width no-break space
0x00AD, # soft hyphen
0x180E, # Mongolian vowel separator
0x2061, 0x2062, 0x2063, 0x2064, # invisible math operators
}
# Common Cyrillic / Greek homoglyphs -> Latin lookalike. Lowercase and
# uppercase both listed because matching is case-insensitive downstream but the
# fold must run before case handling to be safe.
CONFUSABLES = {
# Cyrillic lowercase
"а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x",
"ѕ": "s", "і": "i", "ј": "j", "ԁ": "d", "һ": "h", "ӏ": "l", "п": "n",
"г": "r", "т": "t", "к": "k", "м": "m", "в": "b",
# Cyrillic uppercase
"А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O",
"Р": "P", "С": "C", "Т": "T", "У": "Y", "Х": "X", "Ѕ": "S", "І": "I",
"Ј": "J",
# Greek
"α": "a", "ε": "e", "ο": "o", "ρ": "p", "τ": "t", "ν": "v", "κ": "k",
"Α": "A", "Β": "B", "Ε": "E", "Ζ": "Z", "Η": "H", "Ι": "I", "Κ": "K",
"Μ": "M", "Ν": "N", "Ο": "O", "Ρ": "P", "Τ": "T", "Υ": "Y", "Χ": "X",
}
def normalize(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
text = "".join(ch for ch in text if ord(ch) not in ZERO_WIDTH)
text = "".join(CONFUSABLES.get(ch, ch) for ch in text)
return text
if __name__ == "__main__":
sys.stdout.write(normalize(sys.stdin.read()))