fix(security): harden flag-file reads and refuse sensitive-file compression

Writes were hardened via safeWriteFlag (PRs #70/#71) but readers still
trusted whatever the flag contained. A local attacker with write access
to ~/.claude/ could symlink the flag at a secret file and have the
per-turn reinforcement inject its bytes into model context, or the
statuslines echo ANSI escapes to the terminal on every keystroke.

- caveman-config.js: new readFlag() — lstat symlink refuse, 64-byte cap,
  O_NOFOLLOW, VALID_MODES whitelist. Returns null on any anomaly.
- caveman-mode-tracker.js: per-turn reinforcement routes through
  readFlag() instead of fs.readFileSync.
- caveman-statusline.sh / .ps1: symlink + size refuse, strip to
  [a-z0-9-], whitelist-validate before rendering.
- compress.py (3 synced copies): is_sensitive_path() denylist refuses
  .env*, .netrc, keys/certs, ~/.ssh|.aws|.gnupg|.kube|.docker, and any
  basename containing secret/credential/password/apikey/token/privatekey
  (separator-insensitive). Fails loudly before read — no silent exfil
  of credentials to the Anthropic API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-04-15 14:49:26 +02:00
co-authored by Claude Opus 4.6
parent 4c82699c26
commit 5ad8f6d684
7 changed files with 252 additions and 22 deletions
+51
View File
@@ -16,6 +16,45 @@ OUTER_FENCE_REGEX = re.compile(
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
)
# Filenames and paths that almost certainly hold secrets or PII. Compressing
# them ships raw bytes to the Anthropic API — a third-party data boundary that
# developers on sensitive codebases cannot cross. detect.py already skips .env
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
# slip through the natural-language filter. This is a hard refuse before read.
SENSITIVE_BASENAME_REGEX = re.compile(
r"(?ix)^("
r"\.env(\..+)?"
r"|\.netrc"
r"|credentials(\..+)?"
r"|secrets?(\..+)?"
r"|passwords?(\..+)?"
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
r"|authorized_keys"
r"|known_hosts"
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
r")$"
)
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
SENSITIVE_NAME_TOKENS = (
"secret", "credential", "password", "passwd",
"apikey", "accesskey", "token", "privatekey",
)
def is_sensitive_path(filepath: Path) -> bool:
"""Heuristic denylist for files that must never be shipped to a third-party API."""
name = filepath.name
if SENSITIVE_BASENAME_REGEX.match(name):
return True
lowered_parts = {p.lower() for p in filepath.parts}
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
return True
# Normalize separators so "api-key" and "api_key" both match "apikey".
lower = re.sub(r"[_\-\s.]", "", name.lower())
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
def strip_llm_wrapper(text: str) -> str:
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
@@ -122,6 +161,18 @@ def compress_file(filepath: Path) -> bool:
if filepath.stat().st_size > MAX_FILE_SIZE:
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
# Refuse files that look like they contain secrets or PII. Compressing ships
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
# loudly rather than silently exfiltrate credentials or keys. Override is
# intentional: the user must rename the file if the heuristic is wrong.
if is_sensitive_path(filepath):
raise ValueError(
f"Refusing to compress {filepath}: filename looks sensitive "
"(credentials, keys, secrets, or known private paths). "
"Compression sends file contents to the Anthropic API. "
"Rename the file if this is a false positive."
)
print(f"Processing: {filepath}")
if not should_compress(filepath):
+46 -1
View File
@@ -106,4 +106,49 @@ function safeWriteFlag(flagPath, content) {
}
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag };
// Symlink-safe, size-capped, whitelist-validated flag file read.
// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read,
// and rejects anything that isn't a known mode. Returns null on any anomaly.
//
// Without this, a local attacker with write access to ~/.claude/ could replace
// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every
// reader — statusline, per-turn reinforcement — would slurp that content and
// either echo it to the terminal or inject it into model context.
//
// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is "wenyan-ultra"
// (12 bytes); 64 leaves slack without enabling exfil.
const MAX_FLAG_BYTES = 64;
function readFlag(flagPath) {
try {
let st;
try {
st = fs.lstatSync(flagPath);
} catch (e) {
return null;
}
if (st.isSymbolicLink() || !st.isFile()) return null;
if (st.size > MAX_FLAG_BYTES) return null;
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
let fd;
let out;
try {
fd = fs.openSync(flagPath, flags);
const buf = Buffer.alloc(MAX_FLAG_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
out = buf.slice(0, n).toString('utf8');
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
const raw = out.trim().toLowerCase();
if (!VALID_MODES.includes(raw)) return null;
return raw;
} catch (e) {
return null;
}
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag };
+14 -16
View File
@@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode, safeWriteFlag } = require('./caveman-config');
const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config');
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.caveman-active');
@@ -74,23 +74,21 @@ process.stdin.on('end', () => {
//
// Skip independent modes (commit, review, compress) — they have their own
// skill behavior and the base caveman rules would conflict.
// readFlag enforces symlink-safe read + size cap + VALID_MODES whitelist.
// If the flag is missing, corrupted, oversized, or a symlink pointing at
// something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing
// — never inject untrusted bytes into model context.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
try {
if (fs.existsSync(flagPath)) {
const activeMode = fs.readFileSync(flagPath, 'utf8').trim() || 'full';
if (!INDEPENDENT_MODES.has(activeMode)) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal."
}
}));
const activeMode = readFlag(flagPath);
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
"Code/commits/security: write normal."
}
}
} catch (e) {
// Silent fail — reinforcement is best-effort
}));
}
} catch (e) {
// Silent fail
+21 -2
View File
@@ -1,16 +1,35 @@
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
$Flag = Join-Path $ClaudeDir ".caveman-active"
if (-not (Test-Path $Flag)) {
if (-not (Test-Path $Flag)) { exit 0 }
# Refuse reparse points (symlinks / junctions) and oversized files. Without
# this, a local attacker could point the flag at a secret file and have the
# statusline render its bytes (including ANSI escape sequences) to the terminal
# every keystroke.
try {
$Item = Get-Item -LiteralPath $Flag -Force -ErrorAction Stop
if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { exit 0 }
if ($Item.Length -gt 64) { exit 0 }
} catch {
exit 0
}
$Mode = ""
try {
$Mode = (Get-Content $Flag -ErrorAction Stop | Select-Object -First 1).Trim()
$Raw = Get-Content -LiteralPath $Flag -TotalCount 1 -ErrorAction Stop
if ($null -ne $Raw) { $Mode = ([string]$Raw).Trim() }
} catch {
exit 0
}
# Strip anything outside [a-z0-9-] — blocks terminal-escape and OSC hyperlink
# injection via the flag contents. Then whitelist-validate.
$Mode = $Mode.ToLowerInvariant()
$Mode = ($Mode -replace '[^a-z0-9-]', '')
$Valid = @('off','lite','full','ultra','wenyan-lite','wenyan','wenyan-full','wenyan-ultra','commit','review','compress')
if (-not ($Valid -contains $Mode)) { exit 0 }
$Esc = [char]27
if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") {
[Console]::Write("${Esc}[38;5;172m[CAVEMAN]${Esc}[0m")
+18 -3
View File
@@ -9,12 +9,27 @@
# Standalone users: install.sh wires this automatically.
FLAG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active"
# Refuse symlinks — a local attacker could point the flag at ~/.ssh/id_rsa and
# have the statusline render its bytes (including ANSI escape sequences) to
# the terminal every keystroke.
[ -L "$FLAG" ] && exit 0
[ ! -f "$FLAG" ] && exit 0
MODE=$(cat "$FLAG" 2>/dev/null)
if [ "$MODE" = "full" ] || [ -z "$MODE" ]; then
# Hard-cap the read at 64 bytes and strip anything outside [a-z0-9-] — blocks
# terminal-escape injection and OSC hyperlink spoofing via the flag contents.
MODE=$(head -c 64 "$FLAG" 2>/dev/null | tr -d '\n\r' | tr '[:upper:]' '[:lower:]')
MODE=$(printf '%s' "$MODE" | tr -cd 'a-z0-9-')
# Whitelist. Anything else → render nothing rather than echo attacker bytes.
case "$MODE" in
off|lite|full|ultra|wenyan-lite|wenyan|wenyan-full|wenyan-ultra|commit|review|compress) ;;
*) exit 0 ;;
esac
if [ -z "$MODE" ] || [ "$MODE" = "full" ]; then
printf '\033[38;5;172m[CAVEMAN]\033[0m'
else
SUFFIX=$(echo "$MODE" | tr '[:lower:]' '[:upper:]')
SUFFIX=$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]')
printf '\033[38;5;172m[CAVEMAN:%s]\033[0m' "$SUFFIX"
fi
@@ -16,6 +16,45 @@ OUTER_FENCE_REGEX = re.compile(
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
)
# Filenames and paths that almost certainly hold secrets or PII. Compressing
# them ships raw bytes to the Anthropic API — a third-party data boundary that
# developers on sensitive codebases cannot cross. detect.py already skips .env
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
# slip through the natural-language filter. This is a hard refuse before read.
SENSITIVE_BASENAME_REGEX = re.compile(
r"(?ix)^("
r"\.env(\..+)?"
r"|\.netrc"
r"|credentials(\..+)?"
r"|secrets?(\..+)?"
r"|passwords?(\..+)?"
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
r"|authorized_keys"
r"|known_hosts"
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
r")$"
)
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
SENSITIVE_NAME_TOKENS = (
"secret", "credential", "password", "passwd",
"apikey", "accesskey", "token", "privatekey",
)
def is_sensitive_path(filepath: Path) -> bool:
"""Heuristic denylist for files that must never be shipped to a third-party API."""
name = filepath.name
if SENSITIVE_BASENAME_REGEX.match(name):
return True
lowered_parts = {p.lower() for p in filepath.parts}
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
return True
# Normalize separators so "api-key" and "api_key" both match "apikey".
lower = re.sub(r"[_\-\s.]", "", name.lower())
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
def strip_llm_wrapper(text: str) -> str:
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
@@ -122,6 +161,18 @@ def compress_file(filepath: Path) -> bool:
if filepath.stat().st_size > MAX_FILE_SIZE:
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
# Refuse files that look like they contain secrets or PII. Compressing ships
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
# loudly rather than silently exfiltrate credentials or keys. Override is
# intentional: the user must rename the file if the heuristic is wrong.
if is_sensitive_path(filepath):
raise ValueError(
f"Refusing to compress {filepath}: filename looks sensitive "
"(credentials, keys, secrets, or known private paths). "
"Compression sends file contents to the Anthropic API. "
"Rename the file if this is a false positive."
)
print(f"Processing: {filepath}")
if not should_compress(filepath):
+51
View File
@@ -16,6 +16,45 @@ OUTER_FENCE_REGEX = re.compile(
r"\A\s*(`{3,}|~{3,})[^\n]*\n(.*)\n\1\s*\Z", re.DOTALL
)
# Filenames and paths that almost certainly hold secrets or PII. Compressing
# them ships raw bytes to the Anthropic API — a third-party data boundary that
# developers on sensitive codebases cannot cross. detect.py already skips .env
# by extension, but credentials.md / secrets.txt / ~/.aws/credentials would
# slip through the natural-language filter. This is a hard refuse before read.
SENSITIVE_BASENAME_REGEX = re.compile(
r"(?ix)^("
r"\.env(\..+)?"
r"|\.netrc"
r"|credentials(\..+)?"
r"|secrets?(\..+)?"
r"|passwords?(\..+)?"
r"|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?"
r"|authorized_keys"
r"|known_hosts"
r"|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg)"
r")$"
)
SENSITIVE_PATH_COMPONENTS = frozenset({".ssh", ".aws", ".gnupg", ".kube", ".docker"})
SENSITIVE_NAME_TOKENS = (
"secret", "credential", "password", "passwd",
"apikey", "accesskey", "token", "privatekey",
)
def is_sensitive_path(filepath: Path) -> bool:
"""Heuristic denylist for files that must never be shipped to a third-party API."""
name = filepath.name
if SENSITIVE_BASENAME_REGEX.match(name):
return True
lowered_parts = {p.lower() for p in filepath.parts}
if lowered_parts & SENSITIVE_PATH_COMPONENTS:
return True
# Normalize separators so "api-key" and "api_key" both match "apikey".
lower = re.sub(r"[_\-\s.]", "", name.lower())
return any(tok in lower for tok in SENSITIVE_NAME_TOKENS)
def strip_llm_wrapper(text: str) -> str:
"""Strip outer ```markdown ... ``` fence when it wraps the entire output."""
@@ -122,6 +161,18 @@ def compress_file(filepath: Path) -> bool:
if filepath.stat().st_size > MAX_FILE_SIZE:
raise ValueError(f"File too large to compress safely (max 500KB): {filepath}")
# Refuse files that look like they contain secrets or PII. Compressing ships
# the raw bytes to the Anthropic API — a third-party boundary — so we fail
# loudly rather than silently exfiltrate credentials or keys. Override is
# intentional: the user must rename the file if the heuristic is wrong.
if is_sensitive_path(filepath):
raise ValueError(
f"Refusing to compress {filepath}: filename looks sensitive "
"(credentials, keys, secrets, or known private paths). "
"Compression sends file contents to the Anthropic API. "
"Rename the file if this is a false positive."
)
print(f"Processing: {filepath}")
if not should_compress(filepath):