Skip to content

Commit d2d55b4

Browse files
fix: harden command blocking, secure temp files, add concurrency lock and uninstall
- block-dangerous.sh: add normalize_cmd(), extract_inner_cmd(), _check_blocked(), _check_subshell() for bypass-resistant matching; exclude --force-with-lease from force-push block; use printf instead of echo for pattern matching - verify-on-stop.sh, run-code-review.sh: replace predictable /tmp paths with secure per-project $KOVA_TMP directories (mode 700) - kova-loop.sh: add portable mkdir-based concurrency lock with stale PID detection - install.sh: timestamped backup filenames to prevent overwrites - scripts/kova: add cmd_uninstall with clean removal and settings restore - docs: expand legacy install instructions across all READMEs - tests: add 44 new tests (block-dangerous, format) and update install assertions Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 7b386c1 commit d2d55b4

13 files changed

Lines changed: 717 additions & 40 deletions

File tree

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,30 @@ That's it — no cloning, no scripts. Commands and skills are available immediat
3535
### Option B: Legacy Install (clone + install.sh)
3636

3737
```bash
38-
# 1) Clone once
38+
# Clone Kova
3939
git clone https://github.com/ChiFungHillmanChan/kova.git ~/kova
4040

41-
# 2) Install into any project
41+
# Go to your project
4242
cd /path/to/your/project
43-
bash ~/kova/install.sh --dry-run # preview first
43+
44+
# Preview what will be installed
45+
bash ~/kova/install.sh --dry-run
46+
47+
# Install Kova into this project
4448
bash ~/kova/install.sh
45-
kova activate
4649
```
4750

4851
Optional global CLI:
4952

5053
```bash
54+
# Install global CLI
5155
bash ~/kova/install.sh --global
52-
kova setup
56+
57+
# Activate hooks for this project
58+
kova activate
59+
60+
# Verify setup
61+
kova status
5362
```
5463

5564
### Two Plugins

docs/en/README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,26 @@ No cloning, no scripts. Everything is available immediately after install.
6262
### Option B: Legacy Install (clone + install.sh)
6363

6464
```bash
65-
# From your project directory:
65+
# Clone Kova
66+
git clone https://github.com/ChiFungHillmanChan/kova.git ~/kova
67+
68+
# Go to your project
6669
cd /path/to/your/project
67-
bash /path/to/kova/install.sh
6870

69-
# Preview without installing:
70-
bash /path/to/kova/install.sh --dry-run
71+
# Preview what will be installed
72+
bash ~/kova/install.sh --dry-run
73+
74+
# Install Kova into this project
75+
bash ~/kova/install.sh
76+
77+
# Optional: install global CLI
78+
bash ~/kova/install.sh --global
79+
80+
# Activate hooks for this project
81+
kova activate
82+
83+
# Verify setup
84+
kova status
7185
```
7286

7387
What the installer does:

docs/zh-cn/README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,26 @@ claude /install kova-full # 完整版:命令 + 技能 + hooks + 执行保
6262
### 方法 B:传统安装(clone + install.sh)
6363

6464
```bash
65-
# 在你的项目目录中运行:
65+
# Clone Kova
66+
git clone https://github.com/ChiFungHillmanChan/kova.git ~/kova
67+
68+
# 进入你的项目
6669
cd /path/to/your/project
67-
bash /path/to/kova/install.sh
6870

69-
# 安装前预览:
70-
bash /path/to/kova/install.sh --dry-run
71+
# 先预览将会安装什么
72+
bash ~/kova/install.sh --dry-run
73+
74+
# 把 Kova 安装到这个项目
75+
bash ~/kova/install.sh
76+
77+
# 可选:安装全局 CLI
78+
bash ~/kova/install.sh --global
79+
80+
# 为这个项目启用 hooks
81+
kova activate
82+
83+
# 检查安装状态
84+
kova status
7185
```
7286

7387
安装器会做以下事情:

docs/zh-hk/README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,26 @@ claude /install kova-full # 完整版:指令 + 技能 + hooks + 執行保
6262
### 方法 B:傳統安裝(clone + install.sh)
6363

6464
```bash
65-
# 喺你個 project 目錄入面行:
65+
# Clone Kova
66+
git clone https://github.com/ChiFungHillmanChan/kova.git ~/kova
67+
68+
# 入去你個 project
6669
cd /path/to/your/project
67-
bash /path/to/kova/install.sh
6870

69-
# 裝之前睇下會安裝啲乜:
70-
bash /path/to/kova/install.sh --dry-run
71+
# 先睇下會安裝啲乜
72+
bash ~/kova/install.sh --dry-run
73+
74+
# 安裝 Kova 入呢個 project
75+
bash ~/kova/install.sh
76+
77+
# 可選:安裝 global CLI
78+
bash ~/kova/install.sh --global
79+
80+
# 為呢個 project 啟用 hooks
81+
kova activate
82+
83+
# 檢查安裝狀態
84+
kova status
7185
```
7286

7387
安裝器會做以下嘢:

hooks/block-dangerous.sh

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,39 @@ if [ -z "$CMD" ]; then
99
exit 0
1010
fi
1111

12+
# Normalize a command string for safer pattern matching.
13+
# Strips quotes, backslash escapes, and collapses whitespace so that
14+
# obfuscated variants like 'rm' "-rf" / still match blocked patterns.
15+
normalize_cmd() {
16+
local c="$1"
17+
# Remove backslash escapes (e.g. r\m -> rm). Use | delimiter to avoid
18+
# ambiguity with / in sed — s/\\//g would also strip forward slashes.
19+
c=$(printf '%s' "$c" | sed 's|\\||g')
20+
# Remove single and double quotes
21+
c=$(printf '%s' "$c" | tr -d "\"'")
22+
# Collapse runs of whitespace into a single space
23+
c=$(printf '%s' "$c" | tr -s '[:space:]' ' ')
24+
# Trim leading/trailing whitespace
25+
c=$(printf '%s' "$c" | sed 's/^ //;s/ $//')
26+
printf '%s' "$c"
27+
}
28+
29+
# Extract the inner command from wrapper invocations like:
30+
# eval 'rm -rf /'
31+
# bash -c "rm -rf /"
32+
# sh -c 'DROP TABLE foo'
33+
extract_inner_cmd() {
34+
local c="$1"
35+
if printf '%s' "$c" | grep -qiE '^\s*(eval|bash\s+-c|sh\s+-c)\s+'; then
36+
printf '%s' "$c" | sed -E 's/^\s*(eval|bash\s+-c|sh\s+-c)\s+//I'
37+
fi
38+
}
39+
40+
NORM_CMD=$(normalize_cmd "$CMD")
41+
INNER_CMD=$(extract_inner_cmd "$NORM_CMD")
42+
NORM_INNER=""
43+
[ -n "$INNER_CMD" ] && NORM_INNER=$(normalize_cmd "$INNER_CMD")
44+
1245
# Patterns that are always blocked
1346
BLOCKED_PATTERNS=(
1447
"rm -rf /"
@@ -19,20 +52,64 @@ BLOCKED_PATTERNS=(
1952
"DROP TABLE"
2053
"DROP DATABASE"
2154
"TRUNCATE TABLE"
22-
"rm -rf \*"
55+
"rm -rf *"
2356
"> /dev/sda"
2457
"mkfs"
2558
"dd if="
2659
":(){:|:&};:"
2760
)
2861

29-
for pattern in "${BLOCKED_PATTERNS[@]}"; do
30-
if echo "$CMD" | grep -qiF "$pattern"; then
31-
echo "BLOCKED: Dangerous command detected: \"$pattern\"" >&2
32-
echo '{"decision":"block","reason":"This command matches a dangerous pattern and has been blocked by Kova safety protocol. If you genuinely need to run this, ask the human explicitly."}'
33-
exit 0
62+
# Check a string against all blocked patterns.
63+
# Returns 0 (match) or 1 (no match). Prints the matched pattern on stdout.
64+
_check_blocked() {
65+
local text="$1"
66+
for pattern in "${BLOCKED_PATTERNS[@]}"; do
67+
if printf '%s' "$text" | grep -qiF "$pattern"; then
68+
# --force-with-lease is semi-dangerous (warned), not catastrophic — don't block it
69+
if { [ "$pattern" = "git push --force" ] || [ "$pattern" = "git push -f" ]; } \
70+
&& printf '%s' "$text" | grep -qiF "force-with-lease"; then
71+
continue
72+
fi
73+
printf '%s' "$pattern"
74+
return 0
75+
fi
76+
done
77+
return 1
78+
}
79+
80+
# Also block commands containing $() or backtick substitutions that embed
81+
# dangerous patterns (e.g. $(rm -rf /) or `rm -rf /` ).
82+
_check_subshell() {
83+
local text="$1"
84+
local sub=""
85+
# Extract content inside $(...) — greedy single match is sufficient
86+
sub=$(printf '%s' "$text" | sed -n 's/.*\$(\(.*\)).*/\1/p')
87+
[ -z "$sub" ] && sub=$(printf '%s' "$text" | sed -n 's/.*`\(.*\)`.*/\1/p')
88+
if [ -n "$sub" ]; then
89+
local norm_sub
90+
norm_sub=$(normalize_cmd "$sub")
91+
_check_blocked "$norm_sub" && return 0
3492
fi
35-
done
93+
return 1
94+
}
95+
96+
matched=""
97+
# Check the raw command first (catches exact matches)
98+
matched=$(_check_blocked "$CMD") ||
99+
# Check the normalized form (catches quote/backslash obfuscation)
100+
matched=$(_check_blocked "$NORM_CMD") ||
101+
# Check inner command from eval/bash -c/sh -c wrappers
102+
{ [ -n "$NORM_INNER" ] && matched=$(_check_blocked "$NORM_INNER"); } ||
103+
# Check for dangerous subshell substitutions
104+
matched=$(_check_subshell "$CMD") ||
105+
matched=$(_check_subshell "$NORM_CMD") ||
106+
true
107+
108+
if [ -n "$matched" ]; then
109+
echo "BLOCKED: Dangerous command detected: \"$matched\"" >&2
110+
echo '{"decision":"block","reason":"This command matches a dangerous pattern and has been blocked by Kova safety protocol. If you genuinely need to run this, ask the human explicitly."}'
111+
exit 0
112+
fi
36113

37114
# Warn (but allow) for semi-dangerous patterns
38115
WARN_PATTERNS=(
@@ -42,7 +119,7 @@ WARN_PATTERNS=(
42119
)
43120

44121
for pattern in "${WARN_PATTERNS[@]}"; do
45-
if echo "$CMD" | grep -qiF "$pattern"; then
122+
if printf '%s' "$NORM_CMD" | grep -qiF "$pattern"; then
46123
echo "WARNING: Potentially destructive command detected. Proceeding, but double-check." >&2
47124
fi
48125
done

hooks/kova-loop.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,26 @@ _maybe_escalate_to_codex() {
156156
fi
157157
}
158158

159+
# Acquire an exclusive lock so only one loop runs per project.
160+
# Uses mkdir (atomic on all POSIX systems) since flock is not portable to macOS.
161+
_acquire_loop_lock() {
162+
LOCK_DIR="${STATE_DIR}/.kova-loop.lock"
163+
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
164+
local stale_pid=""
165+
[ -f "$LOCK_DIR/pid" ] && stale_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null)
166+
if [ -n "$stale_pid" ] && kill -0 "$stale_pid" 2>/dev/null; then
167+
echo "ERROR: Another Kova loop is already running in this project (PID $stale_pid)." >&2
168+
exit 1
169+
fi
170+
# Stale lock from a crashed process — reclaim it
171+
rm -rf "$LOCK_DIR"
172+
mkdir "$LOCK_DIR" 2>/dev/null || { echo "ERROR: Cannot acquire loop lock." >&2; exit 1; }
173+
fi
174+
echo $$ > "$LOCK_DIR/pid"
175+
# shellcheck disable=SC2064
176+
trap "rm -rf '$LOCK_DIR'" EXIT
177+
}
178+
159179
main() {
160180
parse_args "$@"
161181
detect_pm; detect_languages
@@ -173,6 +193,7 @@ main() {
173193
if $DRY_RUN; then echo "DRY RUN — no changes will be made." >&2; exit 0; fi
174194

175195
mkdir -p "$STATE_DIR"; : > "$STATE_DIR/ITERATION_LOG.md"
196+
_acquire_loop_lock
176197
rate_limit_init "$STATE_DIR"
177198

178199
local completed_context=""

hooks/lib/run-code-review.sh

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ $diff_content"
9494

9595
# Optional: Cross-model review via Codex
9696
if type codex_available &>/dev/null && codex_available; then
97-
local codex_diff="/tmp/.kova-review-diff-$$.txt"
98-
local codex_rev="/tmp/.kova-codex-review-$$.md"
97+
local _review_tmp="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/kova-review-$$"
98+
mkdir -p "$_review_tmp" 2>/dev/null
99+
chmod 700 "$_review_tmp" 2>/dev/null
100+
local codex_diff="$_review_tmp/diff.txt"
101+
local codex_rev="$_review_tmp/codex-review.md"
99102
echo "$diff_content" > "$codex_diff"
100103
if codex_review "$codex_diff" "$codex_rev"; then
101104
echo "" >> "$output_file"
@@ -106,7 +109,7 @@ $diff_content"
106109
REVIEW_RESULT="HIGH"
107110
fi
108111
fi
109-
rm -f "$codex_diff" "$codex_rev"
112+
rm -rf "$_review_tmp"
110113
fi
111114

112115
return 0

hooks/verify-on-stop.sh

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ fi
2929

3030
# --- Retry counter (max 3 attempts) ---
3131
PROJ_HASH=$(project_hash "$CLAUDE_PROJECT_DIR")
32-
COUNTER_FILE="/tmp/.claude-verify-stop-$PROJ_HASH"
33-
HISTORY_FILE="/tmp/.claude-verify-history-$PROJ_HASH"
32+
KOVA_TMP="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/kova-$PROJ_HASH"
33+
mkdir -p "$KOVA_TMP" 2>/dev/null
34+
chmod 700 "$KOVA_TMP" 2>/dev/null
35+
COUNTER_FILE="$KOVA_TMP/verify-stop-counter"
36+
HISTORY_FILE="$KOVA_TMP/verify-stop-history"
3437
ATTEMPT=$(cat "$COUNTER_FILE" 2>/dev/null || echo "0")
3538
ATTEMPT=$((ATTEMPT + 1))
3639
echo "$ATTEMPT" > "$COUNTER_FILE"
@@ -56,7 +59,7 @@ $(cat "$HISTORY_FILE" 2>/dev/null || echo "No history available")
5659
DEBUGEOF
5760

5861
# Cross-model diagnosis before self-heal
59-
codex_diag="/tmp/.claude-codex-diag-$PROJ_HASH"
62+
codex_diag="$KOVA_TMP/codex-diag"
6063
if codex_diagnose "DEBUG_LOG.md" "$codex_diag"; then
6164
echo "" >> DEBUG_LOG.md
6265
cat "$codex_diag" >> DEBUG_LOG.md
@@ -79,7 +82,7 @@ DEBUGEOF
7982
PROMPT="Read DEBUG_LOG.md and fix all failures listed. Pay special attention to the 'Cross-Model Diagnosis [codex]' section if present — it contains analysis from a different AI model. Run tests after each fix. Do not ask questions — use the assumption protocol."
8083
CLAUDE_SELF_HEAL=1 nohup claude -p "$PROMPT" \
8184
--allowedTools "Edit,Write,Bash,Read,Glob,Grep" \
82-
> "/tmp/.claude-self-heal-$PROJ_HASH.log" 2>&1 &
85+
> "$KOVA_TMP/self-heal.log" 2>&1 &
8386
echo "STOP GATE: Self-healing session spawned (PID: $!)." >&2
8487
else
8588
echo "STOP GATE: claude CLI not found. Manual fix required." >&2

install.sh

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,9 @@ mkdir -p "$TARGET_DIR/.claude/hooks/lib"
167167
# which work for the repo itself (plugin development) but NOT for legacy installs
168168
# where hooks live at .claude/hooks/. We generate the correct settings inline.
169169
if [ -f "$TARGET_DIR/.claude/settings.json" ]; then
170-
echo " .claude/settings.json already exists. Backing up to settings.json.bak"
171-
cp "$TARGET_DIR/.claude/settings.json" "$TARGET_DIR/.claude/settings.json.bak"
170+
_bak_name="settings.json.bak.$(date +%Y%m%d%H%M%S)"
171+
echo " .claude/settings.json already exists. Backing up to $_bak_name"
172+
cp "$TARGET_DIR/.claude/settings.json" "$TARGET_DIR/.claude/$_bak_name"
172173
fi
173174
cat > "$TARGET_DIR/.claude/settings.json" <<'SETTINGS'
174175
{
@@ -387,9 +388,11 @@ install_statusline() {
387388
fi
388389

389390
if [ -f "$statusline_script" ]; then
390-
# Backup existing script
391-
cp "$statusline_script" "$statusline_script.bak"
392-
echo " Statusline: backed up existing to statusline-command.sh.bak"
391+
# Backup existing script with timestamp
392+
local _sl_ts
393+
_sl_ts="$(date +%Y%m%d%H%M%S)"
394+
cp "$statusline_script" "$statusline_script.bak.$_sl_ts"
395+
echo " Statusline: backed up existing to statusline-command.sh.bak.$_sl_ts"
393396

394397
# Inject kova indicator by appending a block at the end of the existing script.
395398
# We append rather than splicing into the last line to avoid corrupting

0 commit comments

Comments
 (0)